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 <artifactDir>/signals/
  <name>.signal (names validated by AutomationArtifactName, so no path
  escape).
- 'wait signal <name> [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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-22 17:44:13 +02:00
parent 8b7b601b32
commit c51b07ef95
8 changed files with 189 additions and 10 deletions

View file

@ -561,4 +561,21 @@ internal sealed class WorldLifecycleAutomationController :
_screenshots.TryRequest(name, out error); _screenshots.TryRequest(name, out error);
public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name); 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;
}
} }

View file

@ -99,6 +99,15 @@ public interface IRetailUiAutomationRuntime
void CancelCheckpoint(IRetailUiAutomationCheckpoint checkpoint); void CancelCheckpoint(IRetailUiAutomationCheckpoint checkpoint);
bool TryRequestScreenshot(string name, out string error); bool TryRequestScreenshot(string name, out string error);
bool IsScreenshotComplete(string name); 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;
}
} }
/// <summary> /// <summary>
@ -395,7 +404,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoWait(ScriptCommand command) private bool DoWait(ScriptCommand command)
{ {
var p = command.Parts; 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(); string target = p[1].ToLowerInvariant();
if (target == "item") if (target == "item")
@ -537,7 +546,28 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
$"framebuffer {width}x{height}"); $"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 <name> [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) private bool DoRenderPack(ScriptCommand command)

View file

@ -250,6 +250,8 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath
Assert.Contains("[int]$classes.RemotePlayers -lt 1", source, StringComparison.Ordinal); Assert.Contains("[int]$classes.RemotePlayers -lt 1", source, StringComparison.Ordinal);
Assert.Contains("live: session failed:", source, StringComparison.Ordinal); Assert.Contains("live: session failed:", source, StringComparison.Ordinal);
Assert.Contains("DistinctAccountsConfigured", 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("both clients entered world with the same character identity", source, StringComparison.Ordinal);
Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $primaryState", source, StringComparison.Ordinal); Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $primaryState", source, StringComparison.Ordinal);
Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $observerState", source, StringComparison.Ordinal); Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $observerState", source, StringComparison.Ordinal);
@ -267,9 +269,11 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath
observer, observer,
"command /teleloc 0x09040008", "command /teleloc 0x09040008",
"wait materialized 1 90000", "wait materialized 1 90000",
"checkpoint remote-observer-ready",
"wait signal primary-before 120000",
"input down MovementForward", "input down MovementForward",
"input up MovementForward", "input up MovementForward",
"checkpoint remote-observer"); "checkpoint remote-observer-moved");
string primary = ReadTool("connected-render-pack-remote-primary.route.txt"); string primary = ReadTool("connected-render-pack-remote-primary.route.txt");
AssertAppearsInOrder( AssertAppearsInOrder(
@ -277,6 +281,7 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath
"command /teleloc 0x09040008", "command /teleloc 0x09040008",
"wait materialized 1 90000", "wait materialized 1 90000",
"screenshot remote-player-before 15000", "screenshot remote-player-before 15000",
"wait signal observer-moved 120000",
"screenshot remote-player-after 15000", "screenshot remote-player-after 15000",
"checkpoint remote-primary"); "checkpoint remote-primary");
} }

View file

@ -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] [Fact]
public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership() public void TransitionAutomationDelegatesPreserveExactDisableReenableAndResizeOwnership()
{ {

View file

@ -56,6 +56,7 @@ public sealed class RetailUiAutomationProbeTests
public List<string> Checkpoints { get; } = new(); public List<string> Checkpoints { get; } = new();
public HashSet<string> ScreenshotRequests { get; } = new(); public HashSet<string> ScreenshotRequests { get; } = new();
public HashSet<string> CompletedScreenshots { get; } = new(); public HashSet<string> CompletedScreenshots { get; } = new();
public HashSet<string> PublishedSignals { get; } = new();
public bool TryResetRenderPackPerformance(out string error) public bool TryResetRenderPackPerformance(out string error)
{ {
@ -143,6 +144,16 @@ public sealed class RetailUiAutomationProbeTests
} }
public bool IsScreenshotComplete(string name) => CompletedScreenshots.Contains(name); 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) 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<InputAction>();
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] [Fact]
public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow() public void ScriptRunner_resetsThenWaitsForCompleteRenderPackEvidenceWindow()
{ {

View file

@ -4,11 +4,13 @@
wait world-ready 90000 wait world-ready 90000
wait world-visible 30000 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 wait materialized 1 90000
sleep 6000 sleep 12000
checkpoint remote-observer-ready
wait signal primary-before 120000
input down MovementForward input down MovementForward
sleep 6000 sleep 6000
input up MovementForward input up MovementForward
checkpoint remote-observer checkpoint remote-observer-moved
sleep 30000 sleep 30000

View file

@ -3,11 +3,12 @@
wait world-ready 90000 wait world-ready 90000
wait world-visible 30000 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 wait materialized 1 90000
sleep 4000 sleep 12000
screenshot remote-player-before 15000 screenshot remote-player-before 15000
sleep 6000 wait signal observer-moved 120000
sleep 2000
screenshot remote-player-after 15000 screenshot remote-player-after 15000
checkpoint remote-primary checkpoint remote-primary
sleep 1000 sleep 1000

View file

@ -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 { function Read-StatusEvents {
param([Parameter(Mandatory = $true)][string]$Path) param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
@ -240,7 +255,8 @@ try {
-RedirectStandardOutput $observerStdout ` -RedirectStandardOutput $observerStdout `
-RedirectStandardError $observerStderr -PassThru -RedirectStandardError $observerStderr -PassThru
Wait-ForLogPattern $observer $observerStdout 'live: in world' $LoginTimeoutSeconds 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' $primaryStdout = Join-Path $primaryRoot 'client.stdout.log'
$primaryStderr = Join-Path $primaryRoot 'client.stderr.log' $primaryStderr = Join-Path $primaryRoot 'client.stderr.log'
@ -251,6 +267,12 @@ try {
-RedirectStandardOutput $primaryStdout ` -RedirectStandardOutput $primaryStdout `
-RedirectStandardError $primaryStderr -PassThru -RedirectStandardError $primaryStderr -PassThru
Wait-ForLogPattern $primary $primaryStdout 'live: in world' $LoginTimeoutSeconds 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 ` Wait-ForLogPattern $primary $primaryStdout `
'[world-gate] screenshot-complete name=remote-player-after' 120 '[world-gate] screenshot-complete name=remote-player-after' 120
Wait-ForLogPattern $primary $primaryStdout ` Wait-ForLogPattern $primary $primaryStdout `