788 lines
39 KiB
Python
788 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1] / "windows-node"
|
|
PROJECT = ROOT / "Zcbot.WindowsNode"
|
|
|
|
|
|
class WindowsNodeSourceTests(unittest.TestCase):
|
|
def test_project_targets_net10_wpf_with_windows_forms_interop(self) -> None:
|
|
tree = ET.parse(PROJECT / "Zcbot.WindowsNode.csproj")
|
|
root = tree.getroot()
|
|
self.assertEqual(root.findtext("./PropertyGroup/TargetFramework"), "net10.0-windows")
|
|
self.assertEqual(root.findtext("./PropertyGroup/UseWindowsForms"), "true")
|
|
self.assertEqual(root.findtext("./PropertyGroup/UseWPF"), "true")
|
|
self.assertEqual(root.findtext("./PropertyGroup/OutputType"), "WinExe")
|
|
self.assertEqual(
|
|
root.findtext("./PropertyGroup/ApplicationIcon"), "Assets\\zcbot.ico"
|
|
)
|
|
self.assertEqual(
|
|
[item.attrib["Include"] for item in root.findall("./ItemGroup/Resource")],
|
|
["Assets\\zcbot.ico"],
|
|
)
|
|
icon = PROJECT / "Assets" / "zcbot.ico"
|
|
self.assertTrue(icon.is_file())
|
|
self.assertEqual(icon.read_bytes()[:6], b"\x00\x00\x01\x00\x07\x00")
|
|
packages = root.findall("./ItemGroup/PackageReference")
|
|
self.assertEqual([item.attrib["Include"] for item in packages], ["JsonSchema.Net"])
|
|
|
|
def test_node_protocol_and_secret_storage_markers_are_present(self) -> None:
|
|
source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.rglob("*.cs"))
|
|
for marker in (
|
|
"v1/software-nodes/enroll",
|
|
"v1/software-nodes/connect",
|
|
'SetRequestHeader("Authorization"',
|
|
'SetRequestHeader("X-Node-Id"',
|
|
"DataProtectionScope.LocalMachine",
|
|
"SetAccessRuleProtection(isProtected: true",
|
|
'"adapter.json"',
|
|
"NotifyIcon",
|
|
"MainWindow",
|
|
"TrayIconFactory.Create",
|
|
'"--headless"',
|
|
):
|
|
self.assertIn(marker, source)
|
|
|
|
def test_gui_and_headless_share_the_composition_root(self) -> None:
|
|
program = (PROJECT / "Program.cs").read_text(encoding="utf-8")
|
|
composition = (
|
|
PROJECT / "Bootstrap" / "NodeCompositionRoot.cs"
|
|
).read_text(encoding="utf-8")
|
|
controller = (
|
|
PROJECT / "Application" / "NodeApplicationController.cs"
|
|
).read_text(encoding="utf-8")
|
|
|
|
self.assertEqual(program.count("new NodeCompositionRoot"), 2)
|
|
self.assertIn("CreateApplicationController()", program)
|
|
self.assertIn("RunHeadlessAsync(shutdown.Token)", program)
|
|
self.assertIn("new App(compositionRoot.CreateApplicationController())", program)
|
|
self.assertIn("return app.Run()", program)
|
|
self.assertNotIn("new TrayApplicationContext", program)
|
|
self.assertIn("new NodeConnectionLoop(config, Paths", composition)
|
|
self.assertIn("stopTask ??= StopOnceAsync(mode)", controller)
|
|
self.assertIn("await StopConnectionAsync()", controller)
|
|
|
|
def test_wpf_overview_registration_and_settings_use_view_model_commands(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
view_model = (
|
|
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
|
|
|
|
for binding in (
|
|
"ShowOverviewCommand",
|
|
"ShowSettingsCommand",
|
|
"RegisterCommand",
|
|
"ReconnectCommand",
|
|
"ResetIdentityCommand",
|
|
"IsStartupEnabled",
|
|
"DataRootPage.CurrentRoot",
|
|
):
|
|
self.assertIn(f"{{Binding {binding}", xaml)
|
|
self.assertIn('PasswordChanged="EnrollmentCodeBox_OnPasswordChanged"', xaml)
|
|
self.assertIn("CopyDiagnosticsCommand", xaml)
|
|
self.assertNotIn("OpenLegacyCommand", xaml + view_model)
|
|
self.assertNotIn("NodeApplicationController", xaml + view_model)
|
|
self.assertIn("interface IStartupRegistration", startup)
|
|
self.assertIn("RegisterRequested += RegisterFromWpfAsync", tray)
|
|
self.assertIn("ReconnectRequested += ReconnectFromWpfAsync", tray)
|
|
self.assertIn("ResetIdentityRequested += ResetIdentityFromWpfAsync", tray)
|
|
|
|
def test_wpf_software_page_uses_runtime_services_and_tray_dialogs(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
page = (
|
|
PROJECT / "Presentation" / "ViewModels" / "SoftwarePageViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
service = (
|
|
PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs"
|
|
).read_text(encoding="utf-8")
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
for binding in (
|
|
"ShowSoftwareCommand",
|
|
"SoftwarePage.RefreshCommand",
|
|
"SoftwarePage.CancelInstallCommand",
|
|
"SoftwarePage.Cards",
|
|
"ChooseLocationCommand",
|
|
"ClearLocationCommand",
|
|
"InstallRuntimeCommand",
|
|
):
|
|
self.assertIn(f"{{Binding {binding}", xaml)
|
|
self.assertIn("ISoftwareManagementService", page + service)
|
|
self.assertIn("SoftwareLocationService.SaveConfiguredPath", service)
|
|
self.assertIn("SoftwareLocationService.ClearConfiguredPath", service)
|
|
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
|
|
self.assertIn("AdapterCatalog.HasActiveExecution", service)
|
|
self.assertIn("ChooseLocationRequested += ChooseSoftwareLocation", tray)
|
|
self.assertIn("ConfirmInstallRequested += ConfirmRuntimeInstall", tray)
|
|
self.assertNotIn("OpenFileDialog", page + service)
|
|
|
|
def test_wpf_theme_has_selected_navigation_status_and_responsive_actions(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
theme = (
|
|
PROJECT / "Presentation" / "Themes" / "LightTheme.xaml"
|
|
).read_text(encoding="utf-8")
|
|
|
|
self.assertGreaterEqual(xaml.count("<ToggleButton"), 3)
|
|
self.assertIn('IsChecked="{Binding IsOverviewPage, Mode=OneWay}"', xaml)
|
|
self.assertIn('IsChecked="{Binding IsSoftwarePage, Mode=OneWay}"', xaml)
|
|
self.assertIn('IsChecked="{Binding IsSettingsPage, Mode=OneWay}"', xaml)
|
|
self.assertIn('Binding="{Binding StatusTone}"', xaml)
|
|
self.assertIn("SoftwarePage.IsBusy", xaml)
|
|
self.assertGreaterEqual(xaml.count("<WrapPanel"), 3)
|
|
self.assertIn("HasOperationMessage", xaml)
|
|
for token in (
|
|
"SidebarSelectedBrush",
|
|
"SuccessSurfaceBrush",
|
|
"WarningSurfaceBrush",
|
|
"ErrorSurfaceBrush",
|
|
"CardBorderStyle",
|
|
"SectionTitleStyle",
|
|
):
|
|
self.assertIn(token, theme)
|
|
self.assertIn('<ControlTemplate TargetType="Button">', theme)
|
|
self.assertIn('<ControlTemplate TargetType="ToggleButton">', theme)
|
|
|
|
def test_workspace_storage_is_global_by_workspace_not_user_directory(self) -> None:
|
|
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
|
|
store = (PROJECT / "WorkspaceStore.cs").read_text(encoding="utf-8")
|
|
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('Path.Combine(root, "workspaces")', models)
|
|
self.assertIn('Binding(job).WorkspaceId.ToString("D")', store)
|
|
self.assertNotIn("UserId", store)
|
|
self.assertIn('Path.Combine(root, "current")', store)
|
|
self.assertIn('Path.Combine(root, "rollback")', store)
|
|
self.assertIn("workspaceStore.Promote", runner)
|
|
self.assertIn("workspaceStore.Restore", runner)
|
|
|
|
def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None:
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray()",
|
|
connection,
|
|
)
|
|
self.assertIn("capabilities = installedCapabilities", connection)
|
|
self.assertIn("installed_capabilities = installedCapabilities", connection)
|
|
self.assertNotIn("config.Capabilities.Contains", connection)
|
|
self.assertNotIn("InstalledCapabilities.Contains", config_store)
|
|
|
|
def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None:
|
|
source = "\n".join(
|
|
path.read_text(encoding="utf-8")
|
|
for path in PROJECT.rglob("*.cs")
|
|
if path.name not in {
|
|
"AdapterExecutionService.cs",
|
|
"ProcessSupervisor.cs",
|
|
"ConfigurationForm.cs",
|
|
"TrayHost.cs",
|
|
"SoftwareLocationService.cs",
|
|
"ManagedRuntimeInstaller.cs",
|
|
}
|
|
)
|
|
for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"):
|
|
self.assertNotIn(forbidden, source)
|
|
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('"explorer.exe"', tray)
|
|
self.assertIn("startInfo.ArgumentList.Add(path)", tray)
|
|
self.assertNotIn("UseShellExecute = true", tray)
|
|
|
|
def test_data_root_is_configurable_and_migrated_before_switching(self) -> None:
|
|
settings = (PROJECT / "NodeDataRoot.cs").read_text(encoding="utf-8")
|
|
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
|
|
page = (
|
|
PROJECT / "Presentation" / "ViewModels" / "DataRootPageViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
controller = (
|
|
PROJECT / "Application" / "NodeApplicationController.cs"
|
|
).read_text(encoding="utf-8")
|
|
composition = (
|
|
PROJECT / "Bootstrap" / "NodeCompositionRoot.cs"
|
|
).read_text(encoding="utf-8")
|
|
|
|
self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings)
|
|
self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings)
|
|
self.assertIn("Environment.SpecialFolder.CommonApplicationData", settings)
|
|
self.assertIn('Directory.Exists(legacy)', settings)
|
|
self.assertLess(
|
|
settings.index("Environment.GetEnvironmentVariable"),
|
|
settings.index("Registry.CurrentUser.OpenSubKey"),
|
|
)
|
|
self.assertIn("NodeDataRootSettings.Resolve().RootDirectory", models)
|
|
self.assertIn("SHA256.HashData(stream)", settings)
|
|
self.assertIn("Directory.Move(staging, target)", settings)
|
|
self.assertLess(
|
|
settings.index("VerifyTree(source, staging"),
|
|
settings.index("Directory.Move(staging, target)"),
|
|
)
|
|
self.assertIn("MigrationRequested", page)
|
|
self.assertIn("service.HasPendingJobs", page)
|
|
self.assertIn("saveDataRoot(result.TargetDirectory)", controller)
|
|
self.assertIn("NodeDataRootSettings.SaveUserRoot", composition)
|
|
self.assertIn("controller.MigrateDataRootAsync(target)", tray)
|
|
self.assertIn("jobPipelines.Values.Concat(exportPipelines.Values)", connection)
|
|
self.assertIn("Path.GetPathRoot(paths.RootDirectory)", connection)
|
|
|
|
def test_config_field_names_do_not_serialize_plain_node_token(self) -> None:
|
|
models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
|
|
stored_record = models.split("internal sealed record StoredNodeConfig", 1)[1].split(");", 1)[0]
|
|
self.assertIn("ProtectedNodeToken", stored_record)
|
|
self.assertNotIn("string NodeToken", stored_record)
|
|
|
|
def test_configuration_ui_never_displays_or_copies_token(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
view_model = (
|
|
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
diagnostic_service = (
|
|
PROJECT / "Application" / "DiagnosticService.cs"
|
|
).read_text(encoding="utf-8")
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("<PasswordBox", xaml)
|
|
self.assertNotIn("NodeToken", xaml + view_model + diagnostic_service)
|
|
self.assertIn("Clipboard.SetText(text)", tray)
|
|
diagnostics = diagnostic_service.split("public string Build", 1)[1]
|
|
self.assertNotIn("NodeToken", diagnostics)
|
|
self.assertIn("内容不包含 Node Token", view_model)
|
|
self.assertIn("清除本机身份", xaml)
|
|
self.assertIn("管理后台删除或禁用云端旧节点", tray)
|
|
|
|
def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
view_model = (
|
|
PROJECT / "Presentation" / "ViewModels" / "AnsysAcceptanceViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
service = (
|
|
PROJECT / "Application" / "AnsysAcceptanceService.cs"
|
|
).read_text(encoding="utf-8")
|
|
adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
|
|
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("ANSYS Mechanical 真机验收", xaml)
|
|
self.assertIn("运行内置基准验收", xaml)
|
|
self.assertIn("ReportPassed", service)
|
|
self.assertIn('GateEnvironmentVariable,\n "1"', service)
|
|
self.assertIn("EnvironmentVariableTarget.Machine", service)
|
|
self.assertIn("SupportsLocalAcceptance", adapters)
|
|
self.assertIn('Path.Combine(descriptor.DirectoryPath, "acceptance.py")', runner)
|
|
self.assertIn(
|
|
'Path.Combine(descriptor.DirectoryPath, "acceptance-coupon.step")', runner
|
|
)
|
|
self.assertIn('["--work-root", root, "--repeat", "20"', runner)
|
|
self.assertIn("Adapter acceptance cannot run while a dispatched job is active", runner)
|
|
self.assertIn("service.RunAsync(workRoot", view_model)
|
|
self.assertNotIn("new OpenFileDialog", view_model + service)
|
|
self.assertNotIn("Process.Start", view_model + service)
|
|
|
|
def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
service = (
|
|
PROJECT / "Presentation" / "Services" / "SoftwareManagementService.cs"
|
|
).read_text(encoding="utf-8")
|
|
catalog = (PROJECT / "Runtime" / "SoftwareCatalog.cs").read_text(encoding="utf-8")
|
|
manager = (PROJECT / "Runtime" / "SoftwareLocationService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn('Text="本机专业软件"', xaml)
|
|
self.assertIn('Content="自动检测"', xaml)
|
|
self.assertIn('Content="选择位置"', xaml)
|
|
self.assertIn('Content="安装 / 更新环境"', xaml)
|
|
self.assertIn('IsReadOnly="True"', xaml)
|
|
self.assertIn("ManagedRuntimeInstaller.InstallAsync", service)
|
|
self.assertIn("item.HasActiveJobs", service)
|
|
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
|
|
self.assertIn(runtime_id, catalog)
|
|
self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
|
|
self.assertIn("RegistryHive.LocalMachine", manager)
|
|
self.assertIn("RegistryHive.CurrentUser", manager)
|
|
self.assertIn('"ZCBOT_BLENDER_EXE"', catalog)
|
|
self.assertIn('"AWP_ROOT242"', catalog)
|
|
self.assertIn('"ZCBOT_ORIGIN_EXE"', catalog)
|
|
self.assertIn('"Origin*.exe"', catalog)
|
|
self.assertIn("OriginExecutables", manager)
|
|
self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', installer)
|
|
self.assertIn("ArgumentList.Add(argument)", installer)
|
|
self.assertNotIn('"cmd.exe"', installer.lower())
|
|
self.assertNotIn('"powershell.exe"', installer.lower())
|
|
self.assertIn("SoftwareLocationService.ApplyProcessEnvironment", runner)
|
|
self.assertIn("AdapterCatalog.HasActiveExecution", installer)
|
|
|
|
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('Width="1040"', xaml)
|
|
self.assertIn('MinWidth="760"', xaml)
|
|
self.assertIn('MinHeight="560"', xaml)
|
|
self.assertIn("<ScrollViewer", xaml)
|
|
self.assertIn('Content="注册并连接"', xaml)
|
|
self.assertIn('Content="节点概览"', xaml)
|
|
self.assertIn('Content="专业软件"', xaml)
|
|
self.assertIn('Content="本机任务"', xaml)
|
|
self.assertIn('Content="运行设置"', xaml)
|
|
self.assertIn("<DataGrid", xaml)
|
|
self.assertFalse((PROJECT / "ConfigurationForm.cs").exists())
|
|
self.assertFalse((PROJECT / "TrayApplicationContext.cs").exists())
|
|
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertNotIn("legacyForm", tray)
|
|
self.assertIn("statusItem.Text = CompactMenuText(status.Message, 24)", tray)
|
|
self.assertIn('singleLine[..(length - 1)] + "…"', tray)
|
|
|
|
def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> None:
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
host_loop = (PROJECT / "Host" / "NodeConnectionLoop.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
controller = (
|
|
PROJECT / "Application" / "NodeApplicationController.cs"
|
|
).read_text(encoding="utf-8")
|
|
|
|
self.assertIn("private async Task ExitNodeAsync()", tray)
|
|
self.assertIn("if (exiting)", tray)
|
|
self.assertIn("controller.ActiveJobCount", tray)
|
|
self.assertIn("MessageBoxButtons.YesNoCancel", tray)
|
|
self.assertIn("NodeShutdownMode.CancelJobs", tray)
|
|
self.assertIn("tray.ContextMenuStrip.Enabled = false", tray)
|
|
self.assertLess(tray.index("await controller.StopAsync"), tray.index("shutdown();"))
|
|
self.assertIn("stopTask ??= StopOnceAsync(mode)", controller)
|
|
self.assertIn("connection?.CancelActiveJobsForExit()", controller)
|
|
self.assertIn("await StopConnectionAsync()", controller)
|
|
self.assertIn("public void CancelActiveJobsForExit()", host_loop)
|
|
self.assertIn("coordinator.CancelActiveJobsForExit()", host_loop)
|
|
self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection)
|
|
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
|
|
self.assertIn('"NODE_EXIT_CANCELLED"', connection)
|
|
self.assertIn("catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)", connection)
|
|
|
|
def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None:
|
|
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
|
|
monitor = (
|
|
PROJECT / "Application" / "JobMonitorService.cs"
|
|
).read_text(encoding="utf-8")
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
models = (PROJECT / "JobMonitorModels.cs").read_text(encoding="utf-8")
|
|
repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
state_machine = (PROJECT / "Jobs" / "JobStateMachine.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('"state.json"', monitor)
|
|
self.assertIn("ReadSnapshots", monitor)
|
|
self.assertIn("AtomicFile.Write(path, content, overwrite: true)", repository)
|
|
for stage in (
|
|
"accepted",
|
|
"downloading_inputs",
|
|
"ready_to_run",
|
|
"software_running",
|
|
"uploading_outputs",
|
|
"succeeded",
|
|
"failed",
|
|
"cancelled",
|
|
):
|
|
self.assertIn(
|
|
f'"{stage}"',
|
|
inbox + monitor + repository + state_machine + connection,
|
|
)
|
|
self.assertIn("JobDisplaySnapshot", models)
|
|
self.assertNotIn("origin_running", inbox + monitor + connection + models)
|
|
|
|
def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None:
|
|
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
|
|
xaml = (PROJECT / "Presentation" / "MainWindow.xaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
view_model = (
|
|
PROJECT / "Presentation" / "ViewModels" / "MainWindowViewModel.cs"
|
|
).read_text(encoding="utf-8")
|
|
installer = (PROJECT / "Runtime" / "ManagedRuntimeInstaller.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertFalse((ROOT / "install-windows-node.bat").exists())
|
|
self.assertIn('Content="登录 Windows 后自动启动节点"', xaml)
|
|
self.assertIn("startupRegistration.SetEnabled", view_model)
|
|
self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup)
|
|
self.assertIn("Environment.ProcessPath", startup)
|
|
self.assertIn("FindPython312Async", installer)
|
|
self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', installer)
|
|
self.assertIn('new BootstrapPython("python.exe", [])', installer)
|
|
self.assertIn('"ZCBOT_PIP_INDEX_URL"', installer)
|
|
self.assertIn(
|
|
'"https://pypi.tuna.tsinghua.edu.cn/simple/"', installer
|
|
)
|
|
|
|
def test_publish_output_contains_the_complete_node_payload(self) -> None:
|
|
project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
|
|
self.assertNotIn("install-windows-node.bat", project)
|
|
self.assertIn("..\\adapters\\**\\*", project)
|
|
self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
|
|
self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
|
|
self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project)
|
|
self.assertIn("..\\..\\software-contracts\\origin.analysis.v1.json", project)
|
|
self.assertIn("adapters\\origin.analysis@v1\\origin.analysis.v1.json", project)
|
|
self.assertIn(
|
|
"..\\..\\software-contracts\\ansys.geometry.inspect.v1.json",
|
|
project,
|
|
)
|
|
self.assertIn(
|
|
"adapters\\ansys.geometry.inspect@v1\\ansys.geometry.inspect.v1.json",
|
|
project,
|
|
)
|
|
self.assertIn(
|
|
"..\\..\\software-contracts\\ansys.mechanical.static_structural.v2.json",
|
|
project,
|
|
)
|
|
self.assertIn(
|
|
"adapters\\ansys.mechanical.static_structural@v2\\"
|
|
"ansys.mechanical.static_structural.v2.json",
|
|
project,
|
|
)
|
|
self.assertIn(
|
|
"..\\..\\software-contracts\\blender.scene.author.v3.json",
|
|
project,
|
|
)
|
|
self.assertIn(
|
|
"adapters\\blender.scene.author@v3\\blender.scene.author.v3.json",
|
|
project,
|
|
)
|
|
self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
|
|
self.assertFalse((ROOT / "install-windows-node.ps1").exists())
|
|
self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
|
|
self.assertFalse((ROOT / "install-startup.ps1").exists())
|
|
|
|
def test_packager_defaults_to_framework_dependent_verified_zip(self) -> None:
|
|
script = (ROOT / "package-windows-node.bat").read_text(encoding="utf-8")
|
|
self.assertNotIn("powershell", script.lower())
|
|
self.assertIn('set "SELF_CONTAINED=false"', script)
|
|
self.assertIn('if /I "%~1"=="--self-contained"', script)
|
|
self.assertIn("dotnet publish", script)
|
|
self.assertNotIn('"install-windows-node.bat"', script)
|
|
self.assertIn("start Zcbot.WindowsNode.exe", script)
|
|
self.assertIn('for /r "%PUBLISH_DIR%\\adapters" %%F in (adapter.json)', script)
|
|
self.assertIn('do if exist "%%~fF"', script)
|
|
self.assertIn("ADAPTER_CONTRACT_COUNT", script)
|
|
self.assertIn("Published adapter has no colocated contract", script)
|
|
self.assertNotIn('"!PUBLISH_DIR!\\software-contracts\\*.json"', script)
|
|
self.assertIn("tar.exe -a -c -f", script)
|
|
self.assertIn("certutil.exe -hashfile", script)
|
|
self.assertFalse((ROOT / "package-windows-node.ps1").exists())
|
|
|
|
def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None:
|
|
client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
session = (PROJECT / "Host" / "NodeSession.cs").read_text(encoding="utf-8")
|
|
self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', client)
|
|
self.assertIn('or HttpStatusCode.Forbidden', client)
|
|
self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", session)
|
|
self.assertIn("catch (NodeEndpointException exception)", session)
|
|
self.assertIn("throw new NodeEndpointException", client)
|
|
self.assertIn("(int?)result.CloseStatus == 4003", client)
|
|
self.assertIn("节点身份已被服务端拒绝", client)
|
|
self.assertNotIn("Node credentials were rejected", client + session)
|
|
|
|
def test_adapter_runtime_probe_is_worker_owned_and_reported(self) -> None:
|
|
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
worker = (
|
|
ROOT / "adapters" / "origin.plot@v2" / "worker.py"
|
|
).read_text(encoding="utf-8")
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
|
|
self.assertIn('["--probe"]', runner)
|
|
self.assertIn('root.GetProperty("adapter_version")', runner)
|
|
self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
|
|
self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
|
|
diagnostics = (
|
|
PROJECT / "Application" / "DiagnosticService.cs"
|
|
).read_text(encoding="utf-8")
|
|
self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', diagnostics)
|
|
self.assertIn(
|
|
"&& !jobInbox.HasPendingJobs",
|
|
connection,
|
|
)
|
|
self.assertIn("originAdapter?.HasActiveJobs", connection)
|
|
self.assertIn(
|
|
"ReadRecoverableJobs().Any(item => item.Terminal is null)",
|
|
(PROJECT / "Jobs" / "JobRepository.cs").read_text(encoding="utf-8"),
|
|
)
|
|
self.assertNotIn("CreateInstance", worker)
|
|
for marker in (
|
|
"software_version = origin.SoftwareVersion",
|
|
"adapter_version = origin.AdapterVersion",
|
|
"health = origin.Health",
|
|
"detail = origin.Detail",
|
|
):
|
|
self.assertIn(marker, connection)
|
|
|
|
def test_job_offer_is_persisted_before_acceptance(self) -> None:
|
|
inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
|
|
monitor = (
|
|
PROJECT / "Application" / "JobMonitorService.cs"
|
|
).read_text(encoding="utf-8")
|
|
repository = (PROJECT / "Jobs" / "JobRepository.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
recovery = (PROJECT / "Jobs" / "JobRecoveryService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
atomic_file = (PROJECT / "Persistence" / "AtomicFile.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
protocol_client = (PROJECT / "Protocol" / "NodeProtocolClient.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
|
|
self.assertIn("adapters.Find(capability)", inbox)
|
|
self.assertIn("adapter.ValidateRequest(request)", inbox)
|
|
self.assertIn(
|
|
'JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone())',
|
|
adapters,
|
|
)
|
|
self.assertIn("var contractPath = ResolveFile(root, manifest.Contract)", adapters)
|
|
self.assertNotIn("ResolveContractFile", adapters)
|
|
self.assertNotIn('AppContext.BaseDirectory, "software-contracts"', adapters)
|
|
self.assertNotIn("IsValidOriginRequest", inbox)
|
|
self.assertNotIn("IsValidPlot", inbox)
|
|
self.assertIn('root.TryGetProperty("input_transfers"', inbox + monitor)
|
|
self.assertIn('root.TryGetProperty("request_summary"', inbox + monitor)
|
|
self.assertIn("ReadDisplayTitle(root)", monitor)
|
|
self.assertIn("foreach (var action in operation.EnumerateObject())", monitor)
|
|
self.assertIn("transfers.GetArrayLength() > 16", inbox)
|
|
self.assertNotIn("transfers.GetArrayLength() is < 1 or > 16", inbox)
|
|
self.assertIn('"input", key, filename', repository)
|
|
self.assertIn("FileOptions.WriteThrough", atomic_file)
|
|
self.assertIn("stream.Flush(flushToDisk: true)", atomic_file)
|
|
new_record = inbox.split("var record =", 1)[1].split("private static JsonElement?", 1)[0]
|
|
self.assertLess(
|
|
new_record.index("AtomicFile.Write(requestPath, record"),
|
|
new_record.index("JobOfferResult.Accept"),
|
|
)
|
|
self.assertIn('offerResult.Accepted ? "job_accept" : "job_reject"', connection)
|
|
self.assertIn("sendLock.WaitAsync", protocol_client)
|
|
self.assertIn("!jobInbox.HasPendingJobs", connection)
|
|
self.assertIn("capability_runtime = capabilityRuntime", connection)
|
|
self.assertIn("adapter.RunAsync(job)", connection)
|
|
self.assertIn("ReportRecoverableJobsAsync", connection)
|
|
self.assertIn("ConcurrentDictionary<Guid, Task> jobPipelines", connection)
|
|
self.assertIn("StartJobPipeline(client, acceptedJob)", connection)
|
|
self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
|
|
self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection)
|
|
self.assertIn('stage = "uploading_outputs"', connection)
|
|
self.assertIn('stage = "software_running"', connection)
|
|
self.assertNotIn('stage = "origin_running"', connection)
|
|
self.assertIn("JobRecoveryAction.ResumeOutputUpload", connection)
|
|
self.assertIn("job.UploadComplete || job.CloudTerminal", recovery)
|
|
self.assertIn(
|
|
"await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray())",
|
|
connection,
|
|
)
|
|
self.assertIn("StartJobPipeline(client, job, recovery: true)", connection)
|
|
self.assertIn('stage = "downloading_inputs"', connection)
|
|
self.assertIn('Path.Combine(jobDirectory, "terminal.json")', repository)
|
|
self.assertIn("AtomicFile.Write(requestPath, updated, overwrite: true)", inbox)
|
|
|
|
downloader = (PROJECT / "JobInputDownloader.cs").read_text(encoding="utf-8")
|
|
http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client)
|
|
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client)
|
|
self.assertIn("SendWithRetryAsync", downloader)
|
|
self.assertIn("HttpCompletionOption.ResponseHeadersRead", downloader)
|
|
self.assertIn("IncrementalHash.CreateHash", downloader)
|
|
self.assertIn("total > expectedSize", downloader)
|
|
self.assertIn("File.Move(temporaryPath, destination, overwrite: false)", downloader)
|
|
self.assertNotIn("Process.Start", downloader)
|
|
|
|
def test_adapter_worker_launch_is_manifest_driven_and_terminal_driven(self) -> None:
|
|
runner = (PROJECT / "Adapters" / "AdapterExecutionService.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
supervisor = (PROJECT / "Adapters" / "ProcessSupervisor.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
|
|
worker = (
|
|
ROOT / "adapters" / "origin.plot@v2" / "worker.py"
|
|
).read_text(encoding="utf-8")
|
|
self.assertIn('Environment.GetEnvironmentVariable("ZCBOT_ORIGIN_PYTHON")', runner)
|
|
self.assertIn(
|
|
'Path.Combine(paths.RootDirectory, "runtimes", runtimeId, "Scripts", "python.exe")',
|
|
runner,
|
|
)
|
|
self.assertIn("UseShellExecute = false", runner)
|
|
self.assertIn("command.PrefixArguments.Concat(arguments)", runner)
|
|
self.assertIn("descriptor.EntrypointPath", runner)
|
|
self.assertIn('Path.Combine(jobDirectory, "terminal.json")', runner)
|
|
self.assertIn('"NODE_RESTARTED_DURING_JOB"', runner)
|
|
self.assertIn("CancellationTokenSource.CreateLinkedTokenSource", runner)
|
|
self.assertIn("process.Kill(entireProcessTree: true)", supervisor)
|
|
self.assertIn('message.Type == "job_cancel"', connection)
|
|
self.assertIn('"cancelled", "USER_CANCELLED"', connection)
|
|
self.assertIn("..\\adapters\\**\\*", project)
|
|
self.assertIn("descriptor.Manifest.WorkerTimeoutMinutes", runner)
|
|
adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
'OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440)',
|
|
adapters,
|
|
)
|
|
self.assertIn("if op.oext:", worker)
|
|
self.assertIn("op.exit()", worker)
|
|
self.assertIn("op.new_graph", worker)
|
|
self.assertIn("layer.add_plot", worker)
|
|
self.assertIn("op.save", worker)
|
|
self.assertIn("graph.save_fig", worker)
|
|
self.assertIn('_atomic_json(job_dir / "terminal.json"', worker)
|
|
for forbidden in ("subprocess", "eval(", "exec(", "os.system"):
|
|
self.assertNotIn(forbidden, worker)
|
|
|
|
def test_origin_adapter_can_be_packaged_without_building_node(self) -> None:
|
|
script = (ROOT / "package-origin-adapter.bat").read_text(encoding="utf-8")
|
|
connection = (PROJECT / "Jobs" / "JobCoordinator.cs").read_text(encoding="utf-8")
|
|
self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
|
|
self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script)
|
|
self.assertIn('"adapters\\origin.plot@v2\\acceptance.py"', script)
|
|
self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
|
|
self.assertNotIn("dotnet", script.lower())
|
|
|
|
diagnostics = (
|
|
PROJECT / "Application" / "DiagnosticService.cs"
|
|
).read_text(encoding="utf-8")
|
|
self.assertIn("Contract SHA-256: {adapter.ContractSha256}", diagnostics)
|
|
self.assertIn("Workspace protocol:", diagnostics)
|
|
self.assertIn("Workspace state:", diagnostics)
|
|
self.assertIn("workspace_protocol_version", connection)
|
|
self.assertIn("contract_sha256", connection)
|
|
|
|
uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8")
|
|
http_client = (PROJECT / "Transfers" / "NodeHttpClient.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', http_client)
|
|
self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', http_client)
|
|
self.assertIn('request.Headers.Add("X-Lease-Id"', http_client)
|
|
self.assertIn("SHA256.HashDataAsync", uploader)
|
|
self.assertIn("upload-complete.json", connection + uploader)
|
|
self.assertIn("adapter.PreviewOutputIds", connection)
|
|
self.assertIn("adapter.WorkspaceStateFilename", connection)
|
|
self.assertIn("WorkspaceSize(jobDirectory), allowConflict: true", uploader)
|
|
self.assertIn("completeResponse.StatusCode == HttpStatusCode.Conflict", uploader)
|
|
self.assertIn('"JOB_ALREADY_TERMINAL"', uploader)
|
|
self.assertIn('"cloud-terminal.json"', uploader)
|
|
self.assertIn("OpenOutputWithRetryAsync", uploader)
|
|
self.assertIn("IsSharingViolation", uploader)
|
|
self.assertIn("(exception.HResult & 0xFFFF) is 32 or 33", uploader)
|
|
self.assertIn("artifact={artifactId}", uploader)
|
|
self.assertIn("hresult=0x{exception.HResult:X8}", connection + uploader)
|
|
self.assertIn('"node-output-upload.log"', uploader)
|
|
self.assertIn("MaxDiagnosticLogBytes", uploader)
|
|
self.assertIn("outputUploader.RecordDeferred(current, exception)", connection)
|
|
self.assertIn('completionPath + ".pending"', uploader)
|
|
self.assertIn("PreparePendingCompletion(pendingPath, responseBody)", uploader)
|
|
self.assertIn("File.Move(pendingPath, completionPath, overwrite: false)", uploader)
|
|
self.assertIn("operation=move_pending", uploader)
|
|
self.assertNotIn('".tmp-" + Guid.NewGuid()', uploader)
|
|
self.assertNotIn("Process.Start", uploader)
|
|
|
|
tray = (PROJECT / "Presentation" / "Tray" / "TrayHost.cs").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
controller = (
|
|
PROJECT / "Application" / "NodeApplicationController.cs"
|
|
).read_text(encoding="utf-8")
|
|
self.assertNotIn("legacyForm", tray)
|
|
self.assertIn("window.ShowAndActivate()", tray)
|
|
self.assertIn(
|
|
'Publish(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))',
|
|
controller,
|
|
)
|
|
|
|
def test_origin_analysis_adapter_can_be_packaged_without_building_node(self) -> None:
|
|
script = (ROOT / "package-origin-analysis-adapter.bat").read_text(encoding="utf-8")
|
|
self.assertIn('"adapters\\origin.analysis@v1\\adapter.json"', script)
|
|
self.assertIn('"adapters\\origin.analysis@v1\\worker.py"', script)
|
|
self.assertIn('"adapters\\origin.analysis@v1\\acceptance.py"', script)
|
|
self.assertIn('"..\\software-contracts\\origin.analysis.v1.json"', script)
|
|
self.assertNotIn("dotnet", script.lower())
|
|
|
|
worker = (
|
|
ROOT / "adapters" / "origin.analysis@v1" / "worker.py"
|
|
).read_text(encoding="utf-8")
|
|
for forbidden in ("eval(", "exec(", "os.system"):
|
|
self.assertNotIn(forbidden, worker)
|
|
self.assertIn("op.LinearFit()", worker)
|
|
self.assertIn("op.save", worker)
|
|
self.assertIn('if op.oext:', worker)
|
|
self.assertIn("op.exit()", worker)
|
|
|
|
def test_blender_adapter_can_be_packaged_without_building_node(self) -> None:
|
|
script = (ROOT / "package-blender-adapter.bat").read_text(encoding="utf-8")
|
|
self.assertIn("adapters\\blender.scene.author@v3", script)
|
|
self.assertIn("\\worker.py", script)
|
|
self.assertIn("\\blender_worker.py", script)
|
|
self.assertIn("\\scene_recipe.py", script)
|
|
self.assertIn("\\rotary_kiln.py", script)
|
|
self.assertIn("..\\software-contracts\\blender.scene.author.v3.json", script)
|
|
self.assertIn("tar.exe -a -c -f", script)
|
|
self.assertNotIn("dotnet", script.lower())
|
|
|
|
def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
|
|
monitor = (
|
|
PROJECT / "Application" / "JobMonitorService.cs"
|
|
).read_text(encoding="utf-8")
|
|
self.assertIn("state.UpdatedAt > acceptedAt", monitor)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|