mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
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:
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()]);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user