From c51b07ef95c2ac7be29b22e7cb82a9dafdcf208f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 22 Aug 2026 17:44:13 +0200 Subject: [PATCH] test(gates): sequence the two-client remote-shadow gate with harness signals Imported from the codex worktree's uncommitted work (C:/Users/erikn/.codex/worktrees/bd98/acdream on codex/atmospheric-rendering-campaign), on top of its 8b7b601b. The remote-player shadow gate ran two clients on fixed sleeps, so the observer could move before the primary had taken its 'before' screenshot, or the primary could take its 'after' shot before the observer had moved. Now the harness publishes named signal files into each client's artifact directory and the routes block on them: - IRetailUiAutomationRuntime.TryIsAutomationSignalPublished, implemented by WorldLifecycleAutomationController over /signals/ .signal (names validated by AutomationArtifactName, so no path escape). - 'wait signal [timeoutMs]' in RetailUiAutomationScriptRunner. - run-connected-render-pack-remote-player-gate.ps1 publishes 'primary-before' to the observer after the primary's before-shot completes, then 'observer-moved' to the primary after the observer's remote-observer-moved checkpoint. - Both routes teleport with an explicit heading and wait 12 s to settle. Build green; the three touched App test classes pass 58/58 including the two new signal tests. Co-Authored-By: Claude Opus 5 --- .../WorldLifecycleAutomationController.cs | 17 ++++++ .../Testing/RetailUiAutomationScriptRunner.cs | 34 ++++++++++- .../ConnectedRenderPackGateContractTests.cs | 7 ++- ...WorldLifecycleAutomationControllerTests.cs | 42 +++++++++++++ .../UI/RetailUiAutomationProbeTests.cs | 60 +++++++++++++++++++ ...cted-render-pack-remote-observer.route.txt | 8 ++- ...ected-render-pack-remote-primary.route.txt | 7 ++- ...nnected-render-pack-remote-player-gate.ps1 | 24 +++++++- 8 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 3122834f..2034fbb4 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -561,4 +561,21 @@ internal sealed class WorldLifecycleAutomationController : _screenshots.TryRequest(name, out error); public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name); + + public bool TryIsAutomationSignalPublished( + string name, + out bool published, + out string error) + { + published = false; + if (!AutomationArtifactName.TryValidate(name, out error)) + return false; + + published = File.Exists(Path.Combine( + _artifactDirectory, + "signals", + name + ".signal")); + error = string.Empty; + return true; + } } diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs index 1f82b6f7..dc9012fa 100644 --- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs +++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs @@ -99,6 +99,15 @@ public interface IRetailUiAutomationRuntime void CancelCheckpoint(IRetailUiAutomationCheckpoint checkpoint); bool TryRequestScreenshot(string name, out string error); bool IsScreenshotComplete(string name); + bool TryIsAutomationSignalPublished( + string name, + out bool published, + out string error) + { + published = false; + error = "automation signals require ACDREAM_AUTOMATION_ARTIFACT_DIR"; + return false; + } } /// @@ -395,7 +404,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable private bool DoWait(ScriptCommand command) { var p = command.Parts; - if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ..."); + if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer|signal ..."); string target = p[1].ToLowerInvariant(); if (target == "item") @@ -537,7 +546,28 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable $"framebuffer {width}x{height}"); } - return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ..."); + if (target == "signal") + { + if (_runtime is null) + return Stop(command, "automation signal runtime is unavailable"); + if (p.Length < 3) + return Stop(command, "usage: wait signal [timeoutMs]"); + if (!_runtime.TryIsAutomationSignalPublished( + p[2], + out bool published, + out string error)) + { + return Stop(command, error); + } + if (published) + return true; + return WaitOrTimeout( + command, + TimeoutMs(p, 3, 120000), + $"automation signal '{p[2]}'"); + } + + return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer|signal ..."); } private bool DoRenderPack(ScriptCommand command) diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs index 4ce98595..67b0d909 100644 --- a/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs @@ -250,6 +250,8 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath Assert.Contains("[int]$classes.RemotePlayers -lt 1", source, StringComparison.Ordinal); Assert.Contains("live: session failed:", source, StringComparison.Ordinal); Assert.Contains("DistinctAccountsConfigured", source, StringComparison.Ordinal); + Assert.Contains("Publish-ClientSignal $observerRoot 'primary-before'", source, StringComparison.Ordinal); + Assert.Contains("Publish-ClientSignal $primaryRoot 'observer-moved'", source, StringComparison.Ordinal); Assert.Contains("both clients entered world with the same character identity", source, StringComparison.Ordinal); Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $primaryState", source, StringComparison.Ordinal); Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $observerState", source, StringComparison.Ordinal); @@ -267,9 +269,11 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath observer, "command /teleloc 0x09040008", "wait materialized 1 90000", + "checkpoint remote-observer-ready", + "wait signal primary-before 120000", "input down MovementForward", "input up MovementForward", - "checkpoint remote-observer"); + "checkpoint remote-observer-moved"); string primary = ReadTool("connected-render-pack-remote-primary.route.txt"); AssertAppearsInOrder( @@ -277,6 +281,7 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath "command /teleloc 0x09040008", "wait materialized 1 90000", "screenshot remote-player-before 15000", + "wait signal observer-moved 120000", "screenshot remote-player-after 15000", "checkpoint remote-primary"); } diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index b0046dd8..5494d766 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -703,6 +703,48 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void AutomationSignalReadsOnlyValidatedNamesFromOwnedArtifactDirectory() + { + string directory = NewDirectory(); + var controller = new WorldLifecycleAutomationController( + () => default, + () => default, + () => default, + () => 0, + _ => EmptyResources(), + new FrameScreenshotController((_, _) => [], directory), + directory); + + try + { + Assert.True(controller.TryIsAutomationSignalPublished( + "observer-moved", out bool missing, out string missingError), + missingError); + Assert.False(missing); + + string signalDirectory = Path.Combine(directory, "signals"); + Directory.CreateDirectory(signalDirectory); + File.WriteAllText( + Path.Combine(signalDirectory, "observer-moved.signal"), + "published"); + + Assert.True(controller.TryIsAutomationSignalPublished( + "observer-moved", out bool published, out string publishedError), + publishedError); + Assert.True(published); + Assert.False(controller.TryIsAutomationSignalPublished( + "../outside", out bool invalid, out string invalidError)); + Assert.False(invalid); + Assert.Contains("may contain only", invalidError); + } + finally + { + controller.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership() { diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index 1bb817db..68de918a 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -56,6 +56,7 @@ public sealed class RetailUiAutomationProbeTests public List Checkpoints { get; } = new(); public HashSet ScreenshotRequests { get; } = new(); public HashSet CompletedScreenshots { get; } = new(); + public HashSet PublishedSignals { get; } = new(); public bool TryResetRenderPackPerformance(out string error) { @@ -143,6 +144,16 @@ public sealed class RetailUiAutomationProbeTests } public bool IsScreenshotComplete(string name) => CompletedScreenshots.Contains(name); + + public bool TryIsAutomationSignalPublished( + string name, + out bool published, + out string error) + { + published = PublishedSignals.Contains(name); + error = string.Empty; + return true; + } } private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects) @@ -709,6 +720,55 @@ public sealed class RetailUiAutomationProbeTests } } + [Fact] + public void ScriptRunner_signalWaitBlocksUntilHarnessPublishesExactName() + { + var (root, _, _, _, objects) = RootWithTwoItemLists(); + var runtime = new FakeRuntime(); + var probe = new RetailUiAutomationProbe(root, objects); + string path = Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ".ui-probe.txt"); + File.WriteAllLines(path, + [ + "wait signal observer-moved 1000", + "input press CombatToggleCombat", + ]); + var presses = new List(); + + try + { + using var runner = new RetailUiAutomationScriptRunner( + probe, + path, + dumpOnStart: false, + pressInput: action => + { + presses.Add(action); + return true; + }, + runtime: runtime); + + runner.Tick(0d); + Assert.False(runner.Completed); + Assert.Empty(presses); + + runtime.PublishedSignals.Add("another-signal"); + runner.Tick(0.5d); + Assert.False(runner.Completed); + Assert.Empty(presses); + + runtime.PublishedSignals.Add("observer-moved"); + runner.Tick(0.001d); + Assert.True(runner.Completed); + Assert.Equal([InputAction.CombatToggleCombat], presses); + } + finally + { + File.Delete(path); + } + } + [Fact] public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow() { diff --git a/tools/connected-render-pack-remote-observer.route.txt b/tools/connected-render-pack-remote-observer.route.txt index 087883d0..e117ea51 100644 --- a/tools/connected-render-pack-remote-observer.route.txt +++ b/tools/connected-render-pack-remote-observer.route.txt @@ -4,11 +4,13 @@ wait world-ready 90000 wait world-visible 30000 -command /teleloc 0x09040008 14.4 188.6 87.705 +command /teleloc 0x09040008 17.4 190.6 87.705 1 0 0 0 wait materialized 1 90000 -sleep 6000 +sleep 12000 +checkpoint remote-observer-ready +wait signal primary-before 120000 input down MovementForward sleep 6000 input up MovementForward -checkpoint remote-observer +checkpoint remote-observer-moved sleep 30000 diff --git a/tools/connected-render-pack-remote-primary.route.txt b/tools/connected-render-pack-remote-primary.route.txt index f3f55e12..82fdbb9c 100644 --- a/tools/connected-render-pack-remote-primary.route.txt +++ b/tools/connected-render-pack-remote-primary.route.txt @@ -3,11 +3,12 @@ wait world-ready 90000 wait world-visible 30000 -command /teleloc 0x09040008 11.4 188.6 87.705 +command /teleloc 0x09040008 11.4 188.6 87.705 1 0 0 0 wait materialized 1 90000 -sleep 4000 +sleep 12000 screenshot remote-player-before 15000 -sleep 6000 +wait signal observer-moved 120000 +sleep 2000 screenshot remote-player-after 15000 checkpoint remote-primary sleep 1000 diff --git a/tools/run-connected-render-pack-remote-player-gate.ps1 b/tools/run-connected-render-pack-remote-player-gate.ps1 index d621c72c..bf9af954 100644 --- a/tools/run-connected-render-pack-remote-player-gate.ps1 +++ b/tools/run-connected-render-pack-remote-player-gate.ps1 @@ -121,6 +121,21 @@ function Stop-ExactClient { } } +function Publish-ClientSignal { + param( + [Parameter(Mandatory = $true)][string]$ClientRoot, + [Parameter(Mandatory = $true)][string]$Name + ) + if ($Name -notmatch '^[A-Za-z0-9_-]{1,80}$') { + throw "invalid automation signal name '$Name'" + } + $signalDirectory = Join-Path $ClientRoot 'signals' + $null = New-Item -ItemType Directory -Force -Path $signalDirectory + Assert-ConnectedGateNoReparsePoint $signalDirectory + Set-Content -Encoding ascii -LiteralPath ` + (Join-Path $signalDirectory "$Name.signal") -Value 'published' +} + function Read-StatusEvents { param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { @@ -240,7 +255,8 @@ try { -RedirectStandardOutput $observerStdout ` -RedirectStandardError $observerStderr -PassThru Wait-ForLogPattern $observer $observerStdout 'live: in world' $LoginTimeoutSeconds - Wait-ForLogPattern $observer $observerStdout 'live: teleport materialized' 90 + Wait-ForLogPattern $observer $observerStdout ` + '[world-gate] checkpoint name=remote-observer-ready' 120 $primaryStdout = Join-Path $primaryRoot 'client.stdout.log' $primaryStderr = Join-Path $primaryRoot 'client.stderr.log' @@ -251,6 +267,12 @@ try { -RedirectStandardOutput $primaryStdout ` -RedirectStandardError $primaryStderr -PassThru Wait-ForLogPattern $primary $primaryStdout 'live: in world' $LoginTimeoutSeconds + Wait-ForLogPattern $primary $primaryStdout ` + '[world-gate] screenshot-complete name=remote-player-before' 120 + Publish-ClientSignal $observerRoot 'primary-before' + Wait-ForLogPattern $observer $observerStdout ` + '[world-gate] checkpoint name=remote-observer-moved' 60 + Publish-ClientSignal $primaryRoot 'observer-moved' Wait-ForLogPattern $primary $primaryStdout ` '[world-gate] screenshot-complete name=remote-player-after' 120 Wait-ForLogPattern $primary $primaryStdout `