feat(browser): retain compact scenario summaries and selected views (#7120)

Retain complete browser scenario Evidence while delivering compact summaries and selected step, event, transaction and text views through shared CLI/MCP workflows. Preserve initial CLI failures across cleanup retries and validate with real Chromium/TodoMVC.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 19:03:06 +08:00
1 parent bc143125ed
commit fc43e285ea
19 files changed
+2297 -90

No files matched your search

@@ -0,0 +1,146 @@
import { access } from "node:fs/promises";
import { join } from "node:path";
import { Cli } from "incur";
import pino from "pino";
import { expect, it } from "vitest";
import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js";
import { ok } from "../../../src/domain/result.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { scenarioCaptureFixture } from "../../fixtures/browserScenarioCapture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each(["complete", "summary"] as const)(
"retains complete Evidence on cancellation during CLI cleanup with %s delivery",
async (detail) => {
const root = await createTestTempDirectory("rea-browser-summary-cli-");
const destination = join(root, "evidence.json");
const originalExit = process.exitCode;
const listeners = new Set(process.listeners("SIGINT"));
const cli = Cli.create("rea-browser-summary-test", { sync: false });
let stdout = "";
let closed = false;
registerBrowserScenarioCommands(
cli,
pino({ level: "silent" }),
{},
{
createProvider: () => ({
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
captureScenario: async () => ok(scenarioCaptureFixture()),
close: async () => {
closed = true;
// Invoke only this command's newly installed listener, without emitting
// a process-wide signal into the shared test worker.
const installed = process
.listeners("SIGINT")
.filter((listener) => !listeners.has(listener));
expect(installed).toHaveLength(1);
installed[0]!("SIGINT");
},
}),
},
);
try {
await cli.serve(
[
"capture-browser-scenario",
JSON.stringify({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "http://127.0.0.1:3101/" },
actions: [
{ step_id: "browse", action: "wait_for_timeout", duration_ms: 1 },
],
}),
"--detail",
detail,
"--evidence_output",
destination,
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
},
);
expect(JSON.parse(stdout)).toMatchObject({
code: "cancelled",
details: {
partial_observation: {
operation: "capture_browser_scenario",
normalized_result: { steps: expect.any(Array) },
},
},
});
expect(closed).toBe(true);
await expect(access(destination)).rejects.toMatchObject({
code: "ENOENT",
});
expect(new Set(process.listeners("SIGINT"))).toEqual(listeners);
} finally {
process.exitCode = originalExit;
}
},
);
it("keeps the provider's cleanup failure and completed Evidence without publishing a summary bundle", async () => {
const root = await createTestTempDirectory("rea-browser-summary-cleanup-");
const destination = join(root, "evidence.json");
const cli = Cli.create("rea-browser-cleanup-test", { sync: false });
let stdout = "";
const failure = new ProviderCleanupError(
"owned-browser",
["owned-profile"],
{ reason: "cleanup denied" },
{ operation: "capture_browser_scenario" },
);
registerBrowserScenarioCommands(
cli,
pino({ level: "silent" }),
{},
{
createProvider: () => ({
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
captureScenario: async () => ok(scenarioCaptureFixture()),
close: async () => {
throw failure;
},
}),
},
);
await cli.serve(
[
"capture-browser-scenario",
JSON.stringify({
browser: { mode: "launch", executable_path: process.execPath },
start_url: { url: "http://127.0.0.1:3101/" },
actions: [
{ step_id: "browse", action: "wait_for_timeout", duration_ms: 1 },
],
}),
"--detail",
"summary",
"--evidence_output",
destination,
"--json",
],
{
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => undefined,
},
);
expect(JSON.parse(stdout)).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: ["owned-profile"],
partial_observation: { operation: "capture_browser_scenario" },
},
});
await expect(access(destination)).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -38,6 +38,38 @@ describe("scenario CLI schema diagnostics", () => {
});
});
cliTest(
"refuses summary delivery without a complete Evidence bundle before launching",
async ({ cli }) => {
const scenario = {
browser: { mode: "launch", executable_path: "/nonexistent/chromium" },
start_url: { url: "https://app.example.test/", query: [] },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
};
const result = await cli.run({
arguments: [
"capture-browser-scenario",
JSON.stringify(scenario),
"--detail",
"summary",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "capture_browser_scenario",
issues: [{ path: ["evidence_output"], reason: "invalid_value" }],
},
});
},
);
cliTest("reports Electron scenario schema paths", async ({ cli }) => {
const result = await cli.run({
arguments: ["capture-electron-scenario", "{}", "--json"],
@@ -367,3 +367,111 @@ test("records a completed late-close cancellation as server Evidence", async ()
await Promise.all([client.close(), server.close(), session.close()]);
}
});
test("delivers a retained summary and pages its exact steps and events", async () => {
const provider = new FakeBrowserScenarioProvider();
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(ok(null)),
}));
const server = createServer(
{ kind: "session", session },
{
browserScenarioCapture: provider,
availabilityPolicy: () => ({
processCaptureEnabled: false,
investigationInputRoots: 0,
}),
},
);
const client = new Client({ name: "scenario-summary", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const tools = (await client.listTools()).tools;
const advertised = tools.find(
({ name }) => name === "capture_browser_scenario",
);
if (advertised?.outputSchema === undefined)
throw new Error("Missing advertised scenario output schema");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validateInput = ajv.compile(advertised.inputSchema);
const validateOutput = ajv.compile(
z.record(z.string(), z.unknown()).parse(advertised.outputSchema),
);
const request = { ...minimalScenario(), detail: "summary" };
expect(validateInput(request), JSON.stringify(validateInput.errors)).toBe(
true,
);
const response = await client.callTool({
name: "capture_browser_scenario",
arguments: request,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
validateOutput(response.structuredContent),
JSON.stringify(validateOutput.errors),
).toBe(true);
const summary = z
.object({
normalized_result: z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
steps: z.array(z.object({ step_id: z.string() })),
}),
}),
})
.parse(response.structuredContent).normalized_result;
expect(summary.summary.steps.map(({ step_id }) => step_id)).toEqual([
"scenario_start",
"settle",
]);
// Delivery detail is neither sent to the provider nor recorded as a scenario parameter.
expect(provider.scenarios[0]).not.toHaveProperty("detail");
const parent = session.evidenceById(summary.parent_evidence_id);
expect(parent?.operation).toBe("capture_browser_scenario");
expect(parent?.parameters).not.toHaveProperty("detail");
const page = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: {
kind: "browser-scenario",
collection: "steps",
step_indices: [1],
},
},
});
expect(page.isError, JSON.stringify(page)).not.toBe(true);
expect(page.structuredContent).toMatchObject({
normalized_result: {
kind: "browser-scenario",
parent_evidence_id: summary.parent_evidence_id,
items: {
collection: "steps",
rows: [{ step_index: 1, step_id: "settle", artifacts: {} }],
},
coverage: { total: 1, exhausted: true },
},
});
const invalid = await client.callTool({
name: "capture_browser_scenario",
arguments: { ...minimalScenario(), detail: "brief" },
});
expect(parseMcpToolError(invalid)).toMatchObject({
error: { code: "invalid_request" },
});
expect(provider.scenarios).toHaveLength(1);
} finally {
await Promise.all([client.close(), server.close(), session.close()]);
}
});