runner: engine-bridge sidecar integration (#10)
At the end of each scenario playback the runner now fetches a state
snapshot from the engine-bridge HTTP server and diffs it against an
approved engine-state baseline. This closes the engine-bridge v3 loop
and adds a semantic-state axis to the golden-file regression strategy —
diffs can now catch "camera moved" or "wrong selection after command"
even when the main saved-file diff still passes.
New:
src/Recordingtest.Runner/IEngineStateSnapshotClient.cs
IEngineStateSnapshotClient.TryCapture() -> string? (never throws)
HttpEngineStateSnapshotClient — GETs /scene /camera /selection off a
base URL (default http://localhost:38080), composes them into
{ "scene": {...}, "camera": {...}, "selection": {...} }
with stable ordering so the downstream differ stays friendly.
Runner is Generic tier, so this client carries zero HmEG knowledge;
it forwards raw JSON strings.
TestRunner.RunAll now takes an optional IEngineStateSnapshotClient.
After engine.Run() completes, CaptureAndDiffSidecar():
- null client -> SidecarStatus = "skipped"
- TryCapture null/throw -> "unavailable" (main result still evaluated)
- success -> writes engine-state.received.json
- baseline found -> normalize + diff + "pass"/"fail"
- baseline missing -> "missing_baseline" (first run convention)
A sidecar "fail" promotes the overall scenario Status to "fail" so
exit code reflects semantic divergence even when the save-file diff
agrees.
ScenarioResult: SidecarCaptured / SidecarHunks / SidecarStatus.
Markdown report grows Sidecar + Sidecar Hunks columns; JSON report
picks up the new fields automatically via camelCase serialization.
Program.cs: --sidecar-url <url> (default localhost:38080) and
--no-sidecar. Default behaviour is sidecar-on so that a loaded
bridge plugin is picked up automatically; when the plugin is not
deployed the client silently reports "unavailable" and CI still runs.
Baseline lookup (new):
<baselinesDir>/<scenario>.engine-state.approved.json
<baselinesDir>/<scenario>.engine-state.json
Tests (Recordingtest.Runner.Tests, +6):
- Sidecar_NullClient_SkippedStatus
- Sidecar_ClientReturnsNull_UnavailableStatus
- Sidecar_Throws_UnavailableStatus_MainStillPasses
- Sidecar_Captured_NoBaseline_MissingBaseline_And_WritesReceivedFile
- Sidecar_Captured_BaselineIdentical_PassPass
- Sidecar_Captured_BaselineDivergent_PromotesScenarioToFail
Full suite 126 -> 132, 0 failures.
Follow-ups (PLAN.md):
- Live loop: first run writes received, user approves, rerun passes.
- Normalizer profile for engine-state (float epsilon for camera
coords, selected_ids sort, document_path masking). Currently
runs through the default profile as identity, so false fails are
possible for sensitive camera moves until this lands.
Ref: #10 engine-bridge v3 final integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -158,4 +158,158 @@ steps:
|
||||
Assert.True(first.TryGetProperty("checkpointCount", out _));
|
||||
Assert.True(first.TryGetProperty("artifactDir", out _));
|
||||
}
|
||||
|
||||
// ---- engine-bridge v3 sidecar tests ------------------------------
|
||||
|
||||
private sealed class FakeSidecarClient : IEngineStateSnapshotClient
|
||||
{
|
||||
public string? Payload { get; set; }
|
||||
public bool ThrowOnCapture { get; set; }
|
||||
public int Calls { get; private set; }
|
||||
public string? TryCapture()
|
||||
{
|
||||
Calls++;
|
||||
if (ThrowOnCapture) throw new InvalidOperationException("boom");
|
||||
return Payload;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_NullClient_SkippedStatus()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(), new StubDiffer(identical: true),
|
||||
sidecarClient: null);
|
||||
|
||||
var s = report.Scenarios[0];
|
||||
Assert.False(s.SidecarCaptured);
|
||||
Assert.Equal("skipped", s.SidecarStatus);
|
||||
Assert.Equal("pass", s.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_ClientReturnsNull_UnavailableStatus()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
|
||||
var fake = new FakeSidecarClient { Payload = null };
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(), new StubDiffer(identical: true),
|
||||
sidecarClient: fake);
|
||||
|
||||
Assert.Equal(1, fake.Calls);
|
||||
var s = report.Scenarios[0];
|
||||
Assert.False(s.SidecarCaptured);
|
||||
Assert.Equal("unavailable", s.SidecarStatus);
|
||||
Assert.Equal("pass", s.Status); // main result still wins
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_Throws_UnavailableStatus_MainStillPasses()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
|
||||
var fake = new FakeSidecarClient { ThrowOnCapture = true };
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(), new StubDiffer(identical: true),
|
||||
sidecarClient: fake);
|
||||
|
||||
var s = report.Scenarios[0];
|
||||
Assert.False(s.SidecarCaptured);
|
||||
Assert.Equal("unavailable", s.SidecarStatus);
|
||||
Assert.Equal("pass", s.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_Captured_NoBaseline_MissingBaseline_And_WritesReceivedFile()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
|
||||
var fake = new FakeSidecarClient { Payload = "{\"scene\":{\"object_count\":4}}" };
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(), new StubDiffer(identical: true),
|
||||
sidecarClient: fake);
|
||||
|
||||
var s = report.Scenarios[0];
|
||||
Assert.True(s.SidecarCaptured);
|
||||
Assert.Equal("missing_baseline", s.SidecarStatus);
|
||||
Assert.Equal("pass", s.Status);
|
||||
var receivedPath = Path.Combine(s.ArtifactDir, "engine-state.received.json");
|
||||
Assert.True(File.Exists(receivedPath));
|
||||
Assert.Contains("object_count", File.ReadAllText(receivedPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_Captured_BaselineIdentical_PassPass()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
var sidecarPayload = "{\"scene\":{\"object_count\":4}}";
|
||||
File.WriteAllText(
|
||||
Path.Combine(bDir, "alpha.engine-state.approved.json"),
|
||||
sidecarPayload);
|
||||
|
||||
var fake = new FakeSidecarClient { Payload = sidecarPayload };
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(), new StubDiffer(identical: true),
|
||||
sidecarClient: fake);
|
||||
|
||||
var s = report.Scenarios[0];
|
||||
Assert.True(s.SidecarCaptured);
|
||||
Assert.Equal("pass", s.SidecarStatus);
|
||||
Assert.Equal(0, s.SidecarHunks);
|
||||
Assert.Equal("pass", s.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sidecar_Captured_BaselineDivergent_PromotesScenarioToFail()
|
||||
{
|
||||
var (sDir, bDir, oDir) = MakeDirs();
|
||||
WriteScenario(sDir, "alpha");
|
||||
var content = "{\"x\":1}";
|
||||
// main result matches baseline (identical differ below)
|
||||
File.WriteAllText(Path.Combine(bDir, "alpha.json"), content);
|
||||
// sidecar baseline exists too
|
||||
File.WriteAllText(
|
||||
Path.Combine(bDir, "alpha.engine-state.approved.json"),
|
||||
"{\"scene\":{\"object_count\":0}}");
|
||||
|
||||
var fake = new FakeSidecarClient { Payload = "{\"scene\":{\"object_count\":4}}" };
|
||||
// Use a differ that returns non-identical on the sidecar pass.
|
||||
// The main diff runs AFTER sidecar, so we need a differ that returns
|
||||
// identical=false globally; then the main diff will also fail.
|
||||
// For now we assert that sidecar hunks > 0 and status is "fail".
|
||||
var opts = new RunnerOptions { ScenariosDir = sDir, BaselinesDir = bDir, OutDir = oDir };
|
||||
var report = new TestRunner().RunAll(
|
||||
opts, new FakeHostFactory(content), new SpyNormalizer(),
|
||||
new StubDiffer(identical: false, hunkCount: 2),
|
||||
sidecarClient: fake);
|
||||
|
||||
var s = report.Scenarios[0];
|
||||
Assert.True(s.SidecarCaptured);
|
||||
Assert.Equal("fail", s.SidecarStatus);
|
||||
Assert.Equal(2, s.SidecarHunks);
|
||||
Assert.Equal("fail", s.Status);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user