diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eceb743ed..382eab99a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,6 +54,11 @@ These are the canonical homes. Do not reintroduce private copies elsewhere. | Test env var save/restore | `OpenClaw.TestSupport.EnvironmentScope` | authoritative | | CLI stdout/stderr/env capture | `OpenClaw.TestSupport.CliHarness` | authoritative | | Loopback MCP server for tests | `OpenClaw.TestSupport.FakeMcpServer` | authoritative | +| Authenticated MCP HTTP client for app fixtures | `OpenClaw.TestSupport.McpClient` | authoritative | +| Synthetic Gateway protocol/scenarios | `OpenClaw.TestSupport.Gateway.FixtureGatewayServer` + `GatewayScenario` | authoritative | +| Fixture-backed app profile/process lifetime | `OpenClaw.GatewayFixtureHost.GatewayFixtureProfile` + `GatewayFixtureRun` | authoritative | +| Explicit fixture context and host-effect isolation gate | `OpenClaw.Shared.GatewayFixtureIsolation` | authoritative | +| Passive fixture chat-render acknowledgement | `GatewayFixtureRenderObservation` (pure metadata) + `ReactorChatComposer` (UI applicator) | authoritative | | Gateway record test data | `OpenClaw.Connection.Tests.GatewayRecordBuilder` | authoritative | | Settings test data | `OpenClaw.TestSupport.SettingsDataBuilder` | authoritative | | JSON `JsonElement` coercion (non-nullable fallback family) | `JsonReadHelpers` | authoritative | @@ -156,6 +161,8 @@ leading and trailing pipe. Columns, in order: | test-env-scope | authoritative | scattered test files | hand-rolled env var save/restore in migrated tests | OpenClaw.TestSupport.EnvironmentScope | pre-existing un-migrated tests until adopted | env vars set in a test are restored on dispose | TestSupportFixtureTests.EnvironmentScope_RestoresOriginal | behavioral | when all env-mutating tests are migrated | | test-cli-harness | authoritative | CLI test projects | duplicated stdout/stderr/env capture tuples | OpenClaw.TestSupport.CliHarness | - | stdout/stderr/env lookup are captured consistently | TestSupportFixtureTests.CliHarness_CapturesAndLooksUp | behavioral | when CLI tests adopt the harness | | test-fake-mcp | authoritative | OpenClaw.WinNode.Cli.Tests | private internal FakeMcpServer copy | OpenClaw.TestSupport.FakeMcpServer | - | one loopback MCP server captures method/body/auth and returns canned/timeout responses | TestSupportFixtureTests.FakeMcpServer_CapturesRequest | behavioral | when all MCP-round-trip tests share it | +| test-app-mcp-client | authoritative | tests/OpenClaw.Tray.IntegrationTests/McpClient.cs | authenticated MCP request/response helper | OpenClaw.TestSupport.McpClient | original app tests retain the same API through a shared namespace import | fixture-host and original MCP integration consumers share request correlation, bearer handling and tool-error parsing without changing original fixture defaults | GatewayFixtureAppTests.RealOperatorPopulatesSessionsWithoutEnablingNodeExecution | behavioral | - | +| gateway-fixture-isolation | authoritative | App startup and service boundaries | implicit isolated-profile safety assumptions | OpenClaw.Shared.GatewayFixtureIsolation | composition-root validation and narrow service checks | explicit fixture mode requires valid absolute profile and setup-local overrides; ordinary isolated mode is unchanged | GatewayFixtureIsolationTests.Get_MissingOrRelativeRoot_ThrowsWithoutFallback | behavioral | - | | test-gateway-builder | authoritative | OpenClaw.Connection.Tests | per-file MakeRecord(id,url) helpers | OpenClaw.Connection.Tests.GatewayRecordBuilder | pre-existing MakeRecord until migrated | gateway record test data has one builder | TestSupportFixtureTests.GatewayRecordBuilder_BuildsRecord | behavioral | when MakeRecord helpers are removed | | test-settings-builder | authoritative | scattered test files | ad hoc SettingsData construction in migrated tests | OpenClaw.TestSupport.SettingsDataBuilder | pre-existing un-migrated tests until adopted | settings test data starts from production defaults | TestSupportFixtureTests.SettingsDataBuilder_StartsFromDefaults | behavioral | when settings tests adopt the builder | | json-read-helpers | authoritative | OpenClaw.Shared (multiple files) | duplicate non-nullable fallback-returning JsonElement getters | JsonReadHelpers | null-sentinel / non-negative / whitespace-absent / trimming variants stay separate | canonical non-nullable fallback JSON coercion; divergent-contract helpers are not blindly routed here | JsonReadHelpersTests.GetString_ReturnsNull_WhenPropertyMissing | behavioral | when the non-nullable fallback getters are all routed here | @@ -197,7 +204,7 @@ leading and trailing pipe. Columns, in order: | app-activation-router-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete deep-link IPC, toast argument routing, and single-instance forwarding production logic | ActivationRouter | App.ActivationRouter.cs implements IActivationPlanSink only, dispatching one typed plan per activation | App does not regain a parallel activation production path outside ActivationRouter | AppRefactorContractTests.ToastActivation_RoutesOnUiThread | source-shape | when App is replaced as the WinUI composition root | | app-settings-change-coordinator | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | detached snapshot comparison, SettingsChangeClassifier use, concurrent save serialization, and the full post-save effect order | SettingsChangeCoordinator | App supplies the existing effects as delegates and triggers synchronous Apply from one explicit post-save call | browser proxy sync, reconnect, MCP, hotkey, autostart, telemetry, and surface notification order is preserved; MCP-only behavior and credential precedence are unaffected | SettingsChangeCoordinatorTests.Apply_GatewayUrlChange_PreparesBeforeReconnect | behavioral | - | | app-settings-change-coordinator-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | OnSettingsSaved impact classification, reconnect switch, and inline effect ordering | SettingsChangeCoordinator | App.SettingsChangeCoordinator.cs wires effect delegates only; OnSettingsSaved forwards to Apply | App does not regain a parallel settings-change orchestration path outside SettingsChangeCoordinator | PresentationSeamContractTests.App_AppliesToolCallVisibilityFromPersistedSettings | source-shape | when App is replaced as the WinUI composition root | -| autostart-settings-applier | authoritative | src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs | post-save auto-start preference read and Windows write | AutoStartSettingsApplier | App supplies its shared mutation gate, live preference reader, OS setter, and background fault observer | settings-save effects read the current preference only after acquiring the toggle and reconciliation gate and hold it until the OS write completes | AutoStartSettingsApplierTests.ApplyLatestAsync_QueuedSave_ReadsPreferenceAfterGateAcquisition | behavioral | - | +| autostart-settings-applier | authoritative | src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs | post-save auto-start preference read and Windows write | AutoStartSettingsApplier | App supplies its shared mutation gate, live preference reader, OS setter, and background fault observer | ordinary settings-save effects read the current preference only after acquiring the toggle and reconciliation gate and hold it until the OS write completes; explicit valid fixture runs skip this background host refresh | AutoStartSettingsApplierTests.ApplyLatestAsync_QueuedSave_ReadsPreferenceAfterGateAcquisition | behavioral | - | | autostart-settings-direct-write-closed | closed | src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs | direct ungated auto-start write from a saved SettingsData snapshot | AutoStartSettingsApplier | effect delegate wiring only; startup reconciliation and explicit toggles retain their existing shared gate | post-save effects cannot replay stale snapshots over newer toggle or reconciliation results | MsixDevelopmentSigningTests.SettingsSaveAutoStart_UsesSharedGateAndLivePreference | source-shape | when the WinUI adapter is exercised directly by behavioral tests | | app-shutdown-coordinator | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | first-wins shared shutdown task, ordered step execution, and per-step log/catch/continue | AppShutdownCoordinator | App builds the immutable step plan from services it owns, including activation null-before-await and failure-safe captured-resource nulling, and constructs the BeginShutdown/ExitApplication actions | shutdown steps run in the same order exactly once even under concurrent callers; each step logs and continues past failure; Exit is called exactly once after all steps | AppShutdownCoordinatorTests.ShutdownAsync_RunsBeginStepsThenExit_InOrder | behavioral | - | | app-shutdown-coordinator-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | the _isExiting bool guard, SafeShutdownStep/SafeShutdownStepAsync helpers, and inline ExitApplicationAsync body | AppShutdownCoordinator | App.AppShutdownCoordinator.cs builds the step plan only; ExitApplicationAsync forwards to ShutdownAsync | App does not regain a parallel exactly-once shutdown guard or step-execution loop outside AppShutdownCoordinator | AppRefactorContractTests.Shutdown_Order_PreservesAwaitedTeardownBeforeExit | source-shape | when App is replaced as the WinUI composition root | diff --git a/docs/GATEWAY_FIXTURE_TESTING.md b/docs/GATEWAY_FIXTURE_TESTING.md new file mode 100644 index 000000000..db74610b8 --- /dev/null +++ b/docs/GATEWAY_FIXTURE_TESTING.md @@ -0,0 +1,184 @@ +# Fixture-backed application tests + +The fixture Gateway fills the gap between unit/component tests and full system +E2E. It runs the real Windows app, Gateway client, chat provider, history loader +and native UI against a deterministic loopback WebSocket server. There is no AI, +WSL Gateway, provider account, real pairing, or live-network fallback. + +The same `multi-session-browse` scenario powers interactive exploration and +automated tests. Its five sessions include an empty conversation, distinct +session identities and titles, and 240 mixed-height messages with an explicit +final-message marker. Supporting model, agent, health, usage and configuration +responses populate the application rather than just its disconnected shell. + +## Build and explore + +Build the current app first, then pass its executable explicitly. An installed +app is never discovered or selected implicitly. + +```powershell +.\build.ps1 +$app = '.\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.22621.0\win-x64\OpenClaw.Tray.WinUI.exe' +.\scripts\run-gateway-fixture.ps1 -AppPath $app +``` + +Use `win-arm64` instead of `win-x64` on a native ARM64 host. The launcher prints +the app PID, isolated profile, Gateway/MCP endpoints and artifact directory. +Leave it running while browsing Chat, Sessions, Settings and Configuration. +Press Ctrl+C to stop only this run and remove its synthetic profile. +For an unattended, bounded exploration/capture run, add `-DurationSeconds 30`. +The timer starts after readiness and uses the same cleanup path as Ctrl+C. + +The Gateway is read-only. Chat sends, session mutations and configuration +writes receive protocol errors, not simulated success. Local preferences such +as chat tool visibility can be saved inside the disposable profile. This is +not a fixture for the legacy WebView Gateway dashboard, installation, +uninstallation, host management, or node command execution. + +The host is `tests\OpenClaw.GatewayFixtureHost`, a plain .NET console app, not a +test container. The request-driven server and scenario live in +`tests\OpenClaw.TestSupport\Gateway`. Repeated reads and different request +orders work; this is deliberately not sequential packet playback. + +## Run the application smoke + +```powershell +.\scripts\test-gateway-fixture.ps1 -AppPath $app +``` + +For screenshots, use the already installed Windows App CLI: + +```powershell +.\scripts\test-gateway-fixture.ps1 -AppPath $app -Screenshots +``` + +The script runs profile/preflight and polling tests, real-process MCP tests, and native UI +tests. It rejects failed, skipped, missing-report and zero-test runs. It needs +a Windows desktop and the same WinUI prerequisites as the existing UI suite. +There is no skip-as-success fallback when the desktop is unavailable. + +The tests use local MCP for discovery, startup state and page navigation, and +UI Automation for the actual session-picker flyout, scrolling and settings +controls. They verify: + +- Real operator connection and populated sessions with node execution off. +- Independent profiles, endpoints, tokens and preferences in two concurrent + application instances. Stopping one must leave the other usable. +- Repeated A/B session selection and session-specific visible histories. +- Natural initial-tail visibility of message 240 before any explicit scroll + command, plus final-message visibility after scrolling at two window sizes. +- Sessions/Settings/Configuration/Chat navigation, profile-only preference + persistence, and the Sessions-page Open in chat action. +- A deliberately held history response arriving after a different session is + selected, without replacing the visible transcript. + +`app.chat.snapshot` is supporting evidence, not a rendering assertion. It +returns only the last 30 entries and does not prove the mounted UI selection. +The UI smoke checks the actual selected control and final text bounds within +the transcript viewport, rather than merely checking the current scroll extent. +For the delayed-history test, a passive `ChatComposerSessionPicker` UI Automation +`ItemStatus` acknowledgement records which loaded-history keys the render +consumed. It contains no messages, is empty outside explicit fixture mode, +and never changes chat state. The test waits for this render acknowledgement +before checking that the previously selected session is still visible. +Each UI test also has an outer deadline so a blocked synchronous UI Automation +call cannot prevent failure reporting and owned-process cleanup. + +Inspect the captured PNGs when claiming visual proof. UI Automation alone does +not detect every overlap, clipping or theme defect. + +## Known runtime regression + +The strict cached-session tail assertion has exposed an intermittent failure: +after A -> B -> A, the long session can remain at messages 1-3 instead of its +previously visible final message. A Release run captured this state while the +Gateway returned the correct histories successfully. The cause is still under +investigation, including potential automation timing effects. + +[Issue #1437](https://github.com/openclaw/openclaw-windows-node/issues/1437) +tracks the separate fix. Keep the assertion and failing artifacts; do not +substitute an earlier passing run, introduce blind retries, or skip the case. +The harness change deliberately does not change production scrolling to make +the test green. Desktop CI promotion remains separate from providing the +opt-in smoke entry point. + +## Production-shaped Release proof + +Debug and DevBuild are not interchangeable with the production runtime. Run +the same smoke against a non-Dev Release build without an attached debugger: + +```powershell +.\build.ps1 -Project WinUI -Configuration Release +$releaseApp = '.\src\OpenClaw.Tray.WinUI\bin\Release\net10.0-windows10.0.22621.0\win-x64\OpenClaw.Tray.WinUI.exe' +.\scripts\test-gateway-fixture.ps1 -AppPath $releaseApp -Configuration Release -Screenshots +``` + +An explicitly selected unpackaged publish executable is also supported. This +does not prove installer/MSIX behavior, real Gateway pairing, streaming, +provider behavior, or MXC. Keep the existing full E2E proof for those changes. + +## Isolation contract + +Each run generates a fresh temporary root, profile, Gateway record, credentials +and identities. It never copies installed settings, pairings or chat caches. +The child receives its own environment rather than changing the caller's +environment. All inherited `OPENCLAW_*` overrides are removed before the +explicit fixture environment is supplied. + +The important controls are: + +| Control | Purpose | +| --- | --- | +| `OPENCLAW_GATEWAY_FIXTURE=1` | Explicit host-side-effect suppression; normal isolated runs retain normal behavior. | +| `OPENCLAW_TRAY_DATA_DIR` | Synthetic settings, registry, identities, MCP token, logs, caches and instance mutex. | +| `OPENCLAW_TRAY_LOCAL_DATA_DIR` | Separate synthetic setup/Local AI state. | +| `OPENCLAW_TRAY_APPDATA_DIR` | Isolated roaming fallback root. | +| `OPENCLAW_MCP_PORT` | Dedicated, non-default MCP port. | +| Runtimeconfig `OpenClaw.GatewayFixtureIsolationVersion=1` | Preflight rejects old binaries before they can execute unguarded startup paths. | + +WSL keepalive/startup cleanup and Windows autostart writes are explicitly +guarded. A loopback URL alone is not enough to prevent the regular keepalive +policy from considering the installed default WSL distro. Sensitive node +capabilities, automatic repair, hotkeys, notifications and voice are disabled +in the synthetic settings. Telemetry export remains unconfigured. + +The fake binds a numeric IPv4 loopback listener to an OS-assigned port. The +launcher never takes over a standard Gateway/MCP port or terminates a process +by name. MCP bind collisions have a bounded retry limited to the owned child. +Cleanup refuses reparse points and only removes the run's owned data. + +This remains a desktop application, not a general OS sandbox. Only the +documented browse/preferences workflow is supported on a developer machine. +Use a disposable Windows environment for setup, external integration or other +host-changing workflows. + +## Results and extending scenarios + +Artifacts default to a per-run child under +`%TEMP%\openclaw-gateway-fixture-artifacts`. Override the parent directory with +`-ArtifactsDirectory`. The run report records the exact app path/hash/version, +runtime configuration, architecture, nonsecret endpoints and outcome. The +server records request methods and results, and unexpected requests fail the +smoke. Isolated logs redact the run's credentials. Profile files, Gateway +registries and identity files are not copied into artifacts. + +Polling timeouts retain the wait description and artifact directory, including +when an individual probe times out. Malformed or disconnected HTTP-upgrade peers +are recorded as unexpected `` requests without retaining headers. +They do not prevent other clients from connecting or make server cleanup throw; +the unexpected-request assertions still surface them as smoke failures. + +Add new behavior at the server boundary. Do not introduce fixture providers, +demo branches in pages, bypasses in the connection manager, or a second chat +renderer. Keep synthetic wire examples aligned with the existing Gateway +protocol snapshot, and exercise them through the real client. + +Backend and independent app instances can run concurrently. UI tests are +serialized on a shared desktop; focus-dependent GUI workers need separate +Windows sessions or VMs. Future multi-Gateway switching and canned streaming +can reuse the existing per-instance server/profile ownership. + +The fixture suites supplement, not replace, the required build, Shared and +Tray tests. Run those on every implementation change as documented in +`AGENTS.md`. The real-app smoke is an explicit opt-in lane; its script is the +entry point for desktop CI workers and local release proof. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index b213ef22b..cf6b6d4a3 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -38,6 +38,17 @@ authoritative runtime totals. ## Coverage highlights +### Fixture-backed application smoke + +The [Gateway fixture harness](GATEWAY_FIXTURE_TESTING.md) adds a middle tier: +the real desktop app and production Gateway/chat stack consume deterministic +synthetic Gateway responses, with no AI or real WSL Gateway. Protocol tests +run in Shared; profile/preflight and concurrent-app tests live in Tray +Integration; native picker/240-message/late-history proofs live in Tray UI. +Use `.\scripts\test-gateway-fixture.ps1 -AppPath ''` for the +opt-in real-app lane. It rejects skipped or zero-test runs and preserves +per-run artifacts. Existing MCP-only integration defaults remain unchanged. + ### OpenClaw.Shared.Tests - **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, channel status, and rich text helpers. @@ -75,6 +86,7 @@ required closeout lane for code changes. | Lane | Entry point | Required when | |---|---|---| | Required closeout | `.\build.ps1`, Shared tests, Tray tests | Every code change and every agent closeout | +| Fixture-backed app smoke | `.\scripts\test-gateway-fixture.ps1 -AppPath ''` | Chat session switching/history/scrolling and populated navigation changes; run both Debug and production-shaped Release for runtime regressions | | Proof-pool inventory | `.\scripts\validate-proof-pools.ps1`, `.\scripts\test-proof-pool-validator.ps1`, and `.\scripts\test-validate-docs-proof-pool-flow.ps1` | Every inventory or proof scheduling change; the documentation gate runs core schema and parent-flow checks, while CI runs the full malformed-contract matrix | | Agent skills | `.\scripts\validate-agent-skills.ps1` and `.\scripts\test-agent-skills-validator.ps1` | Changes under `.agents\skills`; validates skill metadata, agent-facing prose, and local links | | GitHub-hosted PR/main CI | `.github\workflows\ci.yml` | Every pull request and push to `main`; explicit conservative impact outputs select the required test, E2E, and release-publish lanes | diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index ce891b2ab..0867fa45e 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -30,6 +30,15 @@ Short version: run required tests, collect a closeout proof pass with `.\run-app ### Reactor preview.12 compatibility proof +The [fixture Gateway harness](GATEWAY_FIXTURE_TESTING.md) can populate the real +app without a running WSL Gateway or AI provider. Run +`.\scripts\run-gateway-fixture.ps1 -AppPath ''` to explore, or +`.\scripts\test-gateway-fixture.ps1 -AppPath ''` for automated +picker, 240-message final-item, navigation and delayed-history proof. Use a +non-Dev Release binary as well as Debug; an empty page or MCP snapshot is not +equivalent to visible native history proof. Streaming still needs separate +coverage. + Both Reactor packages are temporarily pinned to `0.1.0-preview.12` while [microsoft/microsoft-ui-xaml#11865](https://github.com/microsoft/microsoft-ui-xaml/issues/11865) awaits a released and validated fix. `ReactorChatTimeline.BuildSafeMarkdown` diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index f828a1420..0b1ccdde8 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -31,6 +31,7 @@ + diff --git a/scripts/run-gateway-fixture.ps1 b/scripts/run-gateway-fixture.ps1 new file mode 100644 index 000000000..6f827bfe1 --- /dev/null +++ b/scripts/run-gateway-fixture.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS +Launch the real app against a synthetic, read-only Gateway. Ctrl+C cleans up this run. +.DESCRIPTION +Build the app first. AppPath is required so this command never selects an installed +app or a stale build implicitly. The app must advertise fixture isolation support. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$AppPath, + [string]$ArtifactsDirectory, + [ValidateRange(0, 86400)] + [int]$DurationSeconds = 0, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug' +) +$ErrorActionPreference = 'Stop' +$project = Join-Path $PSScriptRoot '..\tests\OpenClaw.GatewayFixtureHost\OpenClaw.GatewayFixtureHost.csproj' +$resolvedApp = (Resolve-Path -LiteralPath $AppPath).Path +$hostArgs = @('--app', $resolvedApp) +if ($ArtifactsDirectory) { + $hostArgs += @('--artifacts', [IO.Path]::GetFullPath($ArtifactsDirectory)) +} +if ($DurationSeconds -gt 0) { + $hostArgs += @('--duration-seconds', $DurationSeconds.ToString()) +} +& dotnet run --project $project --configuration $Configuration -- @hostArgs +exit $LASTEXITCODE diff --git a/scripts/test-gateway-fixture.ps1 b/scripts/test-gateway-fixture.ps1 new file mode 100644 index 000000000..50c9dfaa1 --- /dev/null +++ b/scripts/test-gateway-fixture.ps1 @@ -0,0 +1,65 @@ +<# +.SYNOPSIS +Run fixture profile checks and real-app UI smokes against an explicitly selected build. +.DESCRIPTION +Requires a Windows desktop and a built app with fixture isolation support. Missing +desktop/runtime support and skipped/zero-test runs are failures, not successful proof. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$AppPath, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug', + [string]$ArtifactsDirectory = (Join-Path ([IO.Path]::GetTempPath()) 'openclaw-gateway-fixture-artifacts'), + [switch]$Screenshots +) +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$resolvedApp = (Resolve-Path -LiteralPath $AppPath).Path +$artifacts = [IO.Path]::GetFullPath($ArtifactsDirectory) +$resultsDirectory = Join-Path $artifacts ([Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $resultsDirectory -Force | Out-Null +$arch = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() +if ($arch -notin @('X64', 'Arm64')) { throw "Unsupported architecture: $arch" } +$rid = if ($arch -eq 'Arm64') { 'win-arm64' } else { 'win-x64' } +$platform = if ($arch -eq 'Arm64') { 'ARM64' } else { 'x64' } + +function Invoke-FixtureTests { + param([string]$Project, [string]$Filter, [string]$ResultName) + $start = [Diagnostics.ProcessStartInfo]::new('dotnet') + $start.UseShellExecute = $false + $start.WorkingDirectory = $repoRoot + foreach ($argument in @('test', (Join-Path $repoRoot $Project), '-c', $Configuration, + '-r', $rid, "-p:Platform=$platform", '-p:DevBuild=false', '--filter', $Filter, + '--results-directory', $resultsDirectory, '--logger', "trx;LogFileName=$ResultName.trx")) { + $start.ArgumentList.Add($argument) + } + $start.Environment['OPENCLAW_RUN_GATEWAY_FIXTURE_UI'] = '1' + $start.Environment['OPENCLAW_GATEWAY_FIXTURE_APP'] = $resolvedApp + $start.Environment['OPENCLAW_GATEWAY_FIXTURE_ARTIFACTS'] = $artifacts + $start.Environment['OPENCLAW_GATEWAY_FIXTURE_SCREENSHOTS'] = if ($Screenshots) { '1' } else { '0' } + $start.Environment['OPENCLAW_REPO_ROOT'] = $repoRoot + $process = [Diagnostics.Process]::Start($start) + try { + if (-not $process.WaitForExit(900000)) { + $process.Kill($true) + throw "$ResultName exceeded 15 minutes. Stopped only the owned test process tree." + } + if ($process.ExitCode -ne 0) { throw "$ResultName failed (exit $($process.ExitCode)). Results: $resultsDirectory" } + } finally { + $process.Dispose() + } + $resultFile = Join-Path $resultsDirectory "$ResultName.trx" + if (-not (Test-Path -LiteralPath $resultFile)) { throw "$ResultName produced no test report." } + [xml]$trx = Get-Content -LiteralPath $resultFile -Raw + $counters = $trx.TestRun.ResultSummary.Counters + if ([int]$counters.total -le 0 -or [int]$counters.passed -ne [int]$counters.total) { + throw "$ResultName did not execute and pass every selected test. Skips are not fixture proof." + } +} + +Invoke-FixtureTests 'tests\OpenClaw.Tray.IntegrationTests\OpenClaw.Tray.IntegrationTests.csproj' 'FullyQualifiedName~GatewayFixtureProfileTests|FullyQualifiedName~GatewayFixtureRunTests|FullyQualifiedName~GatewayFixtureAppTests' 'fixture-profile-app' +Invoke-FixtureTests 'tests\OpenClaw.Tray.UITests\OpenClaw.Tray.UITests.csproj' 'FullyQualifiedName~GatewayFixtureUiTests' 'fixture-ui' +Write-Host "Fixture smoke passed. Results: $resultsDirectory" +exit 0 diff --git a/src/OpenClaw.Shared/GatewayFixtureIsolation.cs b/src/OpenClaw.Shared/GatewayFixtureIsolation.cs new file mode 100644 index 000000000..305cf89dc --- /dev/null +++ b/src/OpenClaw.Shared/GatewayFixtureIsolation.cs @@ -0,0 +1,85 @@ +namespace OpenClaw.Shared; + +/// +/// Validates the explicit, browse-only Gateway fixture context without performing IO. +/// An isolated data directory alone does not enable fixture mode. +/// +/// +/// The launcher owns directory creation, unique run ownership, installed-profile and +/// reparse-point checks, and cleanup. This guard prevents missing or ambiguous overrides +/// from falling back to installed state; it is not an OS sandbox. +/// +public static class GatewayFixtureIsolation +{ + public const string ModeEnvironmentVariable = "OPENCLAW_GATEWAY_FIXTURE"; + public const string DataDirectoryEnvironmentVariable = "OPENCLAW_TRAY_DATA_DIR"; + public const string LocalDataDirectoryEnvironmentVariable = "OPENCLAW_TRAY_LOCAL_DATA_DIR"; + public const string LocalAppDataDirectoryEnvironmentVariable = "OPENCLAW_TRAY_LOCALAPPDATA_DIR"; + + private static readonly GatewayFixtureIsolationContext _disabled = new(false, null, null); + + /// Throws on an invalid explicit fixture context instead of treating it as ordinary mode. + public static bool IsEnabled => Get().IsEnabled; + + /// + /// Reads the current process environment, or a launcher's child-environment snapshot. + /// Returns canonical absolute roots only for an explicitly enabled, valid fixture. + /// + /// An explicit fixture context is invalid. + public static GatewayFixtureIsolationContext Get(Func? getEnvironmentVariable = null) + { + getEnvironmentVariable ??= Environment.GetEnvironmentVariable; + if (!string.Equals(getEnvironmentVariable(ModeEnvironmentVariable), "1", StringComparison.Ordinal)) + return _disabled; + + var dataDirectory = ValidateAbsoluteDirectory( + getEnvironmentVariable(DataDirectoryEnvironmentVariable), DataDirectoryEnvironmentVariable); + var localDataDirectory = ValidateAbsoluteDirectory( + getEnvironmentVariable(LocalDataDirectoryEnvironmentVariable), LocalDataDirectoryEnvironmentVariable); + + // SetupEngine gives this legacy override precedence over LOCAL_DATA_DIR. + // Never accept a context whose validated root would not actually be used. + if (!string.IsNullOrEmpty(getEnvironmentVariable(LocalAppDataDirectoryEnvironmentVariable))) + { + throw new InvalidOperationException( + $"Gateway fixture mode requires {LocalAppDataDirectoryEnvironmentVariable} to be cleared."); + } + + return new GatewayFixtureIsolationContext(true, dataDirectory, localDataDirectory); + } + + private static string ValidateAbsoluteDirectory(string? value, string variableName) + { + const string requirement = "to contain a nonempty, valid absolute directory path."; + if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value)) + throw new InvalidOperationException($"Gateway fixture mode requires {variableName} {requirement}"); + + try + { + if (value.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + throw new ArgumentException("Invalid path characters."); + + var fullPath = Path.GetFullPath(value); + var rootLength = Path.GetPathRoot(fullPath)!.Length; + foreach (var segment in fullPath[rootLength..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + if (segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("Invalid directory characters."); + } + + return fullPath; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + throw new InvalidOperationException($"Gateway fixture mode requires {variableName} {requirement}", ex); + } + } +} + +/// A validated fixture-mode snapshot. Disabled snapshots do not resolve installed paths. +public sealed record GatewayFixtureIsolationContext( + bool IsEnabled, + string? DataDirectory, + string? LocalDataDirectory); diff --git a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs index c5ad8bb28..a673662f6 100644 --- a/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.AppShutdownCoordinator.cs @@ -1,4 +1,5 @@ using Microsoft.Toolkit.Uwp.Notifications; +using OpenClaw.Shared; using OpenClawTray.Services; using System; using System.Collections.Generic; @@ -38,7 +39,10 @@ private AppShutdownPlan BuildShutdownPlan() var activationRouter = _activationRouter; steps.Add(new AppShutdownStep("activation router", async () => { - ToastNotificationManagerCompat.OnActivated -= OnToastActivated; + // Even removing an unregistered handler triggers the toolkit's static + // initializer, which writes installed notification registration. + if (!GatewayFixtureIsolation.IsEnabled) + ToastNotificationManagerCompat.OnActivated -= OnToastActivated; if (ReferenceEquals(_activationRouter, activationRouter)) _activationRouter = null; if (activationRouter is not null) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index fdf1dd1fe..fc591680b 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -151,6 +151,15 @@ private void WireAppCapabilityHandlers() { if (_settings == null) return new { error = "Settings not loaded" }; if (!safeSettings.Contains(name)) return new { error = $"Setting '{name}' is not accessible" }; + if (name.Equals(nameof(SettingsManager.AutoStart), StringComparison.OrdinalIgnoreCase)) + { + try { AutoStartReconciliation.ThrowIfFixtureMutation(); } + catch (AutoStartRefusedException ex) + { + Logger.Warn(ex.Message); + return new { error = ex.Message }; + } + } var prop = typeof(SettingsManager).GetProperty(name, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase); if (prop == null) return new { error = $"Unknown setting: {name}" }; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 04c7311b3..fdc2b9bf0 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -290,6 +290,9 @@ public IntPtr GetHubWindowHandle() => public App() { + // Validate before restart handling, logging, settings, or run-marker writes. + _ = GatewayFixtureIsolation.Get(); + WaitForRestartSourceIfRequested(Environment.GetCommandLineArgs()); StartupInputConfigurator.Configure(); @@ -702,8 +705,10 @@ _dispatcherQueue is null // explicitly via Application.Exit(). DispatcherShutdownMode = DispatcherShutdownMode.OnExplicitShutdown; - // Register toast activation handler - ToastNotificationManagerCompat.OnActivated += OnToastActivated; + // Touching the toolkit initializes installed COM/AUMID registration, even + // when notification display is disabled in this profile. + if (!GatewayFixtureIsolation.IsEnabled) + ToastNotificationManagerCompat.OnActivated += OnToastActivated; _sshTunnelService = new SshTunnelService(new AppLogger()); _sshTunnelService.TunnelExited += OnSshTunnelExited; @@ -4092,6 +4097,11 @@ private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool au private async Task ReconcileAutoStartOnStartupAsync() { if (_settings == null) return; + if (GatewayFixtureIsolation.IsEnabled) + { + Logger.Info("Gateway fixture mode: skipping Windows auto-start reconciliation."); + return; + } var persisted = false; await _autoStartMutationGate.WaitAsync(); diff --git a/src/OpenClaw.Tray.WinUI/Chat/GatewayFixtureRenderObservation.cs b/src/OpenClaw.Tray.WinUI/Chat/GatewayFixtureRenderObservation.cs new file mode 100644 index 000000000..e139cc269 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/GatewayFixtureRenderObservation.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using OpenClaw.Chat; + +namespace OpenClawTray.Chat; + +/// +/// Passive UIA acknowledgement of the snapshot consumed by a fixture render. +/// Provider/MCP readiness can precede UI delivery; no message content is exposed here. +/// +internal static class GatewayFixtureRenderObservation +{ + public static string Create(ChatDataSnapshot snapshot, string? selectedThreadId, bool fixtureEnabled) + { + if (!fixtureEnabled) + return string.Empty; + return JsonSerializer.Serialize(new + { + selectedThreadId, + loadedThreadIds = snapshot.Timelines + .Where(pair => pair.Value.HistoryLoaded) + .Select(pair => pair.Key) + .Order(StringComparer.Ordinal) + .ToArray() + }); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs index ffd8aab27..ab44610ee 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs @@ -209,7 +209,8 @@ Element PickerButton( string automationName, string automationId, bool enabled, - double maxLabelWidth) + double maxLabelWidth, + string? itemStatus = null) { return Button( HStack( @@ -242,7 +243,12 @@ Element PickerButton( .IsEnabled(enabled) .BorderThickness(0) .AutomationId(automationId) - .Set(button => ComposerAutomationVisibility.Prepare(button)) + .Set(button => + { + ComposerAutomationVisibility.Prepare(button); + if (itemStatus is not null) + Microsoft.UI.Xaml.Automation.AutomationProperties.SetItemStatus(button, itemStatus); + }) .OnUnmount(control => ComposerAutomationVisibility.Detach( (FrameworkElement)control)); } @@ -552,7 +558,9 @@ Element PickerButton( $"{Localized("Chat_Composer_Accessibility_Session", "Session")}: {inputs.CurrentThread.Title}", "ChatComposerSessionPicker", !inputs.MessageOptionsDisabled && inputs.AvailableChannels.Count > 1, - props.IsCompact ? 56 : 160), + props.IsCompact ? 56 : 160, + GatewayFixtureRenderObservation.Create( + props.InputSnapshot, inputs.CurrentThread.Id, GatewayFixtureIsolation.IsEnabled)), inputs.AvailableChannels .Select(thread => RadioMenuItem( thread.Title, diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 7291a2c0e..cb701f4e4 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -37,6 +37,11 @@ release + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index d3b2ec606..5e34647da 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -48,6 +48,8 @@ public static bool IsAutoStartEnabled() public static void SetAutoStart(bool enable) { + ThrowIfFixtureMutation(); + if (PackageHelper.IsPackaged) { SetPackagedAutoStartAsync(enable).GetAwaiter().GetResult(); @@ -57,10 +59,14 @@ public static void SetAutoStart(bool enable) SetUnpackagedAutoStart(enable); } - public static Task SetAutoStartAsync(bool enable) => - PackageHelper.IsPackaged + public static Task SetAutoStartAsync(bool enable) + { + ThrowIfFixtureMutation(); + + return PackageHelper.IsPackaged ? SetPackagedAutoStartAsync(enable) : Task.Run(() => SetUnpackagedAutoStart(enable)); + } /// /// Reports whether auto-start is currently enabled. @@ -88,6 +94,9 @@ public static Task IsAutoStartEnabledAsync() => /// public static Task ResolveAutoStartAfterFailedChangeAsync(bool requested, Exception failure) { + if (GatewayFixtureIsolation.IsEnabled) + return Task.FromResult(false); + if (!PackageHelper.IsPackaged) return Task.Run(IsAutoStartEnabled); @@ -114,6 +123,8 @@ public static Task ResolveAutoStartAfterFailedChangeAsync(bool requested, /// public static Task ReconcileAutoStartAsync(bool configured) { + ThrowIfFixtureMutation(); + if (!PackageHelper.IsPackaged) return Task.FromResult(configured); @@ -123,8 +134,24 @@ public static Task ReconcileAutoStartAsync(bool configured) SetPackagedAutoStartAsync); } + private static void ThrowIfFixtureMutation() + { + try + { + AutoStartReconciliation.ThrowIfFixtureMutation(); + } + catch (AutoStartRefusedException ex) + { + Logger.Warn(ex.Message); + throw; + } + } + private static void SetUnpackagedAutoStart(bool enable) { + // Outside the legacy best-effort catch so fixture refusals cannot look successful. + ThrowIfFixtureMutation(); + try { if (enable) @@ -198,6 +225,8 @@ private static async Task QueryPackagedAutoStartAsync() private static async Task SetPackagedAutoStartAsync(bool enable) { + ThrowIfFixtureMutation(); + var startupTask = await StartupTask.GetAsync(AppIdentity.PackageStartupTaskId); if (!enable) { diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs index a9d9d97fd..c76d7c768 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs @@ -21,7 +21,7 @@ internal enum AutoStartState } /// -/// Thrown when Windows explicitly refuses to enable the packaged startup task. +/// Thrown when Windows or fixture isolation explicitly refuses an auto-start change. /// /// /// Distinct from a transient failure: a refusal (DisabledByUser / DisabledByPolicy) is a @@ -51,6 +51,19 @@ public AutoStartRefusedException(string message) : base(message) /// internal static class AutoStartReconciliation { + /// + /// A fixture preference must never be applied to the installed Windows registration. + /// Invalid fixture contexts throw before callers can query Windows or initialize logging. + /// + internal static void ThrowIfFixtureMutation() + { + if (GatewayFixtureIsolation.IsEnabled) + { + throw new AutoStartRefusedException( + "Windows auto-start changes are unavailable in Gateway fixture mode. Installed startup registration was not changed."); + } + } + /// /// Decides whether a startup reconciliation result may still be persisted. /// @@ -89,6 +102,8 @@ internal static async Task ReconcileAsync( Func> queryAsync, Func setEnabledAsync) { + ThrowIfFixtureMutation(); + var actual = await QueryOrUnknownAsync(queryAsync); if (actual == AutoStartState.Unknown) { @@ -147,6 +162,11 @@ internal static async Task ResolveAfterFailedChangeAsync( Exception failure, Func> queryAsync) { + // The fixture cannot register itself for startup. Report disabled without + // looking up the installed application's state after a rejected toggle. + if (GatewayFixtureIsolation.IsEnabled) + return false; + if (failure is AutoStartRefusedException) { Logger.Warn($"Windows refused the auto-start change, reporting disabled: {failure.Message}"); diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs index 2a09d94ce..c93463862 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs @@ -1,3 +1,4 @@ +using OpenClaw.Shared; using System; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,13 @@ internal static async Task ApplyLatestAsync( Func readPreference, Func setEnabledAsync) { + // Every preference save schedules this background refresh. Fixture-local + // preferences may be saved without touching Windows; explicit auto-start + // toggles still go through AutoStartManager and visibly refuse mutation. + // Validate before waiting or reading so malformed fixture contexts fail closed. + if (GatewayFixtureIsolation.IsEnabled) + return; + await mutationGate.WaitAsync(); try { diff --git a/src/OpenClaw.Tray.WinUI/Services/ToastService.cs b/src/OpenClaw.Tray.WinUI/Services/ToastService.cs index 7d2941d1f..8e700c5cc 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ToastService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ToastService.cs @@ -1,4 +1,5 @@ using Microsoft.Toolkit.Uwp.Notifications; +using OpenClaw.Shared; using System; using System.Collections.Generic; using System.Linq; @@ -25,6 +26,10 @@ public ToastService(Func getSettings) /// Shows a toast with optional dedup by tag + device ID. public void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null) { + // Showing a toast also lazily initializes the toolkit's OS registration. + if (GatewayFixtureIsolation.IsEnabled) + return; + if (!ShouldShowToast(toastTag, deviceId)) return; diff --git a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs index bb4e037d5..a9c407d5e 100644 --- a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared; using OpenClawTray; using System; using System.Collections.Generic; @@ -29,6 +30,14 @@ internal sealed class WslGatewayKeepAliveService( /// public async Task TryEnsureAsync() { + // This must precede BOTH the start path and stale cleanup. A loopback fixture + // is not a local WSL gateway, and an invalid context must fail before any IO. + if (GatewayFixtureIsolation.IsEnabled) + { + Logger.Info("[WslKeepAlive] Gateway fixture mode: skipping keepalive start and stale cleanup."); + return; + } + try { var settings = _getSettings(); diff --git a/src/OpenClaw.Tray.WinUI/Services/WslKeepAlivePolicy.cs b/src/OpenClaw.Tray.WinUI/Services/WslKeepAlivePolicy.cs index a8dc8f229..05197e0dc 100644 --- a/src/OpenClaw.Tray.WinUI/Services/WslKeepAlivePolicy.cs +++ b/src/OpenClaw.Tray.WinUI/Services/WslKeepAlivePolicy.cs @@ -10,6 +10,9 @@ internal static class WslKeepAlivePolicy public static bool ShouldStart(GatewayRecord? activeRecord, string? legacyGatewayUrl) { + if (GatewayFixtureIsolation.IsEnabled) + return false; + if (activeRecord is not null) { if (activeRecord.SshTunnel is not null) @@ -28,6 +31,9 @@ public static bool ShouldStart(GatewayRecord? activeRecord, string? legacyGatewa string? setupStateDistroName, string? environmentOverride) { + if (GatewayFixtureIsolation.IsEnabled) + return null; + if (activeRecord is not null && GatewayRecordEditing.ResolveManagedDistroName(activeRecord) is { } managedDistroName) return managedDistroName; diff --git a/tests/OpenClaw.GatewayFixtureHost/Directory.Build.props b/tests/OpenClaw.GatewayFixtureHost/Directory.Build.props new file mode 100644 index 000000000..284d12dfe --- /dev/null +++ b/tests/OpenClaw.GatewayFixtureHost/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureProfile.cs b/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureProfile.cs new file mode 100644 index 000000000..d525525f3 --- /dev/null +++ b/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureProfile.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.GatewayFixtureHost; + +/// Owns synthetic app state. No installed settings or identities are imported. +public sealed class GatewayFixtureProfile : IDisposable +{ + private readonly TempDirectory _directory; + private bool _disposed; + + public string RunId { get; } = Guid.NewGuid().ToString("N"); + public string RunDirectory => _directory.Path; + public string DataDirectory => _directory.Combine("profile"); + public string SetupDirectory => _directory.Combine("setup-local"); + public string GatewayId { get; } = Guid.NewGuid().ToString(); + public Uri GatewayEndpoint { get; } + + public GatewayFixtureProfile(Uri endpoint, string token) + { + ValidateEndpoint(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + GatewayEndpoint = endpoint; + _directory = new TempDirectory("openclaw-gateway-fixture-"); + try + { + Directory.CreateDirectory(DataDirectory); + Directory.CreateDirectory(SetupDirectory); + var settings = new SettingsData + { + GatewayUrl = endpoint.AbsoluteUri, + EnableMcpServer = true, + EnableNodeMode = false, + AutoStart = false, + GlobalHotkeyEnabled = false, + ShowNotifications = false, + NotifyChatResponses = false, + ShowPairingApprovalDialog = false, + HasSeenActivityStreamTip = true, + HasInjectedFirstRunBootstrap = true, + EnableManagedLocalGatewayAutoRepair = false, + NodeSystemRunEnabled = false, + NodeBrowserProxyEnabled = false, + NodeCanvasEnabled = false, + NodeScreenEnabled = false, + NodeCameraEnabled = false, + NodeLocationEnabled = false, + NodeSttEnabled = false, + NodeTtsEnabled = false, + NodeOllamaInferenceEnabled = false, + VoiceTtsEnabled = false, + VoiceAudioFeedback = false, + UseLegacyWebChat = false, + ShowCompletedSessions = true, + AppTheme = "Light", + OpenTelemetryEndpoint = null + }; + File.WriteAllText(Path.Combine(DataDirectory, "settings.json"), settings.ToJson()); + var registry = new GatewayRegistry(DataDirectory); + registry.AddOrUpdate(new GatewayRecord + { + Id = GatewayId, + Url = endpoint.AbsoluteUri, + FriendlyName = $"Fixture Gateway ({RunId[..8]})", + SharedGatewayToken = token, + // This is an unmanaged test server, not a provisioned WSL Gateway. + // Do not claim managed ownership or bypass its credential-provenance checks. + IsLocal = false + }); + registry.SetActive(GatewayId); + registry.Save(); + } + catch + { + Dispose(); + throw; + } + } + + public ProcessStartInfo CreateStartInfo(string appPath, int mcpPort) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (mcpPort is < 1 or > 65535 || mcpPort == GatewayEndpoint.Port || mcpPort is 8765 or 18789) + throw new ArgumentOutOfRangeException(nameof(mcpPort), "A distinct, non-default MCP port is required."); + var executable = ValidateApp(appPath); + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(executable)! + }; + // No inherited test flags, profile paths or real Gateway overrides may escape into this child. + foreach (var key in start.Environment.Keys.Where(key => key.StartsWith("OPENCLAW_", StringComparison.OrdinalIgnoreCase)).ToArray()) + start.Environment.Remove(key); + start.Environment["OPENCLAW_GATEWAY_FIXTURE"] = "1"; + start.Environment["OPENCLAW_TRAY_DATA_DIR"] = DataDirectory; + start.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"] = SetupDirectory; + start.Environment["OPENCLAW_TRAY_APPDATA_DIR"] = _directory.Combine("roaming"); + start.Environment["OPENCLAW_MCP_PORT"] = mcpPort.ToString(CultureInfo.InvariantCulture); + start.Environment["OPENCLAW_SKIP_UPDATE_CHECK"] = "1"; + start.Environment["OPENCLAW_SUPPRESS_EXTERNAL_BROWSER"] = "1"; + start.Environment["OPENCLAW_LANGUAGE"] = "en-US"; + return start; + } + + /// Reject older binaries before they can perform unguarded startup side effects. + public static string ValidateApp(string appPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appPath); + var executable = Path.GetFullPath(appPath); + if (!File.Exists(executable)) + throw new FileNotFoundException("Build the app before starting a Gateway fixture.", executable); + if (!string.Equals(Path.GetFileName(executable), "OpenClaw.Tray.WinUI.exe", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("AppPath must identify the built OpenClaw.Tray.WinUI.exe.", nameof(appPath)); + var runtimeConfig = Path.ChangeExtension(executable, ".runtimeconfig.json"); + using var document = JsonDocument.Parse(File.ReadAllText(runtimeConfig)); + if (!document.RootElement.TryGetProperty("runtimeOptions", out var options) + || !options.TryGetProperty("configProperties", out var properties) + || !properties.TryGetProperty("OpenClaw.GatewayFixtureIsolationVersion", out var version) + || version.ToString() != "1") + { + throw new InvalidDataException( + "This app does not advertise Gateway fixture isolation version 1. Rebuild the app; refusing to launch an older binary."); + } + return executable; + } + + public static void ValidateEndpoint(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + if (!endpoint.IsAbsoluteUri || endpoint.Scheme != "ws" + || !IPAddress.TryParse(endpoint.Host, out var address) || !address.Equals(IPAddress.Loopback) + || endpoint.Port is <= 0 or 18789 or 8765 + || endpoint.UserInfo.Length != 0 || endpoint.Query.Length != 0 || endpoint.Fragment.Length != 0) + { + throw new ArgumentException("A dedicated numeric IPv4 loopback WebSocket endpoint is required.", nameof(endpoint)); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + EnsureNoReparsePoints(RunDirectory); + Directory.Delete(RunDirectory, recursive: true); + } + + private static void EnsureNoReparsePoints(string directory) + { + if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) + throw new IOException("Refusing fixture cleanup through a reparse point."); + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Refusing fixture cleanup containing a reparse point."); + if ((attributes & FileAttributes.Directory) != 0) + EnsureNoReparsePoints(entry); + } + } +} diff --git a/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureRun.cs b/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureRun.cs new file mode 100644 index 000000000..8930af00c --- /dev/null +++ b/tests/OpenClaw.GatewayFixtureHost/GatewayFixtureRun.cs @@ -0,0 +1,340 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using OpenClaw.TestSupport; +using OpenClaw.TestSupport.Gateway; + +namespace OpenClaw.GatewayFixtureHost; + +/// One real app, one fixture server and one disposable profile. Artifacts outlive the run. +public sealed class GatewayFixtureRun : IAsyncDisposable +{ + private readonly string _gatewayToken; + private readonly string _appPath; + private Process? _process; + private McpClient? _client; + private bool _disposed; + private string? _mcpToken; + private JsonElement? _lastStatus; + + public FixtureGatewayServer Gateway { get; } + public GatewayFixtureProfile Profile { get; } + public McpClient Client => _client ?? throw new InvalidOperationException("Fixture MCP is not ready."); + public string ArtifactsDirectory { get; } + public int AppProcessId => _process?.Id ?? throw new InvalidOperationException("The fixture app has not started."); + public int McpPort { get; private set; } + public bool IsRunning => _process is { HasExited: false }; + public int? AppExitCode => _process is { HasExited: true } ? _process.ExitCode : null; + + private GatewayFixtureRun(FixtureGatewayServer gateway, GatewayFixtureProfile profile, string token, string appPath, string? artifactRoot) + { + Gateway = gateway; + Profile = profile; + _gatewayToken = token; + _appPath = appPath; + ArtifactsDirectory = Path.Combine( + Path.GetFullPath(artifactRoot ?? Path.Combine(Path.GetTempPath(), "openclaw-gateway-fixture-artifacts")), + profile.RunId); + Directory.CreateDirectory(ArtifactsDirectory); + } + + public static async Task StartAsync( + string appPath, + string? artifactRoot = null, + CancellationToken cancellationToken = default) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("The fixture app requires a Windows desktop."); + var executable = GatewayFixtureProfile.ValidateApp(appPath); + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var gateway = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token, cancellationToken); + GatewayFixtureProfile profile; + try + { + profile = new GatewayFixtureProfile(gateway.Endpoint, token); + } + catch + { + await gateway.DisposeAsync(); + throw; + } + GatewayFixtureRun run; + try + { + run = new GatewayFixtureRun(gateway, profile, token, executable, artifactRoot); + } + catch + { + try { await gateway.DisposeAsync(); } + finally { profile.Dispose(); } + throw; + } + try + { + await run.StartAppAsync(cancellationToken); + await run.WriteReportAsync("ready"); + return run; + } + catch (Exception ex) + { + try { await run.WriteReportAsync("startup-failed", ex); } + finally { await run.DisposeAsync(); } + throw; + } + } + + private async Task StartAppAsync(CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 3; attempt++) + { + McpPort = FindFreePort(); + _process = Process.Start(Profile.CreateStartInfo(_appPath, McpPort)) + ?? throw new InvalidOperationException("Failed to start the fixture app."); + try + { + await WaitForMcpAsync(cancellationToken); + await WaitForAsync(async () => + { + var status = await InvokeAsync("app.status"); + _lastStatus = status; + if (status.GetProperty("operatorState").GetString() == "Error") + throw new InvalidOperationException($"Fixture operator connection failed. See connection diagnostics in {ArtifactsDirectory}."); + return status.GetProperty("operatorState").GetString() == "Connected" + && status.GetProperty("sessionCount").GetInt32() >= 5; + }, "fixture operator and populated session catalog", TimeSpan.FromSeconds(30), cancellationToken); + return; + } + catch (McpPortCollisionException) when (attempt < 2) + { + await StopAppAsync(); + var marker = Path.Combine(Profile.DataDirectory, "run.marker"); + if (File.Exists(marker)) File.Delete(marker); + } + } + } + + private async Task WaitForMcpAsync(CancellationToken cancellationToken) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(45)); + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + var tokenPath = Path.Combine(Profile.DataDirectory, "mcp-token.txt"); + Exception? lastError = null; + try + { + while (true) + { + deadline.Token.ThrowIfCancellationRequested(); + EnsureRunning(); + if (File.Exists(tokenPath)) + { + _mcpToken = (await File.ReadAllTextAsync(tokenPath, deadline.Token)).Trim(); + if (_mcpToken.Length != 0) + { + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _mcpToken); + try + { + using var response = await http.GetAsync($"http://127.0.0.1:{McpPort}/", deadline.Token); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new McpPortCollisionException(); + if (response.IsSuccessStatusCode) + { + _client?.Dispose(); + _client = new McpClient($"http://127.0.0.1:{McpPort}/mcp", _mcpToken); + using var initialized = await Client.InitializeAsync(); + using var tools = await Client.ListToolsAsync(); + var names = tools.RootElement.GetProperty("result").GetProperty("tools") + .EnumerateArray().Select(tool => tool.GetProperty("name").GetString()).ToHashSet(); + var missing = new[] { "app.navigate", "app.status", "app.sessions", "app.chat.snapshot", "app.settings.get", "app.config.get" } + .Where(required => !names.Contains(required)).ToArray(); + if (missing.Length == 0) return; + lastError = new InvalidDataException($"Waiting for fixture automation tools: {string.Join(", ", missing)}"); + } + else + { + lastError = new HttpRequestException($"MCP readiness returned HTTP {(int)response.StatusCode}."); + } + } + catch (HttpRequestException ex) + { + lastError = ex; + } + catch (TaskCanceledException ex) when (!deadline.IsCancellationRequested) + { + lastError = ex; + } + } + } + await Task.Delay(100, deadline.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Fixture MCP did not become ready. Last error: {lastError?.Message}. Artifacts: {ArtifactsDirectory}"); + } + } + + public async Task InvokeAsync(string tool, object? arguments = null) + { + EnsureRunning(); + using var result = await Client.CallToolExpectSuccessAsync(tool, arguments); + var payload = result.RootElement; + if (payload.ValueKind == JsonValueKind.Object + && payload.TryGetProperty("error", out var error) + && error.ValueKind is not JsonValueKind.Null + && !string.IsNullOrEmpty(error.ToString())) + throw new InvalidOperationException($"Fixture MCP {tool} failed: {error}"); + return payload.Clone(); + } + + public Task WaitForAsync( + Func> condition, + string description, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) => + WaitForConditionAsync(condition, description, ArtifactsDirectory, EnsureRunning, timeout, cancellationToken); + + internal static async Task WaitForConditionAsync( + Func> condition, + string description, + string artifactsDirectory, + Action ensureRunning, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var watch = Stopwatch.StartNew(); + var limit = timeout ?? TimeSpan.FromSeconds(20); + var timeoutMessage = $"Timed out waiting for {description}. Artifacts: {artifactsDirectory}"; + while (watch.Elapsed < limit) + { + cancellationToken.ThrowIfCancellationRequested(); + ensureRunning(); + var remaining = limit - watch.Elapsed; + if (remaining <= TimeSpan.Zero) break; + try + { + if (await condition().WaitAsync(remaining, cancellationToken)) + return; + } + catch (TimeoutException ex) + { + throw new TimeoutException(timeoutMessage, ex); + } + await Task.Delay(100, cancellationToken); + } + throw new TimeoutException(timeoutMessage); + } + + public void EnsureRunning() + { + if (!IsRunning) + throw new InvalidOperationException($"Fixture app exited (code {_process?.ExitCode}). Artifacts: {ArtifactsDirectory}"); + } + + public async Task WriteReportAsync(string outcome, Exception? failure = null) + { + var runtimeConfig = await File.ReadAllTextAsync(Path.ChangeExtension(_appPath, ".runtimeconfig.json")); + var scenario = GatewayScenario.CreateBrowse(); + JsonElement? connectionStatus = null; + string? diagnosticError = null; + if (_client is not null && IsRunning) + { + try { connectionStatus = await InvokeAsync("app.connection.status"); } + catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or JsonException or TaskCanceledException) + { + diagnosticError = ex.Message; + } + } + var metadata = new + { + Profile.RunId, + scenario = scenario.Name, + scenario.Version, + scenario.Sha256, + scenario.ProtocolVersion, + scenario.ContractProvenance, + appPath = _appPath, + appSha256 = Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(_appPath))), + appAssemblySha256 = Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(Path.ChangeExtension(_appPath, ".dll")))), + appVersion = FileVersionInfo.GetVersionInfo(_appPath).ProductVersion, + architecture = RuntimeInformation.ProcessArchitecture.ToString(), + runtimeConfiguration = JsonSerializer.Deserialize(runtimeConfig), + gatewayEndpoint = Gateway.Endpoint.AbsoluteUri, + mcpEndpoint = $"http://127.0.0.1:{McpPort}/", + appProcessId = _process?.Id, + appResponding = _process is { HasExited: false } && _process.Responding, + profileDirectory = Profile.DataDirectory, + appStatus = _lastStatus, + connectionStatus, + diagnosticError, + outcome, + error = failure?.ToString(), + recordedAt = DateTimeOffset.UtcNow + }; + await File.WriteAllTextAsync(Path.Combine(ArtifactsDirectory, "run.json"), + Redact(JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }))); + await File.WriteAllLinesAsync(Path.Combine(ArtifactsDirectory, "gateway-requests.jsonl"), + Gateway.Requests.Select(request => Redact(JsonSerializer.Serialize(request)))); + foreach (var file in new[] { "crash.log", "openclaw-tray.log" }) + { + var source = Path.Combine(Profile.DataDirectory, file); + if (File.Exists(source)) + { + using var stream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + await File.WriteAllTextAsync(Path.Combine(ArtifactsDirectory, file), Redact(await reader.ReadToEndAsync())); + } + } + } + + private string Redact(string text) + { + text = text.Replace(_gatewayToken, "[fixture credential redacted]", StringComparison.Ordinal); + return string.IsNullOrEmpty(_mcpToken) ? text : text.Replace(_mcpToken, "[MCP credential redacted]", StringComparison.Ordinal); + } + + private static int FindFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private async Task StopAppAsync() + { + _client?.Dispose(); + _client = null; + if (_process is null) return; + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + _process.Dispose(); + _process = null; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try + { + await StopAppAsync(); + } + finally + { + try { await Gateway.DisposeAsync(); } + finally { Profile.Dispose(); } + } + } + + private sealed class McpPortCollisionException : Exception + { + public McpPortCollisionException() : base("The selected MCP port belongs to another listener; retrying only the owned app.") { } + } +} diff --git a/tests/OpenClaw.GatewayFixtureHost/OpenClaw.GatewayFixtureHost.csproj b/tests/OpenClaw.GatewayFixtureHost/OpenClaw.GatewayFixtureHost.csproj new file mode 100644 index 000000000..e13dd4e73 --- /dev/null +++ b/tests/OpenClaw.GatewayFixtureHost/OpenClaw.GatewayFixtureHost.csproj @@ -0,0 +1,10 @@ + + + Exe + + + + + + + diff --git a/tests/OpenClaw.GatewayFixtureHost/Program.cs b/tests/OpenClaw.GatewayFixtureHost/Program.cs new file mode 100644 index 000000000..00205a6d9 --- /dev/null +++ b/tests/OpenClaw.GatewayFixtureHost/Program.cs @@ -0,0 +1,86 @@ +namespace OpenClaw.GatewayFixtureHost; + +internal static class Program +{ + private static async Task Main(string[] args) + { + if (args.Length == 0 || args.Contains("--help")) + { + Console.WriteLine("Usage: dotnet OpenClaw.GatewayFixtureHost.dll --app [--artifacts ] [--duration-seconds <1..86400>]"); + Console.WriteLine("Starts the multi-session-browse fixture and an isolated real app. Ctrl+C stops only this run."); + return args.Length == 0 ? 2 : 0; + } + string? appPath = null; + string? artifacts = null; + int? durationSeconds = null; + for (var index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || args[index] is not ("--app" or "--artifacts" or "--duration-seconds")) + { + Console.Error.WriteLine($"Unknown or incomplete option: {args[index]}"); + return 2; + } + if (args[index] == "--app") appPath = args[index + 1]; + else if (args[index] == "--artifacts") artifacts = args[index + 1]; + else if (int.TryParse(args[index + 1], out var duration) && duration is >= 1 and <= 86400) + durationSeconds = duration; + else + { + Console.Error.WriteLine("--duration-seconds must be between 1 and 86400."); + return 2; + } + } + if (string.IsNullOrWhiteSpace(appPath)) + { + Console.Error.WriteLine("--app is required. The installed app is never selected implicitly."); + return 2; + } + using var stop = new CancellationTokenSource(); + ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; stop.Cancel(); }; + Console.CancelKeyPress += cancel; + try + { + await using var run = await GatewayFixtureRun.StartAsync(appPath, artifacts, stop.Token); + await run.InvokeAsync("app.navigate", new { page = "chat" }); + Console.WriteLine($"Fixture ready. App PID: {run.AppProcessId}"); + Console.WriteLine($"Gateway: {run.Gateway.Endpoint}"); + Console.WriteLine($"MCP: http://127.0.0.1:{run.McpPort}/"); + Console.WriteLine($"Profile: {run.Profile.DataDirectory}"); + Console.WriteLine($"Artifacts: {run.ArtifactsDirectory}"); + Console.WriteLine("Synthetic read-only Gateway. Use Chat, Sessions, Settings and Configuration. Ctrl+C stops this run."); + if (durationSeconds is { } seconds) + stop.CancelAfter(TimeSpan.FromSeconds(seconds)); + try + { + while (run.IsRunning) + await Task.Delay(250, stop.Token); + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) + { + // Ctrl+C is the explicit interactive stop operation. + } + if (!stop.IsCancellationRequested && run.AppExitCode is not 0) + { + var failure = new InvalidOperationException($"Fixture app exited unexpectedly (code {run.AppExitCode})."); + await run.WriteReportAsync("app-failed", failure); + Console.Error.WriteLine(failure.Message); + return 1; + } + await run.WriteReportAsync("stopped"); + return 0; + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) + { + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + finally + { + Console.CancelKeyPress -= cancel; + } + } +} diff --git a/tests/OpenClaw.Shared.Tests/GatewayFixtureIsolationTests.cs b/tests/OpenClaw.Shared.Tests/GatewayFixtureIsolationTests.cs new file mode 100644 index 000000000..40db7534a --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/GatewayFixtureIsolationTests.cs @@ -0,0 +1,154 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.Shared.Tests; + +public sealed class GatewayFixtureIsolationTests +{ + [Fact] + public void Get_ValidAbsoluteRoots_EnablesWithoutReadingOrCreatingProfileState() + { + using var temp = new TempDirectory(); + var sentinel = temp.Combine("installed-state-sentinel.json"); + File.WriteAllText(sentinel, """{"synthetic":"unchanged"}"""); + var environment = ValidEnvironment(temp); + var before = File.ReadAllBytes(sentinel); + + var context = GatewayFixtureIsolation.Get(environment.GetValueOrDefault); + + Assert.True(context.IsEnabled); + Assert.Equal(temp.Combine("profile"), context.DataDirectory); + Assert.Equal(temp.Combine("setup-local"), context.LocalDataDirectory); + Assert.False(Directory.Exists(context.DataDirectory)); + Assert.False(Directory.Exists(context.LocalDataDirectory)); + Assert.Equal(before, File.ReadAllBytes(sentinel)); + Assert.Single(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("0")] + [InlineData("true")] + [InlineData(" 1")] + [InlineData("1 ")] + public void Get_WithoutExactOptIn_DoesNotResolveOrValidatePaths(string? mode) + { + var reads = new List(); + var context = GatewayFixtureIsolation.Get(name => + { + reads.Add(name); + Assert.Equal(GatewayFixtureIsolation.ModeEnvironmentVariable, name); + return mode; + }); + + Assert.False(context.IsEnabled); + Assert.Null(context.DataDirectory); + Assert.Null(context.LocalDataDirectory); + Assert.Single(reads); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("profile")] + [InlineData("../profile")] + [InlineData("C:profile")] + [InlineData("\\profile")] + public void Get_MissingOrRelativeRoot_ThrowsWithoutFallback(string? root) + { + using var temp = new TempDirectory(); + foreach (var variable in new[] + { + GatewayFixtureIsolation.DataDirectoryEnvironmentVariable, + GatewayFixtureIsolation.LocalDataDirectoryEnvironmentVariable, + }) + { + var environment = ValidEnvironment(temp); + environment[variable] = root; + + var error = Assert.Throws( + () => GatewayFixtureIsolation.Get(environment.GetValueOrDefault)); + + Assert.Contains(variable, error.Message); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + } + + [Fact] + public void Get_InvalidAbsoluteRoot_ThrowsWithoutFallback() + { + using var temp = new TempDirectory(); + foreach (var variable in new[] + { + GatewayFixtureIsolation.DataDirectoryEnvironmentVariable, + GatewayFixtureIsolation.LocalDataDirectoryEnvironmentVariable, + }) + { + var environment = ValidEnvironment(temp); + environment[variable] = temp.Combine("invalid\0directory"); + + var error = Assert.Throws( + () => GatewayFixtureIsolation.Get(environment.GetValueOrDefault)); + + Assert.Contains(variable, error.Message); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + } + + [Theory] + [InlineData("invalid*directory")] + [InlineData("invalid?directory")] + [InlineData("invalid:directory")] + public void Get_InvalidWindowsDirectoryCharacters_AreRejected(string leaf) + { + if (!OperatingSystem.IsWindows()) + return; + + using var temp = new TempDirectory(); + var environment = ValidEnvironment(temp); + environment[GatewayFixtureIsolation.DataDirectoryEnvironmentVariable] = temp.Combine(leaf); + + Assert.Throws( + () => GatewayFixtureIsolation.Get(environment.GetValueOrDefault)); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData("legacy-root")] + [InlineData(" ")] + public void Get_ShadowingLocalAppDataOverride_IsRejected(string overrideValue) + { + using var temp = new TempDirectory(); + var environment = ValidEnvironment(temp); + environment[GatewayFixtureIsolation.LocalAppDataDirectoryEnvironmentVariable] = overrideValue; + + var error = Assert.Throws( + () => GatewayFixtureIsolation.Get(environment.GetValueOrDefault)); + + Assert.Contains(GatewayFixtureIsolation.LocalAppDataDirectoryEnvironmentVariable, error.Message); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Fact] + public void Get_ReturnsCanonicalRootsWithoutCreatingThem() + { + using var temp = new TempDirectory(); + var environment = ValidEnvironment(temp); + environment[GatewayFixtureIsolation.DataDirectoryEnvironmentVariable] = + temp.Combine("unused", "..", "profile"); + + var context = GatewayFixtureIsolation.Get(environment.GetValueOrDefault); + + Assert.Equal(temp.Combine("profile"), context.DataDirectory); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + private static Dictionary ValidEnvironment(TempDirectory temp) => new() + { + [GatewayFixtureIsolation.ModeEnvironmentVariable] = "1", + [GatewayFixtureIsolation.DataDirectoryEnvironmentVariable] = temp.Combine("profile"), + [GatewayFixtureIsolation.LocalDataDirectoryEnvironmentVariable] = temp.Combine("setup-local"), + }; +} diff --git a/tests/OpenClaw.Shared.Tests/GatewayFixtureProtocolTests.cs b/tests/OpenClaw.Shared.Tests/GatewayFixtureProtocolTests.cs new file mode 100644 index 000000000..2121b743c --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/GatewayFixtureProtocolTests.cs @@ -0,0 +1,466 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenClaw.TestSupport; +using OpenClaw.TestSupport.Gateway; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +[Collection("WebSocketClientBase")] +public sealed class GatewayFixtureProtocolTests +{ + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(20); + + [Fact] + public void BrowseScenario_HasStableSingleSourceMetadataAndRejectsUnknownScenario() + { + var first = GatewayScenario.CreateBrowse(); + var second = GatewayScenario.LoadBuiltin(GatewayScenario.BrowseName); + Assert.Equal(first.Sha256, second.Sha256); + Assert.Equal(64, first.Sha256.Length); + Assert.Equal(GatewayProtocolContract.CurrentVersion, first.ProtocolVersion); + Assert.Equal(5, first.SessionKeys.Count); + Assert.Equal(5, first.SessionKeys.Distinct(StringComparer.Ordinal).Count()); + Assert.Contains("chat.history", first.ReadMethods); + Assert.DoesNotContain("chat.send", first.ReadMethods); + Assert.Throws(() => GatewayScenario.LoadBuiltin("not-a-scenario")); + } + + [Fact] + public async Task RealClient_ChallengeHandshakeResolvesCanonicalMainAndPopulatesAllSessions() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var client = connected.Client; + + Assert.Equal(IPAddress.Loopback.ToString(), server.Endpoint.Host); + Assert.NotEqual(0, server.Endpoint.Port); + Assert.True(client.HasHandshakeSnapshot); + Assert.Equal(GatewayScenario.MainSessionKey, client.MainSessionKey); + Assert.Equal(["operator.read"], client.GrantedOperatorScopes); + await server.HandshakeCompleted.WaitAsync(Deadline); + var sessions = client.GetSessionList(); + Assert.Equal(GatewayScenario.CreateBrowse().SessionKeys.Order(), sessions.Select(s => s.Key).Order()); + Assert.Equal(GatewayScenario.MainSessionKey, Assert.Single(sessions, s => s.IsMain).Key); + Assert.All(sessions, session => + { + Assert.False(string.IsNullOrWhiteSpace(session.SessionId)); + Assert.StartsWith("Fixture:", session.Label); + Assert.Equal("idle", session.Status); + }); + Assert.Equal(1, server.ConnectionCount); + Assert.Equal(1, server.ActiveConnectionCount); + Assert.Equal(1, server.SubscriptionCount); + await client.SendWizardRequestAsync("sessions.subscribe"); + Assert.Equal(1, server.SubscriptionCount); + Assert.Empty(server.UnexpectedRequests); + + var main = await client.RequestChatHistoryAsync(); + Assert.Equal(GatewayScenario.MainSessionKey, main.SessionKey); + Assert.Contains(main.Messages, m => m.Text == GatewayScenario.MainSentinel); + } + + [Fact] + public async Task RealClient_LongHistoryHas240MixedRowsStableIdentitiesAndActualFinalSentinel() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var history = await connected.Client.RequestChatHistoryAsync(GatewayScenario.LongSessionKey); + var repeat = await connected.Client.RequestChatHistoryAsync(GatewayScenario.LongSessionKey); + + Assert.Equal(GatewayScenario.LongMessageCount, history.Messages.Count); + Assert.Equal(history.SessionId, repeat.SessionId); + Assert.Equal(history.Messages.Select(m => m.OpenClawId), repeat.Messages.Select(m => m.OpenClawId)); + Assert.Equal(240, history.Messages.Select(m => m.OpenClawId).Distinct().Count()); + Assert.Equal(GatewayScenario.LongEarlySentinel, history.Messages[0].Text); + Assert.Equal(GatewayScenario.LongMiddleSentinel, history.Messages[119].Text); + Assert.StartsWith("Fixture long message 233", history.Messages[232].Text); + Assert.DoesNotContain(GatewayScenario.LongFinalSentinel, history.Messages[232].Text); + Assert.Contains(GatewayScenario.LongFinalSentinel, history.Messages[^1].Text); + Assert.EndsWith(GatewayScenario.LongFinalLine, history.Messages[^1].Text); + Assert.Equal(GatewayScenario.LongHistoryFinalMarker, history.Messages[^1].Text.Split('\n')[^1]); + Assert.Single(history.Messages, m => m.Text.Contains(GatewayScenario.LongHistoryFinalMarker, StringComparison.Ordinal)); + Assert.Contains(history.Messages, m => m.Text.Contains("```csharp", StringComparison.Ordinal)); + Assert.Contains(history.Messages, m => m.Text.Contains("| Item | State |", StringComparison.Ordinal)); + Assert.Contains(history.Messages, m => m.Text.Contains("- First synthetic observation", StringComparison.Ordinal)); + Assert.Contains(history.Messages, m => m.ToolContent.Count > 0); + Assert.All(history.Messages, m => + { + Assert.Equal(GatewayScenario.LongSessionKey, m.SessionKey); + Assert.InRange(m.Ts, 1_780_000_000_000L, 1_790_000_000_000L); + Assert.InRange(m.OpenClawSeq!.Value, 1, 240); + }); + Assert.True(history.Messages.Zip(history.Messages.Skip(1)).All(pair => pair.First.Ts < pair.Second.Ts)); + Assert.Empty(server.UnexpectedRequests); + } + + [Fact] + public async Task RealClient_EmptyAndOtherAgentHistoriesDoNotLeakOverlappingMessageIds() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var client = connected.Client; + var main = await client.RequestChatHistoryAsync(GatewayScenario.MainSessionKey); + var other = await client.RequestChatHistoryAsync(GatewayScenario.OtherSessionKey); + var empty = await client.RequestChatHistoryAsync(GatewayScenario.EmptySessionKey); + var edge = await client.RequestChatHistoryAsync(GatewayScenario.EdgeSessionKey); + + Assert.Empty(empty.Messages); + Assert.False(string.IsNullOrWhiteSpace(empty.SessionId)); + Assert.NotEqual(main.SessionId, other.SessionId); + Assert.Equal(main.Messages[0].OpenClawId, other.Messages[0].OpenClawId); + Assert.Equal(main.Messages[1].OpenClawId, edge.Messages[1].OpenClawId); + Assert.Contains(other.Messages, m => m.Text == GatewayScenario.OtherSentinel); + Assert.Contains(edge.Messages, m => m.Text == GatewayScenario.EdgeSentinel); + Assert.DoesNotContain(other.Messages, m => m.Text.Contains(GatewayScenario.MainSentinel, StringComparison.Ordinal)); + } + + [Fact] + public async Task RealClient_MeaningfulAgentKeyLimitAndPreviewParametersAreRespected() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var client = connected.Client; + var filtered = await client.SendWizardRequestAsync("sessions.list", new { agentId = "research", limit = 1 }); + var row = Assert.Single(filtered.GetProperty("sessions").EnumerateArray()); + Assert.Equal(GatewayScenario.OtherSessionKey, row.GetProperty("key").GetString()); + var recent = await client.SendWizardRequestAsync("sessions.list", new { activeMinutes = 1 }); + Assert.Equal(2, recent.GetProperty("count").GetInt32()); + var limited = await client.SendWizardRequestAsync("chat.history", new { sessionKey = GatewayScenario.LongSessionKey, limit = 3 }); + Assert.Equal(3, limited.GetProperty("messages").GetArrayLength()); + Assert.Contains(GatewayScenario.LongFinalSentinel, limited.GetProperty("messages")[2].GetProperty("content").GetString()); + + var preview = await client.SendWizardRequestAsync("sessions.preview", new + { + keys = new[] { GatewayScenario.OtherSessionKey, GatewayScenario.EmptySessionKey }, limit = 1, maxChars = 24 + }); + var previews = preview.GetProperty("previews"); + Assert.Equal(2, previews.GetArrayLength()); + Assert.Equal(GatewayScenario.OtherSessionKey, previews[0].GetProperty("key").GetString()); + var item = Assert.Single(previews[0].GetProperty("items").EnumerateArray()); + Assert.Equal(GatewayScenario.OtherSentinel[..24], item.GetProperty("text").GetString()); + Assert.Empty(previews[1].GetProperty("items").EnumerateArray()); + var cost = await client.SendWizardRequestAsync("usage.cost", new { days = 7 }); + Assert.Equal(7, cost.GetProperty("days").GetInt32()); + } + + [Fact] + public async Task RealClient_SupportingReadsUseTypedModelsAndConsistentConfiguration() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var client = connected.Client; + var modelsReceived = Signal(); + var previewsReceived = Signal(); + var configReceived = Signal(); + var schemaReceived = Signal(); + var nodePairsReceived = Signal(); + var devicePairsReceived = Signal(); + client.ModelsListUpdated += (_, models) => modelsReceived.TrySetResult(models); + client.SessionPreviewUpdated += (_, previews) => previewsReceived.TrySetResult(previews); + client.ConfigUpdated += (_, config) => configReceived.TrySetResult(config); + client.ConfigSchemaUpdated += (_, schema) => schemaReceived.TrySetResult(schema); + client.NodePairListUpdated += (_, pairs) => nodePairsReceived.TrySetResult(pairs); + client.DevicePairListUpdated += (_, pairs) => devicePairsReceived.TrySetResult(pairs); + + await client.RequestModelsListAsync(); + var models = await modelsReceived.Task.WaitAsync(Deadline); + Assert.Equal(2, models.Models.Count); + Assert.All(models.Models, model => Assert.True(model.IsAvailable && model.IsConfigured)); + Assert.Contains(models.Models, model => model.Id == "research" && model.Provider == "fixture"); + await client.RequestSessionPreviewAsync([GatewayScenario.MainSessionKey], limit: 1); + var previews = await previewsReceived.Task.WaitAsync(Deadline); + Assert.Equal(GatewayScenario.MainSentinel, Assert.Single(Assert.Single(previews.Previews).Items).Text); + await client.RequestConfigAsync(); + await client.RequestConfigSchemaAsync(); + var config = await configReceived.Task.WaitAsync(Deadline); + var schema = await schemaReceived.Task.WaitAsync(Deadline); + Assert.True(config.GetProperty("valid").GetBoolean()); + Assert.Equal(config.GetProperty("parsed").GetRawText(), config.GetProperty("config").GetRawText()); + using var rawConfig = JsonDocument.Parse(config.GetProperty("raw").GetString()!); + Assert.Equal(config.GetProperty("config").GetRawText(), rawConfig.RootElement.GetRawText()); + var properties = schema.GetProperty("schema").GetProperty("properties"); + Assert.All(config.GetProperty("parsed").EnumerateObject(), property => Assert.True(properties.TryGetProperty(property.Name, out _))); + Assert.Equal("fixture/browse", config.GetProperty("parsed").GetProperty("agents").GetProperty("defaults").GetProperty("model").GetProperty("primary").GetString()); + + var commands = await client.ListCommandsAsync(); + Assert.True(commands.IsSupported); + Assert.Equal("help", Assert.Single(commands.Commands).Name); + var health = await client.SendWizardRequestAsync("health", new { deep = true }); + Assert.True(health.GetProperty("ok").GetBoolean()); + var nodes = await client.SendWizardRequestAsync("node.list"); + Assert.Empty(nodes.GetProperty("nodes").EnumerateArray()); + await client.RequestNodePairListAsync(); + await client.RequestDevicePairListAsync(); + Assert.Empty((await nodePairsReceived.Task.WaitAsync(Deadline)).Pending); + Assert.Empty((await devicePairsReceived.Task.WaitAsync(Deadline)).Pending); + var agents = await client.SendWizardRequestAsync("agents.list"); + Assert.Equal("main", agents.GetProperty("defaultId").GetString()); + Assert.Equal(2, agents.GetProperty("agents").GetArrayLength()); + Assert.Empty(server.UnexpectedRequests); + } + + [Theory] + [InlineData("chat.history", """{"sessionKey":"not-a-fixture-session"}""", "Unknown fixture session key")] + [InlineData("chat.history", """{"sessionKey":"agent:main:main","limit":0}""", "positive integer")] + [InlineData("chat.history", """{"sessionKey":42}""", "nonempty string")] + [InlineData("chat.history", """{"sessionKey":"agent:main:main","typo":true}""", "Unsupported fixture request parameter")] + [InlineData("chat.history", "[]", "params must be an object")] + [InlineData("sessions.list", """{"agentId":"unknown"}""", "Unknown fixture agentId")] + [InlineData("sessions.list", """{"includeGlobal":"yes"}""", "must be a boolean")] + [InlineData("sessions.preview", """{"keys":[]}""", "nonempty array")] + [InlineData("sessions.preview", """{"keys":[7]}""", "session key strings")] + [InlineData("models.list", """{"view":"invented"}""", "configured or all")] + [InlineData("usage.cost", """{"days":-1}""", "positive integer")] + public async Task RealClient_InvalidOrUnknownParametersFailExplicitly(string method, string json, string message) + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + using var parameters = JsonDocument.Parse(json); + var error = await Assert.ThrowsAsync( + () => connected.Client.SendWizardRequestAsync(method, parameters.RootElement)); + Assert.Contains(message, error.Message); + Assert.Contains(server.Requests, request => request.Method == method && request.Outcome == "error:INVALID_PARAMS"); + Assert.Empty(server.UnexpectedRequests); + Assert.Equal(GatewayScenario.MainSessionKey, (await connected.Client.RequestChatHistoryAsync()).SessionKey); + } + + [Theory] + [InlineData("chat.send")] + [InlineData("sessions.patch")] + [InlineData("sessions.delete")] + [InlineData("config.patch")] + [InlineData("config.apply")] + [InlineData("node.invoke")] + [InlineData("exec.approval.resolve")] + public async Task RealClient_WritesAreRejectedWithoutChangingScenario(string method) + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var before = await connected.Client.SendWizardRequestAsync("config.get"); + var error = await Assert.ThrowsAsync( + () => method == "chat.send" + ? connected.Client.SendChatMessageForRunAsync("Synthetic write attempt.", GatewayScenario.MainSessionKey) + : (Task)connected.Client.SendWizardRequestAsync(method, new { sessionKey = GatewayScenario.MainSessionKey, message = "Synthetic write attempt." })); + Assert.Contains("read-only", error.Message); + var after = await connected.Client.SendWizardRequestAsync("config.get"); + Assert.Equal(before.GetRawText(), after.GetRawText()); + Assert.Contains(server.Requests, r => r.Method == method && r.Outcome == "error:FIXTURE_READ_ONLY"); + Assert.Empty(server.UnexpectedRequests); + } + + [Fact] + public async Task RealClient_UnknownMethodIsRecordedAndNeverReturnsFallbackSuccessOrCredentials() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + var error = await Assert.ThrowsAsync( + () => connected.Client.SendWizardRequestAsync("surprise.read", new { token, body = "PRIVATE-REQUEST-BODY" })); + Assert.Contains("Unknown fixture Gateway method", error.Message); + Assert.Equal("surprise.read", Assert.Single(server.UnexpectedRequests).Method); + Assert.Equal("error:METHOD_NOT_FOUND", Assert.Single(server.UnexpectedRequests).Outcome); + var diagnostic = JsonSerializer.Serialize(server.Requests); + Assert.DoesNotContain(token, diagnostic); + Assert.DoesNotContain("PRIVATE-REQUEST-BODY", diagnostic); + Assert.DoesNotContain("signature", diagnostic, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("publicKey", diagnostic, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RealClients_IndependentServersRejectTheOtherRunTokenAndKeepSubscriptionsSeparate() + { + var tokenA = CreateToken(); + var tokenB = CreateToken(); + await using var serverA = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), tokenA); + await using var serverB = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), tokenB); + Assert.NotEqual(serverA.Endpoint, serverB.Endpoint); + await using var first = await ConnectedClient.OpenAsync(serverA, tokenA); + await using var second = await ConnectedClient.OpenAsync(serverB, tokenB); + using var identity = CreateIdentity(); + using var wrong = new OpenClawGatewayClient(serverB.Endpoint.AbsoluteUri, tokenA, identityPath: identity.Path); + var rejected = Signal(); + wrong.ConnectionFailure += (_, error) => rejected.TrySetResult(error); + await wrong.ConnectAsync(); + Assert.Equal(GatewayErrorKind.Auth, await rejected.Task.WaitAsync(Deadline)); + Assert.False(wrong.HasHandshakeSnapshot); + Assert.True(wrong.IsAuthFailed); + Assert.Equal(1, serverA.SubscriptionCount); + Assert.Equal(1, serverB.SubscriptionCount); + Assert.Equal(1, serverA.ConnectionCount); + Assert.Equal(2, serverB.ConnectionCount); + Assert.Contains(serverB.Requests, r => r.Method == "connect" && r.Outcome == "error:AUTH_TOKEN_MISMATCH"); + await wrong.DisconnectAsync(); + Assert.Equal(GatewayScenario.MainSessionKey, (await second.Client.RequestChatHistoryAsync()).SessionKey); + } + + [Fact] + public async Task RealClient_ConcurrentFreshRequestIdsCorrelateOutOfOrderRepliesAndRepeatedReads() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + server.HoldHistory(GatewayScenario.LongSessionKey); + var historyA = connected.Client.RequestChatHistoryAsync(GatewayScenario.LongSessionKey); + using var deadline = new CancellationTokenSource(Deadline); + await server.WaitForRequestAsync("chat.history", GatewayScenario.LongSessionKey, 1, deadline.Token); + var reads = Enumerable.Range(0, 12).Select(i => connected.Client.RequestChatHistoryAsync( + i % 2 == 0 ? GatewayScenario.OtherSessionKey : GatewayScenario.MainSessionKey)).ToArray(); + var completed = await Task.WhenAll(reads).WaitAsync(Deadline); + Assert.False(historyA.IsCompleted); + for (var i = 0; i < completed.Length; i++) + Assert.Equal(i % 2 == 0 ? GatewayScenario.OtherSessionKey : GatewayScenario.MainSessionKey, completed[i].SessionKey); + server.ReleaseHistory(GatewayScenario.LongSessionKey); + var longHistory = await historyA.WaitAsync(Deadline); + Assert.Equal(240, longHistory.Messages.Count); + Assert.EndsWith(GatewayScenario.LongFinalLine, longHistory.Messages[^1].Text); + Assert.Empty(server.UnexpectedRequests); + } + + [Fact] + public async Task Shutdown_CancelsHeldReadsAndRequestWaitersAndClosesOwnedSockets() + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + await using var connected = await ConnectedClient.OpenAsync(server, token); + server.HoldHistory(GatewayScenario.LongSessionKey); + var history = connected.Client.RequestChatHistoryAsync(GatewayScenario.LongSessionKey); + using var deadline = new CancellationTokenSource(Deadline); + await server.WaitForRequestAsync("chat.history", GatewayScenario.LongSessionKey, 1, deadline.Token); + var missing = server.WaitForRequestAsync("never.requested"); + await server.DisposeAsync().AsTask().WaitAsync(Deadline); + await Assert.ThrowsAnyAsync(() => history.WaitAsync(Deadline)); + await Assert.ThrowsAnyAsync(() => missing); + Assert.Equal(0, server.ActiveConnectionCount); + Assert.Equal(0, server.SubscriptionCount); + Assert.Contains(server.Requests, r => r.Method == "chat.history" && r.Outcome == "cancelled"); + using var tcp = new TcpClient(); + await Assert.ThrowsAnyAsync(() => tcp.ConnectAsync(IPAddress.Loopback, server.Endpoint.Port)); + } + + [Fact] + public async Task Shutdown_DoesNotWaitForAnIncompleteHttpUpgrade() + { + using var cancellation = new CancellationTokenSource(); + await using var server = await FixtureGatewayServer.StartAsync( + GatewayScenario.CreateBrowse(), CreateToken(), cancellation.Token); + using var peer = new TcpClient(); + await peer.ConnectAsync(IPAddress.Loopback, server.Endpoint.Port); + await server.ConnectionAccepted.WaitAsync(Deadline); + Assert.Equal(1, server.ActiveConnectionCount); + await cancellation.CancelAsync(); + await server.DisposeAsync().AsTask().WaitAsync(Deadline); + Assert.Equal(0, server.ActiveConnectionCount); + await Assert.ThrowsAnyAsync(() => server.HandshakeCompleted); + Assert.Empty(server.Requests); + } + + [Theory] + [InlineData("\r\n\r\n")] + [InlineData("POST / HTTP/1.1\r\nSec-WebSocket-Key: PRIVATE-UPGRADE-DATA\r\n\r\n")] + [InlineData("GET / HTTP/1.1\r\n\r\n")] + public Task Upgrade_MalformedHeadersAreRecordedWithoutPoisoningServerOrShutdown(string headers) => + AssertRejectedUpgradeAsync(headers, "error:INVALID_UPGRADE"); + + [Fact] + public Task Upgrade_OversizedHeadersAreRecordedWithoutPoisoningServerOrShutdown() => + AssertRejectedUpgradeAsync(new string('x', 16 * 1024), "error:INVALID_UPGRADE"); + + [Theory] + [InlineData("")] + [InlineData("GET / HTTP/1.1\r\n")] + public Task Upgrade_DisconnectedPeerIsRecordedWithoutPoisoningServerOrShutdown(string headers) => + AssertRejectedUpgradeAsync(headers, "error:UPGRADE_DISCONNECTED"); + + private static async Task AssertRejectedUpgradeAsync(string headers, string outcome) + { + var token = CreateToken(); + await using var server = await FixtureGatewayServer.StartAsync(GatewayScenario.CreateBrowse(), token); + using var deadline = new CancellationTokenSource(Deadline); + using (var peer = new TcpClient()) + { + await peer.ConnectAsync(IPAddress.Loopback, server.Endpoint.Port, deadline.Token); + var stream = peer.GetStream(); + await stream.WriteAsync(Encoding.ASCII.GetBytes(headers), deadline.Token); + peer.Client.Shutdown(SocketShutdown.Send); + Assert.Equal(0, await stream.ReadAsync(new byte[1], deadline.Token)); + } + + await server.WaitForRequestAsync("", cancellationToken: deadline.Token); + await using (var connected = await ConnectedClient.OpenAsync(server, token)) + { + Assert.Equal(GatewayScenario.MainSessionKey, + (await connected.Client.RequestChatHistoryAsync()).SessionKey); + } + await server.DisposeAsync().AsTask().WaitAsync(Deadline); + + var rejected = Assert.Single(server.UnexpectedRequests); + Assert.Equal("", rejected.Method); + Assert.Null(rejected.SessionKey); + Assert.Equal(outcome, rejected.Outcome); + Assert.Equal(0, server.ActiveConnectionCount); + var diagnostic = JsonSerializer.Serialize(server.Requests); + Assert.DoesNotContain(token, diagnostic); + Assert.DoesNotContain("PRIVATE-UPGRADE-DATA", diagnostic); + } + + private static string CreateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static TempDirectory CreateIdentity() => new(Path.Combine(Directory.GetCurrentDirectory(), ".fixture-identity-")); + + private sealed class ConnectedClient(OpenClawGatewayClient client, TempDirectory identity) : IAsyncDisposable + { + public OpenClawGatewayClient Client { get; } = client; + + public static async Task OpenAsync(FixtureGatewayServer server, string token) + { + var startupOccurrence = server.Requests.Count(r => r.Method == "agents.list") + 1; + var identity = CreateIdentity(); + var client = new OpenClawGatewayClient( + server.Endpoint.AbsoluteUri, token, NullLogger.Instance, identityPath: identity.Path, + ignoreStoredDeviceToken: true, persistHandshakeDeviceTokens: false); + var owner = new ConnectedClient(client, identity); + var handshake = Signal(); + var sessions = Signal(); + client.HandshakeSucceeded += (_, _) => handshake.TrySetResult(true); + client.SessionsUpdated += (_, data) => + { + if (data.Length == 5) + sessions.TrySetResult(true); + }; + try + { + await client.ConnectAsync(); + await Task.WhenAll(handshake.Task, sessions.Task).WaitAsync(Deadline); + using var deadline = new CancellationTokenSource(Deadline); + await server.WaitForRequestAsync("agents.list", null, startupOccurrence, deadline.Token); + return owner; + } + catch + { + await owner.DisposeAsync(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + try { await Client.DisconnectAsync(); } + finally + { + Client.Dispose(); + identity.Dispose(); + } + } + } +} diff --git a/tests/OpenClaw.TestSupport/Gateway/FixtureGatewayServer.cs b/tests/OpenClaw.TestSupport/Gateway/FixtureGatewayServer.cs new file mode 100644 index 000000000..f166e67ba --- /dev/null +++ b/tests/OpenClaw.TestSupport/Gateway/FixtureGatewayServer.cs @@ -0,0 +1,446 @@ +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OpenClaw.TestSupport.Gateway; + +/// Safe diagnostic metadata only. Request IDs, credentials and payloads are never retained. +public sealed record GatewayFixtureRequest(string Method, string? SessionKey, string Outcome); + +/// +/// An independently owned, operator-only loopback Gateway. Responses are selected by +/// method and parameters, not consumed from a tape. No request is forwarded anywhere. +/// +public sealed class FixtureGatewayServer : IAsyncDisposable +{ + private readonly GatewayScenario _scenario; + private readonly byte[] _tokenHash; + private readonly TcpListener _listener; + private readonly CancellationTokenSource _lifetime; + private readonly object _sync = new(); + private readonly List _connections = []; + private readonly List _requests = []; + private readonly List _unexpectedIndices = []; + private readonly HashSet _subscriptions = []; + private readonly Dictionary _historyGates = new(StringComparer.Ordinal); + private readonly TaskCompletionSource _handshake = NewSignal(); + private readonly TaskCompletionSource _accepted = NewSignal(); + private TaskCompletionSource _requestChanged = NewSignal(); + private readonly Task _acceptLoop; + private Task? _disposal; + private int _connectionCount; + private int _activeConnectionCount; + private static readonly JsonElement EmptyParameters = JsonSerializer.SerializeToElement(new { }); + + public Uri Endpoint { get; } + public Task HandshakeCompleted => _handshake.Task; + public Task ConnectionAccepted => _accepted.Task; + public int ConnectionCount => Volatile.Read(ref _connectionCount); + public int ActiveConnectionCount => Volatile.Read(ref _activeConnectionCount); + public int SubscriptionCount + { + get { lock (_sync) return _subscriptions.Count; } + } + public IReadOnlyList Requests + { + get { lock (_sync) return _requests.ToArray(); } + } + public IReadOnlyList UnexpectedRequests + { + get { lock (_sync) return _unexpectedIndices.Select(i => _requests[i]).ToArray(); } + } + + private FixtureGatewayServer(GatewayScenario scenario, string token, CancellationToken cancellationToken) + { + _scenario = scenario; + _tokenHash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + _lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Endpoint = new Uri($"ws://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"); + _acceptLoop = AcceptLoopAsync(); + } + + public static Task StartAsync( + GatewayScenario scenario, string token, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(scenario); + ArgumentException.ThrowIfNullOrWhiteSpace(token); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new FixtureGatewayServer(scenario, token, cancellationToken)); + } + + /// Holds subsequent reads of this history until ReleaseHistory. Other requests continue normally. + public void HoldHistory(string sessionKey) + { + if (!_scenario.ContainsSession(sessionKey)) + throw new ArgumentException("Unknown fixture session key.", nameof(sessionKey)); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposal is not null, this); + if (!_historyGates.TryAdd(sessionKey, NewSignal())) + throw new InvalidOperationException("History is already held for this session."); + } + } + + public void ReleaseHistory(string sessionKey) + { + lock (_sync) + { + if (!_historyGates.Remove(sessionKey, out var signal)) + throw new InvalidOperationException("History is not held for this session."); + signal.TrySetResult(); + } + } + + /// Releases the gate immediately. Await response/UI readiness separately after releasing. + public Task ReleaseHistoryAsync(string sessionKey) + { + ReleaseHistory(sessionKey); + return Task.CompletedTask; + } + + /// + /// Waits for receipt, including a request currently held at a history gate. + /// Occurrence is one-based and includes earlier requests in this server's lifetime. + /// + public async Task WaitForRequestAsync( + string method, string? sessionKey = null, int occurrence = 1, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(occurrence); + using var wait = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + while (true) + { + Task changed; + lock (_sync) + { + var request = _requests.Where(r => r.Method == method && (sessionKey is null || r.SessionKey == sessionKey)) + .Skip(occurrence - 1).FirstOrDefault(); + if (request is not null) + return request; + changed = _requestChanged.Task; + } + await changed.WaitAsync(wait.Token); + } + } + + private async Task AcceptLoopAsync() + { + try + { + while (true) + { + var tcp = await _listener.AcceptTcpClientAsync(_lifetime.Token); + var id = Interlocked.Increment(ref _connectionCount); + lock (_sync) + _connections.Add(ServeConnectionAsync(tcp, id)); + _accepted.TrySetResult(); + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { } + catch (SocketException) when (_lifetime.IsCancellationRequested) { } + finally + { + _listener.Stop(); + _accepted.TrySetCanceled(_lifetime.Token); + _handshake.TrySetCanceled(_lifetime.Token); + } + } + + private async Task ServeConnectionAsync(TcpClient tcp, int connectionId) + { + using (tcp) + using (var connection = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token)) + using (var sendLock = new SemaphoreSlim(1, 1)) + { + Interlocked.Increment(ref _activeConnectionCount); + var pending = new List(); + WebSocket? socket = null; + try + { + socket = await UpgradeAsync(tcp.GetStream(), connection.Token); + var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(24)); + await SendAsync(socket, sendLock, new + { + type = "event", @event = "connect.challenge", + payload = new { nonce, ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() } + }, connection.Token); + var authenticated = false; + while (!connection.IsCancellationRequested) + { + var json = await ReceiveAsync(socket, connection.Token); + if (json is null) + { + await sendLock.WaitAsync(connection.Token); + try + { + await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Fixture connection closed.", connection.Token); + } + finally { sendLock.Release(); } + break; + } + + JsonElement root; + try + { + using var document = JsonDocument.Parse(json); + root = document.RootElement.Clone(); + } + catch (JsonException) + { + var malformed = Record("", null); + await SendErrorAsync(socket, sendLock, null, "INVALID_REQUEST", "Expected a Gateway JSON request.", connection.Token); + Complete(malformed, "error:INVALID_REQUEST", unexpected: true); + continue; + } + + var id = ReadString(root, "id"); + var method = ReadString(root, "method"); + if (ReadString(root, "type") != "req" || string.IsNullOrWhiteSpace(id) || !ValidMethod(method)) + { + var malformed = Record("", null); + await SendErrorAsync(socket, sendLock, id, "INVALID_REQUEST", "Expected a request type, string id and method.", connection.Token); + Complete(malformed, "error:INVALID_REQUEST", unexpected: true); + continue; + } + + var parameters = root.TryGetProperty("params", out var p) ? p : EmptyParameters; + var key = ReadString(parameters, "sessionKey") ?? ReadString(parameters, "key"); + var requestIndex = Record(method!, _scenario.ContainsSession(key ?? "") ? key : key is null ? null : ""); + if (!authenticated || method == "connect") + { + try + { + if (authenticated) + throw new FixtureRequestException("INVALID_REQUEST", "Operator is already connected."); + if (method != "connect") + throw new FixtureRequestException("AUTH_REQUIRED", "Operator connect is required before reading fixture data."); + Authenticate(parameters, nonce); + await SendAsync(socket, sendLock, new + { + type = "res", id, ok = true, payload = _scenario.CreateHello($"fixture-connection-{connectionId}") + }, connection.Token); + authenticated = true; + Complete(requestIndex, "ok"); + _handshake.TrySetResult(); + } + catch (FixtureRequestException ex) + { + await SendErrorAsync(socket, sendLock, id, ex.Code, ex.Message, connection.Token); + Complete(requestIndex, $"error:{ex.Code}"); + } + continue; + } + + Task? gate; + lock (_sync) + gate = method == "chat.history" && key is not null && _historyGates.TryGetValue(key, out var held) + ? held.Task : null; + pending.RemoveAll(task => task.IsCompletedSuccessfully); + pending.Add(RespondAsync(socket, sendLock, id!, method!, parameters, requestIndex, connectionId, gate, connection.Token)); + } + } + catch (OperationCanceledException) when (connection.IsCancellationRequested) { } + catch (WebSocketException) when (socket?.State is WebSocketState.Aborted or WebSocketState.Closed) { } + catch (IOException) when (connection.IsCancellationRequested) { } + catch (InvalidDataException) when (socket is null) + { + Complete(Record("", null), "error:INVALID_UPGRADE", unexpected: true); + } + catch (IOException) when (socket is null) + { + Complete(Record("", null), "error:UPGRADE_DISCONNECTED", unexpected: true); + } + finally + { + await connection.CancelAsync(); + socket?.Abort(); + try { await Task.WhenAll(pending); } + finally + { + socket?.Dispose(); + lock (_sync) _subscriptions.Remove(connectionId); + Interlocked.Decrement(ref _activeConnectionCount); + } + } + } + } + + private async Task RespondAsync( + WebSocket socket, SemaphoreSlim sendLock, string id, string method, JsonElement parameters, + int requestIndex, int connectionId, Task? gate, CancellationToken cancellationToken) + { + try + { + object payload; + try + { + if (parameters.ValueKind != JsonValueKind.Object) + throw new FixtureRequestException("INVALID_PARAMS", "params must be an object."); + payload = _scenario.Respond(method, parameters); + } + catch (FixtureRequestException ex) + { + Complete(requestIndex, $"error:{ex.Code}", ex.Unexpected); + await SendErrorAsync(socket, sendLock, id, ex.Code, ex.Message, cancellationToken); + return; + } + if (method == "sessions.subscribe") + lock (_sync) _subscriptions.Add(connectionId); + if (gate is not null) + await gate.WaitAsync(cancellationToken); + await SendAsync(socket, sendLock, new { type = "res", id, ok = true, payload }, cancellationToken); + Complete(requestIndex, "ok"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Complete(requestIndex, "cancelled"); + } + catch (WebSocketException) when (cancellationToken.IsCancellationRequested || socket.State != WebSocketState.Open) + { + Complete(requestIndex, "disconnected"); + } + } + + private void Authenticate(JsonElement p, string nonce) + { + if (p.ValueKind != JsonValueKind.Object) + throw new FixtureRequestException("INVALID_PARAMS", "connect params must be an object."); + if (!p.TryGetProperty("auth", out var auth) || ReadString(auth, "token") is not { } token + || !CryptographicOperations.FixedTimeEquals(_tokenHash, SHA256.HashData(Encoding.UTF8.GetBytes(token)))) + throw new FixtureRequestException("AUTH_TOKEN_MISMATCH", "Unauthorized: fixture token mismatch."); + if (ReadString(p, "role") != "operator") + throw new FixtureRequestException("INVALID_PARAMS", "Fixture Gateway supports only the operator role."); + if (!p.TryGetProperty("minProtocol", out var min) || min.ValueKind != JsonValueKind.Number || !min.TryGetInt32(out var minimum) + || !p.TryGetProperty("maxProtocol", out var max) || max.ValueKind != JsonValueKind.Number || !max.TryGetInt32(out var maximum) + || minimum > _scenario.ProtocolVersion || maximum < _scenario.ProtocolVersion || minimum > maximum) + throw new FixtureRequestException("PROTOCOL_MISMATCH", "Fixture Gateway protocol range mismatch."); + if (!p.TryGetProperty("client", out var client) || string.IsNullOrWhiteSpace(ReadString(client, "id")) + || !p.TryGetProperty("device", out var device) || ReadString(device, "nonce") != nonce + || string.IsNullOrWhiteSpace(ReadString(device, "id")) + || string.IsNullOrWhiteSpace(ReadString(device, "publicKey")) + || string.IsNullOrWhiteSpace(ReadString(device, "signature"))) + throw new FixtureRequestException("INVALID_PARAMS", "Expected a signed operator envelope for this challenge."); + } + + private int Record(string method, string? key) + { + lock (_sync) + { + var index = _requests.Count; + _requests.Add(new GatewayFixtureRequest(method, key, "pending")); + SignalRequestChanged(); + return index; + } + } + + private void Complete(int index, string outcome, bool unexpected = false) + { + lock (_sync) + { + _requests[index] = _requests[index] with { Outcome = outcome }; + if (unexpected) + _unexpectedIndices.Add(index); + SignalRequestChanged(); + } + } + + private void SignalRequestChanged() + { + var previous = _requestChanged; + _requestChanged = NewSignal(); + previous.TrySetResult(); + } + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static string? ReadString(JsonElement p, string name) => + p.ValueKind == JsonValueKind.Object && p.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + private static bool ValidMethod(string? method) => method is { Length: > 0 and <= 96 } + && method.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-'); + + private static Task SendErrorAsync( + WebSocket socket, SemaphoreSlim sendLock, string? id, string code, string message, CancellationToken ct) => + SendAsync(socket, sendLock, new + { + type = "res", id, ok = false, + error = new { code, message, details = new { code } } + }, ct); + + private static async Task SendAsync(WebSocket socket, SemaphoreSlim sendLock, object value, CancellationToken ct) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value); + await sendLock.WaitAsync(ct); + try { await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, ct); } + finally { sendLock.Release(); } + } + + private static async Task ReceiveAsync(WebSocket socket, CancellationToken ct) + { + var buffer = new byte[16 * 1024]; + using var message = new MemoryStream(); + while (true) + { + var result = await socket.ReceiveAsync(buffer.AsMemory(), ct); + if (result.MessageType == WebSocketMessageType.Close) + return null; + if (result.MessageType != WebSocketMessageType.Text || message.Length + result.Count > 1_048_576) + throw new InvalidDataException("Fixture expects text requests up to 1 MiB."); + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + return Encoding.UTF8.GetString(message.GetBuffer(), 0, checked((int)message.Length)); + } + } + + private static async Task UpgradeAsync(NetworkStream stream, CancellationToken ct) + { + // Same bounded HTTP upgrade leaf as Shared.Tests LoopbackWebSocketServer. + // Read exactly through CRLFCRLF so a coalesced first WebSocket frame is not consumed. + var headers = new List(); + var next = new byte[1]; + while (headers.Count < 16 * 1024) + { + if (await stream.ReadAsync(next.AsMemory(), ct) == 0) + throw new EndOfStreamException("Connection ended during the fixture WebSocket upgrade."); + headers.Add(next[0]); + if (headers.Count >= 4 && headers[^4] == '\r' && headers[^3] == '\n' + && headers[^2] == '\r' && headers[^1] == '\n') + break; + } + if (headers.Count == 16 * 1024) + throw new InvalidDataException("Fixture WebSocket headers exceeded 16 KiB."); + var lines = Encoding.ASCII.GetString(headers.ToArray()).Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + var key = lines.FirstOrDefault(line => line.StartsWith("Sec-WebSocket-Key:", StringComparison.OrdinalIgnoreCase))?.Split(':', 2)[1].Trim(); + if (lines.Length == 0 || !lines[0].StartsWith("GET / HTTP/1.1", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(key)) + throw new InvalidDataException("Expected a loopback WebSocket upgrade."); + var accept = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + var response = $"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), ct); + return WebSocket.CreateFromStream(stream, isServer: true, subProtocol: null, keepAliveInterval: TimeSpan.FromSeconds(30)); + } + + public ValueTask DisposeAsync() + { + lock (_sync) + return new ValueTask(_disposal ??= DisposeCoreAsync()); + } + + private async Task DisposeCoreAsync() + { + await _lifetime.CancelAsync(); + lock (_sync) + { + foreach (var gate in _historyGates.Values) + gate.TrySetCanceled(_lifetime.Token); + _historyGates.Clear(); + } + await _acceptLoop; + Task[] connections; + lock (_sync) connections = _connections.ToArray(); + try { await Task.WhenAll(connections); } + finally { _lifetime.Dispose(); } + } +} diff --git a/tests/OpenClaw.TestSupport/Gateway/GatewayScenario.cs b/tests/OpenClaw.TestSupport/Gateway/GatewayScenario.cs new file mode 100644 index 000000000..436a6a3dc --- /dev/null +++ b/tests/OpenClaw.TestSupport/Gateway/GatewayScenario.cs @@ -0,0 +1,451 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.TestSupport.Gateway; + +/// +/// Single synthetic source for the interactive host and protocol/UI tests. +/// This is a browse fixture, not a Gateway emulator or a recording of user data. +/// +public sealed class GatewayScenario +{ + public const string BrowseName = "multi-session-browse"; + public const string MainSessionKey = "agent:main:main"; + public const string LongSessionKey = "agent:main:fixture-long"; + public const string OtherSessionKey = "agent:research:fixture-other"; + public const string EmptySessionKey = "agent:main:fixture-empty"; + public const string EdgeSessionKey = "agent:research:fixture-edge"; + public const string MainTitle = "Fixture: main conversation"; + public const string LongTitle = "Fixture: 240-message conversation"; + public const string OtherTitle = "Fixture: research conversation"; + public const string LongSessionTitle = LongTitle; + public const string OtherSessionTitle = OtherTitle; + public const string EmptyTitle = "Fixture: empty conversation"; + public const string EdgeTitle = "Fixture: a deliberately long conversation title with repeated message identities across agents"; + public const string MainSentinel = "FIXTURE MAIN: the lighthouse is green."; + public const string LongEarlySentinel = "FIXTURE LONG BEGIN: message 001."; + public const string LongMiddleSentinel = "FIXTURE LONG MIDDLE: message 120."; + public const string LongFinalSentinel = "FIXTURE LONG FINAL MESSAGE 240"; + public const string LongHistoryFinalMarker = "FIXTURE LONG END 240"; + public const string LongFinalLine = LongHistoryFinalMarker; + public const string OtherSentinel = "FIXTURE RESEARCH: the violet compass points north."; + public const string OtherHistoryMarker = OtherSentinel; + public const string EdgeSentinel = "FIXTURE EDGE: shared IDs belong to this conversation only."; + public const int LongMessageCount = 240; + + private static readonly DateTimeOffset Epoch = new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero); + private readonly Session[] _sessions; + private readonly IReadOnlyDictionary _reads; + + public string Name => BrowseName; + public int Version => 1; + public int ProtocolVersion => GatewayProtocolContract.CurrentVersion; + public string ContractProvenance => + "OpenClaw.Shared GatewayProtocolContract/OpenClawGatewayClient and Protocol/gateway-protocol-snapshot.json"; + public string Sha256 { get; } + public IReadOnlyList SessionKeys { get; } + public IReadOnlyList ReadMethods { get; } + + private GatewayScenario(Session[] sessions, IReadOnlyDictionary reads) + { + _sessions = sessions; + _reads = reads; + SessionKeys = Array.AsReadOnly(sessions.Select(s => s.Key).ToArray()); + ReadMethods = Array.AsReadOnly(new[] + { + "sessions.list", "sessions.subscribe", "sessions.preview", "chat.history", + "models.list", "usage.cost" + }.Concat(reads.Keys).Order(StringComparer.Ordinal).ToArray()); + var source = JsonSerializer.Serialize(new + { + Name, Version, ProtocolVersion, ContractProvenance, + sessions = sessions.Select(s => new { s.Row, s.Messages }), reads + }); + Sha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))).ToLowerInvariant(); + } + + public static GatewayScenario LoadBuiltin(string name) => + name == BrowseName ? CreateBrowse() : throw new ArgumentException("Unknown fixture scenario.", nameof(name)); + + public static GatewayScenario CreateBrowse() + { + Session[] sessions = + [ + CreateSession(MainSessionKey, MainTitle, "main", "browse", 0, + ["Can we browse the synthetic Gateway?", MainSentinel]), + CreateSession(LongSessionKey, LongTitle, "main", "browse", 1, + Enumerable.Range(1, LongMessageCount).Select(LongMessage).ToArray()), + CreateSession(OtherSessionKey, OtherTitle, "research", "research", 2, + ["What did the synthetic research find?", OtherSentinel, "No provider was contacted.", OtherSentinel]), + CreateSession(EmptySessionKey, EmptyTitle, "main", "browse", 3, []), + CreateSession(EdgeSessionKey, EdgeTitle, "research", "research", 4, + ["These message IDs also exist in other sessions.", EdgeSentinel]) + ]; + var config = JsonSerializer.SerializeToElement(new + { + agents = new + { + defaults = new { model = new { primary = "fixture/browse" }, workspace = "/fixture/workspace" }, + list = new[] + { + new { id = "main", name = "Fixture Main", model = "fixture/browse" }, + new { id = "research", name = "Fixture Research", model = "fixture/research" } + } + }, + gateway = new { mode = "local", bind = "loopback" }, + session = new { scope = "per-sender" }, + messages = new { responsePrefix = "Fixture" } + }); + var schema = JsonSerializer.SerializeToElement(new + { + type = "object", + properties = new + { + agents = new + { + type = "object", + properties = new + { + defaults = new + { + type = "object", + properties = new + { + model = new + { + type = "object", + properties = new { primary = new { type = "string", title = "Default model" } } + }, + workspace = new { type = "string", title = "Synthetic workspace" } + } + }, + list = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + id = new { type = "string" }, name = new { type = "string" }, + model = new { type = "string" } + } + } + } + } + }, + gateway = new + { + type = "object", + properties = new { mode = new { type = "string" }, bind = new { type = "string" } } + }, + session = new { type = "object", properties = new { scope = new { type = "string" } } }, + messages = new { type = "object", properties = new { responsePrefix = new { type = "string" } } } + } + }); + var configHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(config.GetRawText()))).ToLowerInvariant(); + var reads = new Dictionary(StringComparer.Ordinal) + { + ["health"] = JsonSerializer.SerializeToElement(new + { + ok = true, ts = Epoch.ToUnixTimeMilliseconds(), durationMs = 0, + channels = new { }, channelOrder = Array.Empty(), + defaultAgentId = "main", + agents = new[] { new { agentId = "main", isDefault = true }, new { agentId = "research", isDefault = false } } + }), + ["agents.list"] = JsonSerializer.SerializeToElement(new + { + defaultId = "main", mainKey = "main", scope = "per-sender", + agents = new[] + { + new { id = "main", name = "Fixture Main", identity = new { name = "Fixture Main" } }, + new { id = "research", name = "Fixture Research", identity = new { name = "Fixture Research" } } + } + }), + ["node.list"] = JsonSerializer.SerializeToElement(new { ts = Epoch.ToUnixTimeMilliseconds(), nodes = Array.Empty() }), + ["node.pair.list"] = JsonSerializer.SerializeToElement(new { pending = Array.Empty(), paired = Array.Empty() }), + ["device.pair.list"] = JsonSerializer.SerializeToElement(new { pending = Array.Empty(), paired = Array.Empty() }), + ["usage.status"] = JsonSerializer.SerializeToElement(new + { + updatedAt = Epoch.ToUnixTimeMilliseconds(), providers = Array.Empty() + }), + ["commands.list"] = JsonSerializer.SerializeToElement(new + { + commands = new[] + { + new + { + name = "help", description = "Show available commands (fixture browsing only).", + source = "native", scope = "text", acceptsArgs = false, args = Array.Empty() + } + } + }), + ["config.get"] = JsonSerializer.SerializeToElement(new + { + path = "/fixture/openclaw.json", exists = true, raw = config.GetRawText(), + parsed = config, config, valid = true, hash = configHash, + issues = Array.Empty(), warnings = Array.Empty(), legacyIssues = Array.Empty() + }), + ["config.schema"] = JsonSerializer.SerializeToElement(new + { + schema, uiHints = new Dictionary + { + ["agents"] = new { label = "Fixture agents", group = "Agents", order = 10 }, + ["gateway"] = new { label = "Fixture Gateway", group = "Gateway", order = 20 } + }, + version = "fixture-1", generatedAt = Epoch.ToString("O") + }) + }; + return new GatewayScenario(sessions, reads); + } + + internal object CreateHello(string connectionId) => new + { + type = GatewayProtocolContract.HelloOkType, + protocol = ProtocolVersion, + server = new { version = "fixture-1", connId = connectionId }, + features = new { methods = ReadMethods, events = new[] { "connect.challenge", "sessions.changed" } }, + snapshot = new + { + presence = Array.Empty(), health = _reads["health"], + sessionDefaults = new { defaultAgentId = "main", mainKey = "main", mainSessionKey = MainSessionKey, scope = "per-sender" } + }, + auth = new { role = "operator", scopes = new[] { "operator.read" } }, + policy = new { maxPayload = 1_048_576, maxBufferedBytes = 1_048_576, tickIntervalMs = 30_000 } + }; + + internal bool ContainsSession(string key) => _sessions.Any(s => s.Key == key); + + internal object Respond(string method, JsonElement parameters) + { + if (IsWrite(method)) + throw new FixtureRequestException("FIXTURE_READ_ONLY", "Fixture Gateway is read-only. This operation is not executed."); + return method switch + { + "sessions.list" => ListSessions(parameters), + "sessions.subscribe" => Subscribe(parameters), + "sessions.preview" => Preview(parameters), + "chat.history" => History(parameters), + "models.list" => Models(parameters), + "usage.cost" => Cost(parameters), + _ when _reads.ContainsKey(method) => Read(method, parameters), + _ => throw new FixtureRequestException("METHOD_NOT_FOUND", "Unknown fixture Gateway method.", unexpected: true) + }; + } + + private object ListSessions(JsonElement p) + { + ValidateProperties(p, "agentId", "limit", "activeMinutes", "includeGlobal", "includeUnknown", "includeDerivedTitles", "includeLastMessage"); + var agent = OptionalString(p, "agentId"); + if (agent is not null && agent is not ("main" or "research")) + throw new FixtureRequestException("INVALID_PARAMS", "Unknown fixture agentId."); + var limit = PositiveInt(p, "limit", _sessions.Length); + var activeMinutes = PositiveInt(p, "activeMinutes", int.MaxValue); + foreach (var flag in new[] { "includeGlobal", "includeUnknown", "includeDerivedTitles", "includeLastMessage" }) + OptionalBoolean(p, flag); + var rows = _sessions.Where(s => agent is null || s.AgentId == agent) + .Where(s => Epoch.ToUnixTimeMilliseconds() - s.UpdatedAt <= (long)activeMinutes * 60_000) + .Take(limit).Select(s => s.Row).ToArray(); + return new + { + ts = Epoch.ToUnixTimeMilliseconds(), count = rows.Length, + defaults = new { modelProvider = "fixture", model = "browse", contextTokens = 128_000 }, + sessions = rows + }; + } + + private object History(JsonElement p) + { + ValidateProperties(p, "sessionKey", "limit"); + var session = FindSession(RequiredString(p, "sessionKey")); + var limit = PositiveInt(p, "limit", LongMessageCount); + return new + { + sessionKey = session.Key, sessionId = session.Id, + messages = session.Messages.TakeLast(limit).ToArray(), thinkingLevel = "off" + }; + } + + private object Preview(JsonElement p) + { + ValidateProperties(p, "keys", "limit", "maxChars"); + if (!p.TryGetProperty("keys", out var keys) || keys.ValueKind != JsonValueKind.Array || keys.GetArrayLength() == 0) + throw new FixtureRequestException("INVALID_PARAMS", "keys must be a nonempty array."); + var sessions = keys.EnumerateArray().Select(key => key.ValueKind == JsonValueKind.String + ? FindSession(key.GetString()!) + : throw new FixtureRequestException("INVALID_PARAMS", "keys must contain session key strings.")).ToArray(); + var limit = PositiveInt(p, "limit", 12); + var maxChars = PositiveInt(p, "maxChars", 240); + return new + { + ts = Epoch.ToUnixTimeMilliseconds(), + previews = sessions.Select(s => new + { + key = s.Key, status = s.Messages.Length == 0 ? "empty" : "ok", + items = s.Texts.TakeLast(limit).Select((text, index) => new + { + role = s.Messages[s.Messages.Length - Math.Min(limit, s.Messages.Length) + index].GetProperty("role").GetString(), + text = text[..Math.Min(text.Length, maxChars)] + }).ToArray() + }).ToArray() + }; + } + + private static object Subscribe(JsonElement p) + { + ValidateProperties(p); + return new { ok = true }; + } + + private static object Models(JsonElement p) + { + ValidateProperties(p, "view"); + var view = OptionalString(p, "view") ?? "configured"; + if (view is not ("configured" or "all")) + throw new FixtureRequestException("INVALID_PARAMS", "view must be configured or all."); + return new + { + models = new[] + { + new { id = "browse", name = "Fixture Browse", provider = "fixture", contextWindow = 128_000, configured = true, available = true, @default = true }, + new { id = "research", name = "Fixture Research", provider = "fixture", contextWindow = 64_000, configured = true, available = true, @default = false } + } + }; + } + + private static object Cost(JsonElement p) + { + ValidateProperties(p, "days"); + var days = PositiveInt(p, "days", 30); + return new + { + updatedAt = Epoch.ToUnixTimeMilliseconds(), days, daily = Array.Empty(), + totals = new { input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0, missingCostEntries = 0 } + }; + } + + private JsonElement Read(string method, JsonElement p) + { + ValidateProperties(p, method == "health" ? ["deep", "probe"] : []); + if (method == "health") + { + OptionalBoolean(p, "deep"); + OptionalBoolean(p, "probe"); + } + return _reads[method]; + } + + private Session FindSession(string key) => _sessions.FirstOrDefault(s => s.Key == key) + ?? throw new FixtureRequestException("INVALID_PARAMS", "Unknown fixture session key."); + + private static bool IsWrite(string method) => method is + "chat.send" or "chat.abort" or "chat.inject" or "config.set" or "config.patch" or "config.apply" + or "sessions.patch" or "sessions.reset" or "sessions.delete" or "sessions.compact" or "sessions.create" + or "sessions.compaction.branch" or "node.invoke" or "exec.approval.resolve" or "node.rename" + or "node.pair.approve" or "node.pair.reject" or "node.pair.remove" or "device.pair.approve" + or "device.pair.reject" or "device.token.rotate" or "device.token.revoke" or "update.run" + or "cron.add" or "cron.update" or "cron.remove" or "cron.run" or "skills.install" or "skills.update"; + + internal static void ValidateProperties(JsonElement p, params string[] names) + { + if (p.ValueKind != JsonValueKind.Object) + throw new FixtureRequestException("INVALID_PARAMS", "params must be an object."); + if (p.EnumerateObject().Any(property => !names.Contains(property.Name, StringComparer.Ordinal))) + throw new FixtureRequestException("INVALID_PARAMS", "Unsupported fixture request parameter."); + } + + internal static string RequiredString(JsonElement p, string name) => OptionalString(p, name) + ?? throw new FixtureRequestException("INVALID_PARAMS", $"Missing {name}."); + + private static string? OptionalString(JsonElement p, string name) + { + if (!p.TryGetProperty(name, out var value)) + return null; + if (value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString())) + throw new FixtureRequestException("INVALID_PARAMS", $"{name} must be a nonempty string."); + return value.GetString(); + } + + private static int PositiveInt(JsonElement p, string name, int fallback) + { + if (!p.TryGetProperty(name, out var value)) + return fallback; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result) || result <= 0) + throw new FixtureRequestException("INVALID_PARAMS", $"{name} must be a positive integer."); + return result; + } + + private static void OptionalBoolean(JsonElement p, string name) + { + if (p.TryGetProperty(name, out var value) && value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new FixtureRequestException("INVALID_PARAMS", $"{name} must be a boolean."); + } + + private static Session CreateSession(string key, string title, string agent, string model, int index, string[] texts) + { + var id = $"00000000-0000-4000-8000-{index + 1:D12}"; + var updatedAt = Epoch.AddMinutes(-index).ToUnixTimeMilliseconds(); + var row = JsonSerializer.SerializeToElement(new + { + key, sessionId = id, agentId = agent, label = title, displayName = title, derivedTitle = title, + kind = "direct", chatType = "direct", channel = "webchat", status = "idle", hasActiveRun = false, + isBackground = false, isMain = key == MainSessionKey, model, modelProvider = "fixture", + updatedAt, startedAt = Epoch.AddDays(-1).ToString("O"), thinkingLevel = "off", + inputTokens = 120, outputTokens = 80, totalTokens = 200, contextTokens = 128_000 + }); + var messages = texts.Select((text, i) => + { + var number = i + 1; + var role = i % 2 == 0 ? "user" : "assistant"; + object content = text; + if (key == LongSessionKey && number == 40) + { + content = new object[] + { + new { type = "text", text }, + new { type = "toolCall", id = "fixture-inert-tool", name = "fixture_inventory", arguments = new { shelf = "synthetic" } } + }; + } + if (key == LongSessionKey && number == 41) + { + role = "toolResult"; + content = new object[] { new { type = "text", text = text + "\nSynthetic inventory: 3 books. No command executed." } }; + } + return JsonSerializer.SerializeToElement(new + { + role, content, timestamp = Epoch.AddHours(-6).AddMinutes(i).ToUnixTimeMilliseconds(), + toolCallId = number == 41 && key == LongSessionKey ? "fixture-inert-tool" : null, + toolName = number == 41 && key == LongSessionKey ? "fixture_inventory" : null, + stopReason = number == 40 && key == LongSessionKey ? "toolUse" : "stop", + __openclaw = new { id = i < 2 ? $"fixture-shared-{number:D3}" : $"{id}-message-{number:D3}", seq = number, kind = "message" } + }); + }).ToArray(); + return new Session(key, id, agent, updatedAt, row, messages, texts); + } + + private static string LongMessage(int number) + { + if (number == 1) return LongEarlySentinel; + if (number == 120) return LongMiddleSentinel; + if (number == LongMessageCount) return $"{LongFinalSentinel}\n\nThis is the actual last message, not a nearby row.\n\n{LongFinalLine}"; + var heading = $"Fixture long message {number:D3}"; + return (number % 5) switch + { + 0 => $"{heading}\n\n- First synthetic observation\n- Second observation with a longer explanation that wraps at narrow widths\n- Third observation\n\nNothing here invokes a provider.", + 1 => $"{heading}\n\n```csharp\nvar fixture = \"inert text\";\nConsole.WriteLine(fixture);\n```\n\nThis code block is display-only.", + 2 => $"{heading}\n\n| Item | State |\n| --- | --- |\n| Copper telescope | Parked |\n| Violet compass | Ready |\n\nA small synthetic table.", + 3 => $"{heading}\n\nA longer paragraph for mixed-height virtualization. The quiet observatory has three windows and a copper telescope. All names and events are fictional.\n\nA second paragraph makes the narrow viewport wrap differently from the wide viewport.", + _ => $"{heading}: short synthetic reply." + }; + } + + private sealed record Session(string Key, string Id, string AgentId, long UpdatedAt, JsonElement Row, JsonElement[] Messages, string[] Texts); +} + +internal sealed class FixtureRequestException(string code, string message, bool unexpected = false) : Exception(message) +{ + public string Code { get; } = code; + public bool Unexpected { get; } = unexpected; +} diff --git a/tests/OpenClaw.Tray.IntegrationTests/McpClient.cs b/tests/OpenClaw.TestSupport/McpClient.cs similarity index 98% rename from tests/OpenClaw.Tray.IntegrationTests/McpClient.cs rename to tests/OpenClaw.TestSupport/McpClient.cs index 5a27258da..fd970fd3e 100644 --- a/tests/OpenClaw.Tray.IntegrationTests/McpClient.cs +++ b/tests/OpenClaw.TestSupport/McpClient.cs @@ -4,7 +4,7 @@ using System.Text.Json; using System.Threading.Tasks; -namespace OpenClaw.Tray.IntegrationTests; +namespace OpenClaw.TestSupport; /// /// Tiny JSON-RPC over HTTP client for the MCP endpoint. Exposes the two methods diff --git a/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureAppTests.cs b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureAppTests.cs new file mode 100644 index 000000000..d295e3f0e --- /dev/null +++ b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureAppTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using OpenClaw.GatewayFixtureHost; + +namespace OpenClaw.Tray.IntegrationTests; + +public sealed class GatewayFixtureAppFactAttribute : FactAttribute +{ + public GatewayFixtureAppFactAttribute() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_RUN_GATEWAY_FIXTURE_UI") != "1") + Skip = "Run scripts\\test-gateway-fixture.ps1 with an explicitly built app."; + } +} + +[Collection("Gateway fixture environment")] +public sealed class GatewayFixtureAppTests +{ + [GatewayFixtureAppFact] + public async Task RealOperatorPopulatesSessionsWithoutEnablingNodeExecution() + { + await using var run = await StartAsync(); + try + { + var status = await run.InvokeAsync("app.status"); + Assert.Equal("Connected", status.GetProperty("operatorState").GetString()); + Assert.False(status.GetProperty("nodeConnected").GetBoolean()); + Assert.True(status.GetProperty("sessionCount").GetInt32() >= 5); + Assert.False((await run.InvokeAsync("app.settings.get", new { name = "EnableNodeMode" })).GetBoolean()); + using var discovery = await run.Client.ListToolsAsync(); + var tools = discovery.RootElement.GetProperty("result").GetProperty("tools") + .EnumerateArray().Select(tool => tool.GetProperty("name").GetString()).ToArray(); + Assert.Contains("app.chat.snapshot", tools); + Assert.DoesNotContain("system.run", tools); + Assert.DoesNotContain("camera.snap", tools); + Assert.DoesNotContain("browser.proxy", tools); + var sessions = await run.InvokeAsync("app.sessions"); + Assert.True(sessions.GetArrayLength() >= 5); + using var autoStart = await run.Client.CallToolAsync("app.settings.set", new { name = "AutoStart", value = "true" }); + Assert.True(autoStart.RootElement.GetProperty("result").GetProperty("isError").GetBoolean()); + Assert.False((await run.InvokeAsync("app.settings.get", new { name = "AutoStart" })).GetBoolean()); + Assert.Empty(run.Gateway.UnexpectedRequests); + await run.WriteReportAsync("passed"); + } + catch (Exception ex) + { + await run.WriteReportAsync("failed", ex); + throw; + } + } + + [GatewayFixtureAppFact] + public async Task ConcurrentAppsHaveIndependentProfilesMcpTokensAndPreferences() + { + var starts = new[] { StartAsync(), StartAsync() }; + try + { + var runs = await Task.WhenAll(starts); + var first = runs[0]; + var second = runs[1]; + Assert.NotEqual(first.AppProcessId, second.AppProcessId); + Assert.NotEqual(first.McpPort, second.McpPort); + Assert.NotEqual(first.Gateway.Endpoint, second.Gateway.Endpoint); + Assert.NotEqual(first.Profile.GatewayId, second.Profile.GatewayId); + Assert.NotEqual(first.Profile.DataDirectory, second.Profile.DataDirectory); + await first.InvokeAsync("app.settings.set", new { name = "NotifyInfo", value = "false" }); + Assert.False((await first.InvokeAsync("app.settings.get", new { name = "NotifyInfo" })).GetBoolean()); + Assert.True((await second.InvokeAsync("app.settings.get", new { name = "NotifyInfo" })).GetBoolean()); + var firstToken = (await File.ReadAllTextAsync(Path.Combine(first.Profile.DataDirectory, "mcp-token.txt"))).Trim(); + using var wrongClient = new McpClient($"http://127.0.0.1:{second.McpPort}/mcp", firstToken); + await Assert.ThrowsAsync(async () => + { + using var response = await wrongClient.ListToolsAsync(); + }); + await first.WriteReportAsync("passed"); + await first.DisposeAsync(); + second.EnsureRunning(); + Assert.True((await second.InvokeAsync("app.sessions")).GetArrayLength() >= 5); + using var persisted = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(second.Profile.DataDirectory, "settings.json"))); + Assert.True(persisted.RootElement.GetProperty("NotifyInfo").GetBoolean()); + await second.WriteReportAsync("passed"); + } + finally + { + foreach (var start in starts) + if (start.IsCompletedSuccessfully) + await start.Result.DisposeAsync(); + } + } + + private static Task StartAsync() => + GatewayFixtureRun.StartAsync( + Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_FIXTURE_APP") + ?? throw new InvalidOperationException("An explicit current app build is required."), + Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_FIXTURE_ARTIFACTS")); +} diff --git a/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureProfileTests.cs b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureProfileTests.cs new file mode 100644 index 000000000..bf87ba721 --- /dev/null +++ b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureProfileTests.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.GatewayFixtureHost; + +namespace OpenClaw.Tray.IntegrationTests; + +[CollectionDefinition("Gateway fixture environment", DisableParallelization = true)] +public sealed class GatewayFixtureEnvironmentCollection { } + +[Collection("Gateway fixture environment")] +public sealed class GatewayFixtureProfileTests +{ + private static readonly Uri Endpoint = new("ws://127.0.0.1:49231/"); + + [Theory] + [InlineData("ws://example.org:49231/")] + [InlineData("ws://localhost:49231/")] + [InlineData("wss://127.0.0.1:49231/")] + [InlineData("ws://127.0.0.1:18789/")] + [InlineData("ws://127.0.0.1:8765/")] + [InlineData("ws://secret@127.0.0.1:49231/")] + [InlineData("ws://127.0.0.1:49231/?token=secret")] + [InlineData("relative")] + public void EndpointMustBeDedicatedNumericLoopback(string endpoint) + { + Assert.Throws(() => + new GatewayFixtureProfile(new Uri(endpoint, UriKind.RelativeOrAbsolute), "fixture-token")); + } + + [Fact] + public void ProfileUsesRealRegistryFormatAndSyntheticSafeSettings() + { + using var profile = new GatewayFixtureProfile(Endpoint, "only-this-fixture"); + var registry = new GatewayRegistry(profile.DataDirectory); + registry.Load(); + var record = Assert.Single(registry.GetAll()); + Assert.Equal(record.Id, registry.ActiveGatewayId); + Assert.Equal(profile.GatewayId, record.Id); + Assert.Equal(Endpoint.AbsoluteUri, record.Url); + Assert.Equal("only-this-fixture", record.SharedGatewayToken); + Assert.Null(record.SetupManagedDistroName); + Assert.False(record.IsLocal); + Assert.Null(record.SshTunnel); + Assert.Null(record.BootstrapToken); + using var settings = JsonDocument.Parse(File.ReadAllText(Path.Combine(profile.DataDirectory, "settings.json"))); + Assert.True(settings.RootElement.GetProperty("EnableMcpServer").GetBoolean()); + foreach (var disabled in new[] + { + "EnableNodeMode", "AutoStart", "GlobalHotkeyEnabled", "NodeSystemRunEnabled", + "NodeBrowserProxyEnabled", "NodeScreenEnabled", "NodeCameraEnabled", + "NodeLocationEnabled", "NodeCanvasEnabled", "EnableManagedLocalGatewayAutoRepair", + "NodeOllamaInferenceEnabled", "VoiceTtsEnabled", "UseLegacyWebChat" + }) + Assert.False(settings.RootElement.GetProperty(disabled).GetBoolean(), disabled); + Assert.False(File.Exists(Path.Combine(profile.DataDirectory, "device-key-ed25519.json"))); + Assert.Empty(Directory.EnumerateFiles(profile.SetupDirectory)); + } + + [Fact] + public void TwoProfilesNeverShareIdentityOrStateAndCleanupIsOwned() + { + using var sentinel = new TempDirectory(); + var sentinelFile = sentinel.Combine("installed-pairing.json"); + File.WriteAllText(sentinelFile, "synthetic sentinel, never a real pairing"); + using var environment = new EnvironmentScope("OPENCLAW_TRAY_DATA_DIR", sentinel.Path); + using var second = new GatewayFixtureProfile(new Uri("ws://127.0.0.1:49232/"), "second-token"); + var first = new GatewayFixtureProfile(Endpoint, "first-token"); + Assert.NotEqual(first.RunDirectory, second.RunDirectory); + Assert.NotEqual(first.GatewayId, second.GatewayId); + Assert.NotEqual(first.DataDirectory, sentinel.Path); + var firstRoot = first.RunDirectory; + first.Dispose(); + first.Dispose(); + Assert.False(Directory.Exists(firstRoot)); + Assert.True(Directory.Exists(second.DataDirectory)); + Assert.Equal("synthetic sentinel, never a real pairing", File.ReadAllText(sentinelFile)); + } + + [Fact] + public void ChildEnvironmentClearsInheritedOverridesWithoutChangingParent() + { + using var app = CreateFakeSupportedApp(); + using var profile = new GatewayFixtureProfile(Endpoint, "fixture-token"); + using var environment = new EnvironmentScope("OPENCLAW_TRAY_DATA_DIR", "must-not-use") + .Set("OPENCLAW_TRAY_LOCALAPPDATA_DIR", "must-not-use") + .Set("OPENCLAW_ACCESSIBILITY_TEST_CHAT", "1") + .Set("OPENCLAW_FORCE_ONBOARDING", "1") + .Set("OPENCLAW_FUTURE_UNRECOGNIZED_OVERRIDE", "must-not-use"); + + var start = profile.CreateStartInfo(app.Combine("OpenClaw.Tray.WinUI.exe"), 49233); + Assert.False(start.UseShellExecute); + Assert.Equal(profile.DataDirectory, start.Environment["OPENCLAW_TRAY_DATA_DIR"]); + Assert.Equal(profile.SetupDirectory, start.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"]); + Assert.Equal("1", start.Environment["OPENCLAW_GATEWAY_FIXTURE"]); + Assert.Equal("49233", start.Environment["OPENCLAW_MCP_PORT"]); + Assert.False(start.Environment.ContainsKey("OPENCLAW_ACCESSIBILITY_TEST_CHAT")); + Assert.False(start.Environment.ContainsKey("OPENCLAW_FORCE_ONBOARDING")); + Assert.False(start.Environment.ContainsKey("OPENCLAW_TRAY_LOCALAPPDATA_DIR")); + Assert.False(start.Environment.ContainsKey("OPENCLAW_FUTURE_UNRECOGNIZED_OVERRIDE")); + Assert.Equal("must-not-use", Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR")); + } + + [Theory] + [InlineData(0)] + [InlineData(65536)] + [InlineData(8765)] + [InlineData(18789)] + [InlineData(49231)] + public void McpPortCannotBeInvalidDefaultOrGatewayPort(int port) + { + using var app = CreateFakeSupportedApp(); + using var profile = new GatewayFixtureProfile(Endpoint, "fixture-token"); + Assert.Throws(() => + profile.CreateStartInfo(app.Combine("OpenClaw.Tray.WinUI.exe"), port)); + } + + [Theory] + [InlineData("{}")] + [InlineData("""{"runtimeOptions":{"configProperties":{}}}""")] + [InlineData("""{"runtimeOptions":{"configProperties":{"OpenClaw.GatewayFixtureIsolationVersion":0}}}""")] + public void OlderAppIsRejectedBeforeProcessLaunch(string runtimeConfig) + { + using var app = CreateFakeSupportedApp(); + File.WriteAllText(app.Combine("OpenClaw.Tray.WinUI.runtimeconfig.json"), runtimeConfig); + Assert.Throws(() => + GatewayFixtureProfile.ValidateApp(app.Combine("OpenClaw.Tray.WinUI.exe"))); + } + + [Fact] + public void MissingAppNeverFallsBackToInstalledExecutable() + { + using var directory = new TempDirectory(); + Assert.Throws(() => + GatewayFixtureProfile.ValidateApp(directory.Combine("OpenClaw.Tray.WinUI.exe"))); + } + + private static TempDirectory CreateFakeSupportedApp() + { + var directory = new TempDirectory(); + File.WriteAllText(directory.Combine("OpenClaw.Tray.WinUI.exe"), "test metadata only; never executable"); + File.WriteAllText(directory.Combine("OpenClaw.Tray.WinUI.runtimeconfig.json"), + """{"runtimeOptions":{"configProperties":{"OpenClaw.GatewayFixtureIsolationVersion":1}}}"""); + return directory; + } +} diff --git a/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureRunTests.cs b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureRunTests.cs new file mode 100644 index 000000000..e02311651 --- /dev/null +++ b/tests/OpenClaw.Tray.IntegrationTests/GatewayFixtureRunTests.cs @@ -0,0 +1,136 @@ +using OpenClaw.GatewayFixtureHost; + +namespace OpenClaw.Tray.IntegrationTests; + +public sealed class GatewayFixtureRunTests +{ + private const string Description = "history loaded for agent:main:fixture-long"; + private const string ArtifactsDirectory = @"C:\fixture-artifacts\test-run"; + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(5); + + [Fact] + public async Task WaitForAsync_SlowConditionPreservesTimeoutContext() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + var error = await Assert.ThrowsAsync(() => + WaitAsync(() => pending.Task, timeout: TimeSpan.FromSeconds(1))); + + AssertTimeoutContext(error); + Assert.IsType(error.InnerException); + } + finally + { + pending.TrySetResult(false); + } + } + + [Fact] + public async Task WaitForAsync_ConditionTimeoutPreservesOriginalExceptionAndContext() + { + var original = new TimeoutException("Synthetic MCP timeout."); + var error = await Assert.ThrowsAsync(() => + WaitAsync(() => Task.FromException(original))); + + AssertTimeoutContext(error); + Assert.Same(original, error.InnerException); + } + + [Fact] + public async Task WaitForAsync_ExpiredDeadlinePreservesTimeoutContext() + { + var invoked = false; + var error = await Assert.ThrowsAsync(() => + WaitAsync(() => + { + invoked = true; + return Task.FromResult(true); + }, timeout: TimeSpan.Zero)); + + AssertTimeoutContext(error); + Assert.False(invoked); + } + + [Fact] + public async Task WaitForAsync_RetriesFalseConditionAndChecksAppBeforeEachProbe() + { + var probes = 0; + var checks = 0; + await WaitAsync(() => + { + Assert.Equal(probes + 1, checks); + return Task.FromResult(++probes == 2); + }, ensureRunning: () => checks++); + + Assert.Equal(2, probes); + Assert.Equal(2, checks); + } + + [Fact] + public async Task WaitForAsync_AppFailurePropagatesBeforeCondition() + { + var original = new InvalidOperationException("Synthetic app exit."); + var invoked = false; + var error = await Assert.ThrowsAsync(() => + WaitAsync(() => + { + invoked = true; + return Task.FromResult(true); + }, ensureRunning: () => throw original)); + + Assert.Same(original, error); + Assert.False(invoked); + } + + [Fact] + public async Task WaitForAsync_ConditionFailureIsNotConvertedToTimeout() + { + var original = new InvalidDataException("Synthetic invalid MCP response."); + var error = await Assert.ThrowsAsync(() => + WaitAsync(() => Task.FromException(original))); + + Assert.Same(original, error); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WaitForAsync_CancellationIsNotConvertedToTimeout(bool cancelBeforeProbe) + { + using var cancellation = new CancellationTokenSource(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invoked = false; + if (cancelBeforeProbe) + cancellation.Cancel(); + try + { + var error = await Assert.ThrowsAnyAsync(() => + WaitAsync(() => + { + invoked = true; + cancellation.Cancel(); + return pending.Task; + }, cancellationToken: cancellation.Token)); + + Assert.Equal(cancellation.Token, error.CancellationToken); + Assert.Equal(!cancelBeforeProbe, invoked); + } + finally + { + pending.TrySetResult(false); + } + } + + private static Task WaitAsync( + Func> condition, + TimeSpan? timeout = null, + Action? ensureRunning = null, + CancellationToken cancellationToken = default) => + GatewayFixtureRun.WaitForConditionAsync( + condition, Description, ArtifactsDirectory, ensureRunning ?? (() => { }), + timeout ?? Deadline, cancellationToken).WaitAsync(Deadline); + + private static void AssertTimeoutContext(TimeoutException error) => + Assert.Equal($"Timed out waiting for {Description}. Artifacts: {ArtifactsDirectory}", error.Message); +} diff --git a/tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj b/tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj index b694ecae8..4974a864e 100644 --- a/tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj +++ b/tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj @@ -12,6 +12,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/GatewayFixtureHostPolicyTests.cs b/tests/OpenClaw.Tray.Tests/GatewayFixtureHostPolicyTests.cs new file mode 100644 index 000000000..1873615ff --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GatewayFixtureHostPolicyTests.cs @@ -0,0 +1,210 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.TestSupport; +using OpenClawTray; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class GatewayFixtureEnvironmentCollection +{ + public const string Name = "Gateway fixture environment"; +} + +// These tests never construct settings/registry services or call Windows. All effects +// are counters; environment changes are serialized and restored by TestSupport. +[Collection(GatewayFixtureEnvironmentCollection.Name)] +public sealed class GatewayFixtureHostPolicyTests +{ + [Fact] + public void FixtureWslPolicy_RejectsLoopbackAndEveryDistroFallback() + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1"); + var record = new GatewayRecord + { + Id = "fixture", + Url = "ws://127.0.0.1:49152", + IsLocal = true, + SetupManagedDistroName = "SyntheticInstalledDistro", + }; + + Assert.True(GatewayFixtureIsolation.IsEnabled); + Assert.False(WslKeepAlivePolicy.ShouldStart(record, "ws://localhost:18789")); + Assert.False(WslKeepAlivePolicy.ShouldStart(null, "ws://127.0.0.1:49152")); + Assert.Null(WslKeepAlivePolicy.ResolveDistroName(record, "SyntheticSetupDistro", "SyntheticOverride")); + Assert.Null(WslKeepAlivePolicy.ResolveDistroName(null, null, null)); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FixtureAutoStart_ReconciliationRefusesBeforeAnyHostDelegate(bool configured) + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1"); + var reads = 0; + var writes = 0; + + var error = await Assert.ThrowsAsync(() => + AutoStartReconciliation.ReconcileAsync( + configured, + () => { reads++; return Task.FromResult(AutoStartState.Disabled); }, + _ => { writes++; return Task.CompletedTask; })); + + Assert.Contains("Gateway fixture mode", error.Message); + Assert.Equal(0, reads); + Assert.Equal(0, writes); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FixtureAutoStart_FailedToggleReportsDisabledWithoutInstalledStateQuery(bool requested) + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1"); + var failure = Assert.Throws(AutoStartReconciliation.ThrowIfFixtureMutation); + var reads = 0; + + var result = await AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested, + failure, + () => { reads++; return Task.FromResult(AutoStartState.Enabled); }); + + Assert.False(result); + Assert.Equal(0, reads); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Fact] + public async Task FixtureAutoStart_BackgroundPreferenceRefreshSkipsGateAndHostEffects() + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1"); + using var gate = new SemaphoreSlim(0, 1); + var reads = 0; + var writes = 0; + + await AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => { reads++; return true; }, + _ => { writes++; return Task.CompletedTask; }).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, reads); + Assert.Equal(0, writes); + Assert.Equal(0, gate.CurrentCount); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + Assert.Throws(AutoStartReconciliation.ThrowIfFixtureMutation); + } + + [Theory] + [InlineData(null)] + [InlineData("relative-local-data")] + public async Task InvalidFixtureContext_BackgroundRefreshThrowsBeforeGateOrHostEffects(string? root) + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1") + .Set(GatewayFixtureIsolation.LocalDataDirectoryEnvironmentVariable, root); + using var gate = new SemaphoreSlim(0, 1); + var calls = 0; + + await Assert.ThrowsAsync(() => + AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => { calls++; return true; }, + _ => { calls++; return Task.CompletedTask; }).WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.Equal(0, calls); + Assert.Equal(0, gate.CurrentCount); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Fact] + public async Task OrdinaryIsolatedMode_BackgroundRefreshStillReadsLatestPreferenceAfterGate() + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, null); + using var gate = new SemaphoreSlim(0, 1); + var preference = true; + var reads = 0; + var writes = new List(); + + var pending = AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => { reads++; return preference; }, + enabled => { writes.Add(enabled); return Task.CompletedTask; }); + Assert.False(pending.IsCompleted); + Assert.Equal(0, reads); + Assert.Empty(writes); + preference = false; + gate.Release(); + await pending.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(1, reads); + Assert.Equal(new[] { false }, writes); + Assert.Equal(1, gate.CurrentCount); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData(null)] + [InlineData("relative-profile")] + public async Task InvalidFixtureContext_FailsBeforeWslOrAutoStartPolicyEffects(string? root) + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, "1") + .Set(GatewayFixtureIsolation.DataDirectoryEnvironmentVariable, root); + var calls = 0; + + Assert.Throws(() => GatewayFixtureIsolation.IsEnabled); + Assert.Throws(() => WslKeepAlivePolicy.ShouldStart(null, "ws://localhost:18789")); + Assert.Throws(() => WslKeepAlivePolicy.ResolveDistroName(null, null, null)); + Assert.Throws(AutoStartReconciliation.ThrowIfFixtureMutation); + await Assert.ThrowsAsync(() => AutoStartReconciliation.ReconcileAsync( + true, + () => { calls++; return Task.FromResult(AutoStartState.Disabled); }, + _ => { calls++; return Task.CompletedTask; })); + await Assert.ThrowsAsync(() => AutoStartReconciliation.ResolveAfterFailedChangeAsync( + true, + new InvalidOperationException("Synthetic failure"), + () => { calls++; return Task.FromResult(AutoStartState.Enabled); })); + + Assert.Equal(0, calls); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OrdinaryIsolatedMode_PreservesWslFallbackAndAutoStartEffects(bool configured) + { + using var temp = new TempDirectory(); + using var environment = SetEnvironment(temp, null); + var reads = 0; + var writes = 0; + + Assert.False(GatewayFixtureIsolation.IsEnabled); + Assert.True(WslKeepAlivePolicy.ShouldStart(null, "ws://127.0.0.1:49152")); + Assert.Equal(AppIdentity.SetupDistroName, WslKeepAlivePolicy.ResolveDistroName(null, null, null)); + AutoStartReconciliation.ThrowIfFixtureMutation(); + var result = await AutoStartReconciliation.ReconcileAsync( + configured, + () => { reads++; return Task.FromResult(AutoStartState.Disabled); }, + enabled => { Assert.True(enabled); writes++; return Task.CompletedTask; }); + + Assert.Equal(configured, result); + Assert.Equal(1, reads); + Assert.Equal(configured ? 1 : 0, writes); + Assert.Empty(Directory.EnumerateFileSystemEntries(temp.Path)); + } + + private static EnvironmentScope SetEnvironment(TempDirectory temp, string? mode) => new EnvironmentScope() + .Set(GatewayFixtureIsolation.ModeEnvironmentVariable, mode) + .Set(GatewayFixtureIsolation.DataDirectoryEnvironmentVariable, temp.Combine("profile")) + .Set(GatewayFixtureIsolation.LocalDataDirectoryEnvironmentVariable, temp.Combine("setup-local")) + .Set(GatewayFixtureIsolation.LocalAppDataDirectoryEnvironmentVariable, null); +} diff --git a/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs b/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs new file mode 100644 index 000000000..c605f5eaf --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GatewayFixtureIsolationContractTests.cs @@ -0,0 +1,127 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Guard only the WinUI/OS adapter entrypoints not compiled by Tray.Tests. +/// Retirement condition: replace each source guard when that adapter has an +/// injected runtime and can be executed without reaching the real host. +/// +public sealed class GatewayFixtureIsolationContractTests +{ + [Fact] + public void TrayBuild_AdvertisesUnconditionalFixtureIsolationVersion() + { + var project = XDocument.Parse(ReadTraySource("OpenClaw.Tray.WinUI.csproj")); + var marker = Assert.Single(project.Descendants("RuntimeHostConfigurationOption"), + option => (string?)option.Attribute("Include") == "OpenClaw.GatewayFixtureIsolationVersion"); + + Assert.Equal("1", (string?)marker.Attribute("Value")); + Assert.All(marker.AncestorsAndSelf(), element => Assert.Null(element.Attribute("Condition"))); + } + + [Fact] + public void StartupAutostartReconciliationSkipsFixtureHostAccess() + { + var body = BodyAfter(ReadTraySource("App.xaml.cs"), "private async Task ReconcileAutoStartOnStartupAsync()"); + var guard = body.IndexOf("GatewayFixtureIsolation.IsEnabled", StringComparison.Ordinal); + var hostCall = body.IndexOf("AutoStartManager.ReconcileAutoStartAsync", StringComparison.Ordinal); + Assert.True(guard >= 0 && hostCall > guard); + Assert.Contains("return;", body[guard..hostCall]); + } + + [Fact] + public void App_ValidatesFixtureBeforeStartupSideEffects() + { + var body = BodyAfter(ReadTraySource("App.xaml.cs"), "public App()"); + + Assert.StartsWith("_ = GatewayFixtureIsolation.Get();", body); + Assert.True(body.IndexOf("GatewayFixtureIsolation.Get()", StringComparison.Ordinal) < + body.IndexOf("WaitForRestartSourceIfRequested(", StringComparison.Ordinal)); + Assert.True(body.IndexOf("GatewayFixtureIsolation.Get()", StringComparison.Ordinal) < + body.IndexOf("s_runMarker.MarkStarted()", StringComparison.Ordinal)); + } + + [Fact] + public void WslKeepalive_GuardsBothStartAndStaleCleanupBeforeSettingsOrHostAccess() + { + var body = BodyAfter(ReadTraySource("Services", "WslGatewayKeepAliveService.cs"), + "public async Task TryEnsureAsync()"); + + Assert.StartsWith("if (GatewayFixtureIsolation.IsEnabled)", body); + var guardedPrefix = body[..body.IndexOf("try", StringComparison.Ordinal)]; + Assert.Contains("return;", guardedPrefix); + Assert.DoesNotContain("_getSettings()", guardedPrefix); + Assert.DoesNotContain("_getRegistry()", guardedPrefix); + Assert.DoesNotContain("StopStaleLocalGatewayKeepAliveAsync()", guardedPrefix); + Assert.DoesNotContain("Process.", guardedPrefix); + Assert.Contains("await StopStaleLocalGatewayKeepAliveAsync();", body); + } + + [Theory] + [InlineData("public static void SetAutoStart(bool enable)")] + [InlineData("public static Task SetAutoStartAsync(bool enable)")] + [InlineData("public static Task ReconcileAutoStartAsync(bool configured)")] + [InlineData("private static void SetUnpackagedAutoStart(bool enable)")] + [InlineData("private static async Task SetPackagedAutoStartAsync(bool enable)")] + public void AutoStart_MutationEntrypointsRejectFixtureBeforeWindowsAccess(string signature) + { + var body = BodyAfter(ReadTraySource("Services", "AutoStartManager.cs"), signature); + + // In particular this must be outside the unpackaged best-effort catch: + // swallowing the refusal would make an installed-registration no-op look successful. + Assert.StartsWith("ThrowIfFixtureMutation();", body); + } + + [Fact] + public void AutoStart_RefusalIsLoggedAndRethrown_NotConvertedToSuccess() + { + var body = BodyAfter(ReadTraySource("Services", "AutoStartManager.cs"), + "private static void ThrowIfFixtureMutation()"); + body = body[..body.IndexOf("private static void SetUnpackagedAutoStart", StringComparison.Ordinal)]; + + Assert.Contains("AutoStartReconciliation.ThrowIfFixtureMutation();", body); + Assert.Contains("catch (AutoStartRefusedException ex)", body); + Assert.Contains("Logger.Warn(ex.Message);", body); + Assert.Contains("throw;", body); + } + + [Fact] + public void AutoStart_FixtureRollbackAvoidsInstalledRegistrationReads() + { + var body = BodyAfter(ReadTraySource("Services", "AutoStartManager.cs"), + "public static Task ResolveAutoStartAfterFailedChangeAsync(bool requested, Exception failure)"); + + Assert.StartsWith("if (GatewayFixtureIsolation.IsEnabled)", body); + Assert.Contains("return Task.FromResult(false);", + body[..body.IndexOf("PackageHelper.IsPackaged", StringComparison.Ordinal)]); + } + + [Fact] + public void ToastBoundaries_DoNotInitializeInstalledRegistrationInFixtureMode() + { + Assert.Matches( + @"if \(!GatewayFixtureIsolation\.IsEnabled\)\s+ToastNotificationManagerCompat\.OnActivated \+= OnToastActivated;", + ReadTraySource("App.xaml.cs")); + Assert.Matches( + @"if \(!GatewayFixtureIsolation\.IsEnabled\)\s+ToastNotificationManagerCompat\.OnActivated -= OnToastActivated;", + ReadTraySource("App.AppShutdownCoordinator.cs")); + var body = BodyAfter(ReadTraySource("Services", "ToastService.cs"), + "public void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null)"); + Assert.StartsWith("if (GatewayFixtureIsolation.IsEnabled)", body); + Assert.Contains("return;", body[..body.IndexOf("ShouldShowToast(", StringComparison.Ordinal)]); + } + + private static string ReadTraySource(params string[] parts) => + File.ReadAllText(Path.Combine( + [TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", .. parts])); + + private static string BodyAfter(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"Missing guarded adapter: {signature}"); + var body = source[(source.IndexOf('{', start) + 1)..]; + return Regex.Replace(body, @"//[^\r\n]*", string.Empty).TrimStart(); + } +} diff --git a/tests/OpenClaw.Tray.Tests/GatewayFixtureRenderObservationTests.cs b/tests/OpenClaw.Tray.Tests/GatewayFixtureRenderObservationTests.cs new file mode 100644 index 000000000..135855172 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GatewayFixtureRenderObservationTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using OpenClaw.Chat; +using OpenClawTray.Chat; + +namespace OpenClaw.Tray.Tests; + +public sealed class GatewayFixtureRenderObservationTests +{ + [Fact] + public void OrdinaryRenderingExposesNoFixtureObservation() + { + Assert.Empty(GatewayFixtureRenderObservation.Create(Snapshot(), "selected", fixtureEnabled: false)); + } + + [Fact] + public void RenderAcknowledgesLoadedHistoriesNotOnlySelectedThread() + { + using var observation = JsonDocument.Parse(GatewayFixtureRenderObservation.Create(Snapshot(), "b", fixtureEnabled: true)); + Assert.Equal("b", observation.RootElement.GetProperty("selectedThreadId").GetString()); + Assert.Collection(observation.RootElement.GetProperty("loadedThreadIds").EnumerateArray(), + key => Assert.Equal("a", key.GetString()), + key => Assert.Equal("b", key.GetString())); + } + + [Fact] + public void ObservationNeverIncludesMessageContentOrUnloadedThreads() + { + var observation = GatewayFixtureRenderObservation.Create(Snapshot(), "b", fixtureEnabled: true); + Assert.DoesNotContain("unloaded", observation); + Assert.DoesNotContain("private message content", observation); + } + + private static ChatDataSnapshot Snapshot() => new( + [], + new Dictionary + { + ["b"] = ChatTimelineState.Initial() with + { + HistoryLoaded = true, + Entries = [new ChatTimelineItem("entry", ChatTimelineItemKind.User, "private message content")] + }, + ["unloaded"] = ChatTimelineState.Initial(), + ["a"] = ChatTimelineState.Initial() with { HistoryLoaded = true } + }, + "a", "Connected", [], ChatComposeTarget.NotReady); +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 63a59eb10..cea48ead0 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -36,6 +36,7 @@ + diff --git a/tests/OpenClaw.Tray.UITests/GatewayFixtureUiTests.cs b/tests/OpenClaw.Tray.UITests/GatewayFixtureUiTests.cs new file mode 100644 index 000000000..bc2979b45 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/GatewayFixtureUiTests.cs @@ -0,0 +1,350 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Windows.Automation; +using OpenClaw.GatewayFixtureHost; +using OpenClaw.TestSupport.Gateway; +using Xunit.Abstractions; + +namespace OpenClaw.Tray.UITests; + +[CollectionDefinition("Gateway fixture UI", DisableParallelization = true)] +public sealed class GatewayFixtureUiCollection { } + +public sealed class GatewayFixtureUiFactAttribute : FactAttribute +{ + public GatewayFixtureUiFactAttribute() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_RUN_GATEWAY_FIXTURE_UI") != "1") + Skip = "Opt in with scripts\\test-gateway-fixture.ps1. This test launches a real isolated Windows app."; + } +} + +[Collection("Gateway fixture UI")] +public sealed class GatewayFixtureUiTests(ITestOutputHelper output) +{ + [GatewayFixtureUiFact] + [Trait("Category", "GatewayFixture")] + public async Task SessionPickerSwitchesRealHistoriesAndShowsMessage240AtBothWidths() + { + await WithAppAsync(async run => + { + await run.InvokeAsync("app.navigate", new { page = "chat" }); + await WaitUiAsync(run, () => FindById(run, "ChatComposerSessionPicker") is not null, "native session picker"); + for (var iteration = 0; iteration < 3; iteration++) + { + await SelectSessionAsync(run, GatewayScenario.LongSessionTitle, GatewayScenario.LongSessionKey); + // This must pass BEFORE driving the scrollbar: a manual jump could hide a broken initial-tail request. + await WaitUiAsync(run, () => IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker), "natural tail at message 240"); + await SelectSessionAsync(run, GatewayScenario.OtherSessionTitle, GatewayScenario.OtherSessionKey); + await WaitUiAsync(run, () => IsVisibleInTimeline(run, GatewayScenario.OtherHistoryMarker), "other session's visible history"); + Assert.False(IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker)); + } + await SelectSessionAsync(run, GatewayScenario.LongSessionTitle, GatewayScenario.LongSessionKey); + var measurements = new List(); + double narrowWidth = 0; + foreach (var name in new[] { "narrow", "wide" }) + { + var window = FindHub(run); + var transform = (TransformPattern)window.GetCurrentPattern(TransformPattern.Pattern); + Assert.True(transform.Current.CanResize); + transform.Resize(name == "narrow" ? 900 : narrowWidth + 400, name == "narrow" ? 720 : 950); + await ScrollToAsync(run, 0); + await ScrollToAsync(run, 100); + await WaitUiAsync(run, () => IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker), $"message 240 visible at {name} width"); + var bounds = window.Current.BoundingRectangle; + if (name == "narrow") + narrowWidth = bounds.Width; + else + Assert.True(bounds.Width >= narrowWidth + 200, "The host did not provide two meaningfully different window widths."); + measurements.Add(new { name, width = bounds.Width, height = bounds.Height, finalMarker = GatewayScenario.LongHistoryFinalMarker }); + await CaptureIfRequestedAsync(run, $"long-history-{name}.png"); + } + await File.WriteAllTextAsync(Path.Combine(run.ArtifactsDirectory, "layout-proof.json"), JsonSerializer.Serialize(measurements)); + }); + } + + [GatewayFixtureUiFact] + [Trait("Category", "GatewayFixture")] + public async Task PopulatedPagesPreserveSelectionAndPreferencesStayInDisposableProfile() + { + await WithAppAsync(async run => + { + await run.InvokeAsync("app.navigate", new { page = "chat" }); + await SelectSessionAsync(run, GatewayScenario.OtherSessionTitle, GatewayScenario.OtherSessionKey); + foreach (var (page, marker) in new[] + { + ("sessions", "SessionsPageMarker"), + ("settings", "SettingsPageMarker"), + ("config", "ConfigPageMarker") + }) + { + await run.InvokeAsync("app.navigate", new { page }); + await WaitUiAsync(run, () => FindById(run, marker) is not null, page); + if (page == "settings") + { + var toggle = FindById(run, "SettingsPageShowToolCalls"); + Assert.NotNull(toggle); + ((TogglePattern)toggle.GetCurrentPattern(TogglePattern.Pattern)).Toggle(); + await run.WaitForAsync(() => + { + using var settings = JsonDocument.Parse(File.ReadAllText(Path.Combine(run.Profile.DataDirectory, "settings.json"))); + return Task.FromResult(!settings.RootElement.GetProperty("ShowChatToolCalls").GetBoolean()); + }, "profile-only preference persistence"); + } + if (page == "config") + { + await run.WaitForAsync(async () => + { + using var result = await run.Client.CallToolExpectSuccessAsync("app.config.get"); + return !result.RootElement.TryGetProperty("error", out _); + }, "synthetic Gateway configuration"); + await CaptureIfRequestedAsync(run, "gateway-configuration.png"); + } + } + await run.InvokeAsync("app.navigate", new { page = "chat" }); + await WaitUiAsync(run, () => PickerShows(run, GatewayScenario.OtherSessionTitle) + && IsVisibleInTimeline(run, GatewayScenario.OtherHistoryMarker), "selected session after page navigation"); + await run.InvokeAsync("app.navigate", new { page = "sessions" }); + await WaitUiAsync(run, () => FindText(run, GatewayScenario.LongSessionTitle) is not null, "long session row"); + var row = FindText(run, GatewayScenario.LongSessionTitle)!; + for (var depth = 0; depth < 15 && row.Current.ControlType != ControlType.ListItem; depth++) + row = TreeWalker.ControlViewWalker.GetParent(row) + ?? throw new InvalidOperationException("Session title has no list-item ancestor."); + var openChat = row.FindFirst(TreeScope.Descendants, new AndCondition( + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button), + new PropertyCondition(AutomationElement.NameProperty, "Open in chat"))); + Assert.NotNull(openChat); + Invoke(openChat); + await WaitUiAsync(run, () => PickerShows(run, GatewayScenario.LongSessionTitle) + && IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker), "Sessions-page action routes to long chat"); + }); + } + + [GatewayFixtureUiFact] + [Trait("Category", "GatewayFixture")] + public async Task EmptySessionRendersConnectedComposerWithoutPreviousHistory() + { + await WithAppAsync(async run => + { + await run.InvokeAsync("app.navigate", new { page = "chat" }); + await SelectSessionAsync(run, GatewayScenario.OtherSessionTitle, GatewayScenario.OtherSessionKey); + await SelectSessionAsync(run, GatewayScenario.EmptyTitle, GatewayScenario.EmptySessionKey); + await WaitUiAsync(run, () => RenderConsumedHistory(run, GatewayScenario.EmptySessionKey), "rendered empty-session history"); + Assert.True(PickerShows(run, GatewayScenario.EmptyTitle)); + Assert.NotNull(FindById(run, "ChatComposerInput")); + Assert.False(IsVisibleInTimeline(run, GatewayScenario.OtherHistoryMarker)); + var snapshot = await run.InvokeAsync("app.chat.snapshot", new { threadId = GatewayScenario.EmptySessionKey }); + Assert.Empty(snapshot.GetProperty("selectedTimeline").GetProperty("entries").EnumerateArray()); + }); + } + + [GatewayFixtureUiFact] + [Trait("Category", "GatewayFixture")] + public async Task LateHistoryForPreviousSessionCannotReplaceSelectedTranscript() + { + await WithAppAsync(async run => + { + run.Gateway.HoldHistory(GatewayScenario.LongSessionKey); + await run.InvokeAsync("app.navigate", new { page = "chat" }); + await SelectSessionAsync(run, GatewayScenario.LongSessionTitle, GatewayScenario.LongSessionKey, waitForHistory: false); + await run.WaitForAsync(() => Task.FromResult(run.Gateway.Requests.Any(request => + request.Method == "chat.history" && request.SessionKey == GatewayScenario.LongSessionKey)), "held long-history request"); + await SelectSessionAsync(run, GatewayScenario.OtherSessionTitle, GatewayScenario.OtherSessionKey); + await run.Gateway.ReleaseHistoryAsync(GatewayScenario.LongSessionKey); + await WaitHistoryAsync(run, GatewayScenario.LongSessionKey); + await WaitUiAsync(run, () => RenderConsumedHistory(run, GatewayScenario.LongSessionKey), + "native composer rendering the snapshot containing delayed A history"); + Assert.True(PickerShows(run, GatewayScenario.OtherSessionTitle)); + await WaitUiAsync(run, () => IsVisibleInTimeline(run, GatewayScenario.OtherHistoryMarker), "other history after late response"); + Assert.False(IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker)); + await SelectSessionAsync(run, GatewayScenario.LongSessionTitle, GatewayScenario.LongSessionKey); + await WaitUiAsync(run, () => IsVisibleInTimeline(run, GatewayScenario.LongHistoryFinalMarker), "cached delayed history when actually selected"); + }); + } + + private async Task WithAppAsync(Func test) + { + var appPath = Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_FIXTURE_APP") + ?? throw new InvalidOperationException("Set OPENCLAW_GATEWAY_FIXTURE_APP to the freshly built app. No installed-app fallback is allowed."); + await using var run = await GatewayFixtureRun.StartAsync(appPath, + Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_FIXTURE_ARTIFACTS")); + output.WriteLine($"Fixture run {run.Profile.RunId}, PID {run.AppProcessId}, artifacts: {run.ArtifactsDirectory}"); + try + { + // UIA is synchronous and can block inside a hung app. Keep the deadline + // outside that worker so failure reporting and owned-process cleanup still run. + await Task.Run(() => test(run)).WaitAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false); + run.EnsureRunning(); + Assert.Empty(run.Gateway.UnexpectedRequests); + var crashLog = Path.Combine(run.Profile.DataDirectory, "crash.log"); + Assert.False(File.Exists(crashLog) && new FileInfo(crashLog).Length > 0, "The real app wrote a crash log."); + await run.WriteReportAsync("passed"); + } + catch (Exception ex) + { + await run.WriteReportAsync("failed", ex); + if (run.IsRunning) + { + try { await CaptureIfRequestedAsync(run, "failure.png"); } + catch (Exception captureError) + { + output.WriteLine($"Failure screenshot unavailable: {captureError.Message}"); + await File.WriteAllTextAsync(Path.Combine(run.ArtifactsDirectory, "screenshot-error.txt"), captureError.ToString()); + } + } + throw; + } + } + + private static async Task SelectSessionAsync(GatewayFixtureRun run, string title, string key, bool waitForHistory = true) + { + await WaitUiAsync(run, () => FindById(run, "ChatComposerSessionPicker") is not null, "session picker"); + Invoke(FindById(run, "ChatComposerSessionPicker")!); + AutomationElement? item = null; + await WaitUiAsync(run, () => + { + item = FindInApp(run, new AndCondition( + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.MenuItem), + new PropertyCondition(AutomationElement.NameProperty, title))); + return item is not null; + }, $"picker item {title}"); + Invoke(item!); + await WaitUiAsync(run, () => PickerShows(run, title), $"selected session {title}"); + if (waitForHistory) await WaitHistoryAsync(run, key); + } + + private static Task WaitHistoryAsync(GatewayFixtureRun run, string key) => + run.WaitForAsync(async () => + { + var snapshot = await run.InvokeAsync("app.chat.snapshot", new { threadId = key }); + return snapshot.TryGetProperty("selectedTimeline", out var timeline) + && timeline.ValueKind == JsonValueKind.Object + && timeline.GetProperty("historyLoaded").GetBoolean(); + }, $"history loaded for {key}"); + + private static bool PickerShows(GatewayFixtureRun run, string title) => + FindById(run, "ChatComposerSessionPicker")?.Current.Name.Contains(title, StringComparison.Ordinal) == true; + + private static bool RenderConsumedHistory(GatewayFixtureRun run, string key) + { + var picker = FindById(run, "ChatComposerSessionPicker"); + if (picker is null || string.IsNullOrEmpty(picker.Current.ItemStatus)) return false; + using var rendered = JsonDocument.Parse(picker.Current.ItemStatus); + return rendered.RootElement.GetProperty("loadedThreadIds").EnumerateArray() + .Any(thread => thread.GetString() == key); + } + + private static void Invoke(AutomationElement element) + { + if (element.TryGetCurrentPattern(InvokePattern.Pattern, out var invoke)) + ((InvokePattern)invoke).Invoke(); + else if (element.TryGetCurrentPattern(SelectionItemPattern.Pattern, out var selection)) + ((SelectionItemPattern)selection).Select(); + else if (element.TryGetCurrentPattern(TogglePattern.Pattern, out var toggle)) + ((TogglePattern)toggle).Toggle(); + else + throw new InvalidOperationException($"Control '{element.Current.Name}' has no activation pattern. Available: {string.Join(", ", element.GetSupportedPatterns().Select(pattern => pattern.ProgrammaticName))}"); + } + + private static AutomationElement? FindById(GatewayFixtureRun run, string id) => + FindInApp(run, new PropertyCondition(AutomationElement.AutomationIdProperty, id)); + + private static AutomationElement? FindText(GatewayFixtureRun run, string text) + { + foreach (var window in AppWindows(run)) + { + var matches = window.FindAll(TreeScope.Descendants, + new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Text)); + foreach (AutomationElement element in matches) + if (element.Current.Name.Contains(text, StringComparison.Ordinal)) + return element; + } + return null; + } + + private static AutomationElement? FindInApp(GatewayFixtureRun run, Condition condition) + { + foreach (var window in AppWindows(run)) + if (window.FindFirst(TreeScope.Descendants, condition) is { } match) + return match; + return null; + } + + private static IEnumerable AppWindows(GatewayFixtureRun run) + { + run.EnsureRunning(); + return AutomationElement.RootElement.FindAll(TreeScope.Children, + new PropertyCondition(AutomationElement.ProcessIdProperty, run.AppProcessId)).Cast() + .Where(window => !window.Current.IsOffscreen); + } + + private static AutomationElement FindHub(GatewayFixtureRun run) => + AppWindows(run).First(window => window.FindFirst(TreeScope.Descendants, + new PropertyCondition(AutomationElement.AutomationIdProperty, "ChatComposerSessionPicker")) is not null); + + private static AutomationElement? FindTimeline(GatewayFixtureRun run) => + FindInApp(run, new PropertyCondition(AutomationElement.NameProperty, "Chat messages")); + + private static bool IsVisibleInTimeline(GatewayFixtureRun run, string text) + { + var timeline = FindTimeline(run); + var message = FindText(run, text); + if (timeline is null || message is null || message.Current.IsOffscreen) + return false; + var viewport = timeline.Current.BoundingRectangle; + var bounds = message.Current.BoundingRectangle; + return !bounds.IsEmpty && bounds.Width > 0 && bounds.Height > 0 + && bounds.Top >= viewport.Top - 1 && bounds.Bottom <= viewport.Bottom + 1 + && bounds.Right > viewport.Left && bounds.Left < viewport.Right; + } + + private static async Task ScrollToAsync(GatewayFixtureRun run, double percent) + { + var timeline = FindTimeline(run) ?? throw new InvalidOperationException("Chat timeline is not mounted."); + var scrollElement = timeline; + if (!scrollElement.TryGetCurrentPattern(ScrollPattern.Pattern, out var pattern)) + { + scrollElement = timeline.FindFirst(TreeScope.Descendants, + new PropertyCondition(AutomationElement.IsScrollPatternAvailableProperty, true)) + ?? throw new InvalidOperationException("Chat timeline has no accessible scroll control."); + pattern = scrollElement.GetCurrentPattern(ScrollPattern.Pattern); + } + ((ScrollPattern)pattern).SetScrollPercent(ScrollPattern.NoScroll, percent); + await Task.Delay(100); + } + + private static async Task WaitUiAsync(GatewayFixtureRun run, Func condition, string description) + { + await run.WaitForAsync(() => + { + try { return Task.FromResult(condition()); } + catch (ElementNotAvailableException) { return Task.FromResult(false); } + }, description); + } + + private static async Task CaptureIfRequestedAsync(GatewayFixtureRun run, string name) + { + if (Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_FIXTURE_SCREENSHOTS") != "1") + return; + var path = Path.Combine(run.ArtifactsDirectory, name); + var start = new ProcessStartInfo("winapp") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var argument in new[] { "ui", "screenshot", "-a", run.AppProcessId.ToString(), "-o", path }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start winapp screenshot capture."); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); } + catch (TimeoutException) + { + process.Kill(entireProcessTree: true); + throw; + } + Assert.True(process.ExitCode == 0 && File.Exists(path), + $"Screenshot failed: {await stdout} {await stderr}"); + } +} diff --git a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj index ac96585be..185e5e045 100644 --- a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj +++ b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj @@ -54,6 +54,7 @@ +