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 `