Files
reverse-engineering-anything/tests/acceptance/webSessionCliProgress.test.ts
T
wunianze666-netizenandgensokyo-dev 3e95d22dd4 fix(cli): report observe-web-session progress on stderr
MCP passes progress to observeWebSession, but the CLI did not, so a
caller of `rea observe-web-session` never saw the armed observation
window it must wait for before acting. Three CLI commands also each
wrote their own copy of the same stderr progress line.

One cliProgress module now owns the stderr progress line and reporter.
observe-web-session uses it, as do web runtime, JavaScript application
and desktop capture progress. The CDP session also stops reporting
readiness once collection is no longer active, so a stopped capture does
not invite the external action.

Fixes #8141
Supersedes #8149

Based on #8149; the desktop capture copy now shares the line writer too.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-11-14 14:16:39 +01:00

243 lines
7.6 KiB
TypeScript

import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
import { z } from "zod";
import { parseEvidence } from "../../src/domain/evidence.js";
import { webObservationSessionSchema } from "../../src/domain/browserSession.js";
import {
startFakeCdpBrowser,
type FakeCdpBrowser,
} from "../fixtures/fakeCdpBrowser.js";
import {
processTest,
type TestProcesses,
} from "../support/process/processFixture.js";
const entrypoint = fileURLToPath(
new URL("../../scripts/rea.mjs", import.meta.url),
);
const progressSchema = z.object({
rea_progress: z.object({
phase: z.literal("browser_observation"),
completed: z.union([z.literal(1), z.literal(2)]),
total: z.literal(2),
message: z.string(),
terminal: z.boolean().optional(),
}),
});
type Progress = z.infer<typeof progressSchema>["rea_progress"];
const observe = async (
processes: TestProcesses,
browser: FakeCdpBrowser,
onProgress: (update: Progress) => void = () => {},
) => {
const child = processes.spawn(
process.execPath,
[
entrypoint,
"observe-web-session",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"1000",
"--json",
],
{ env: { ...process.env, REA_LOG_LEVEL: "silent" } },
);
child.stdin.end();
let stdout = "";
let stderr = "";
let pending = "";
const updates: Progress[] = [];
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
const lines = (pending + chunk).split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
continue;
}
const update = progressSchema.safeParse(value);
if (!update.success) continue;
updates.push(update.data.rea_progress);
onProgress(update.data.rea_progress);
}
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, 20_000);
try {
const [code, signal] = await once(child, "close");
expect(timedOut, stderr).toBe(false);
expect(signal, stderr).toBeNull();
expect(stdout).not.toContain("rea_progress");
return { code, stdout, stderr, updates };
} finally {
clearTimeout(timer);
}
};
processTest(
"arms the CLI caller before accepting its action and keeps final Evidence on stdout",
async ({ processes }) => {
const browser = await startFakeCdpBrowser({ commandEvents: () => [] });
onTestFinished(() => browser.close());
const url = `${browser.allowedOrigin}/action-after-ready`;
let actions = 0;
const result = await observe(processes, browser, (update) => {
if (update.completed !== 1) return;
actions += 1;
const sessionId = browser.commands.find(
({ method }) => method === "Page.enable",
)?.sessionId;
browser.emitEvent({
method: "Page.navigatedWithinDocument",
...(sessionId === undefined ? {} : { sessionId }),
params: { frameId: "frame-main", url, navigationType: "historyApi" },
});
});
expect(result.code, result.stderr).toBe(0);
expect(actions).toBe(1);
expect(
result.updates.map(({ message: _message, ...facts }) => facts),
).toEqual([
{
phase: "browser_observation",
completed: 1,
total: 2,
},
{
phase: "browser_observation",
completed: 2,
total: 2,
terminal: true,
},
]);
const capture = webObservationSessionSchema.parse(
parseEvidence(JSON.parse(result.stdout)).normalized_result,
);
expect(capture.window.end_reason).toBe("window_elapsed");
expect(capture.timeline).toContainEqual(
expect.objectContaining({ type: "same_document_navigation", url }),
);
expect(browser.commands).toContainEqual(
expect.objectContaining({ method: "Target.detachFromTarget" }),
);
},
);
processTest(
"does not announce arming or completion when CDP preparation fails",
async ({ processes }) => {
const browser = await startFakeCdpBrowser({
commandError: ({ method }) =>
method === "Performance.enable"
? { code: -32000, message: "authored preparation failure" }
: undefined,
});
onTestFinished(() => browser.close());
const result = await observe(processes, browser);
expect(result.code).not.toBe(0);
expect(
result.updates.map(({ message: _message, ...facts }) => facts),
).toEqual([]);
expect(JSON.parse(result.stdout)).toMatchObject({
code: expect.any(String),
category: expect.any(String),
});
expect(browser.commands).toContainEqual(
expect.objectContaining({ method: "Target.detachFromTarget" }),
);
},
);
processTest(
"keeps a failure after arming distinct from a successfully ended window",
async ({ processes }) => {
const browser = await startFakeCdpBrowser({ commandEvents: () => [] });
onTestFinished(() => browser.close());
const result = await observe(processes, browser, (update) => {
if (update.completed === 1) browser.emitRawMessage("{not-json");
});
expect(result.code).not.toBe(0);
expect(result.updates.map(({ completed }) => completed)).toEqual([1]);
expect(result.updates.every(({ terminal }) => terminal !== true)).toBe(
true,
);
expect(JSON.parse(result.stdout)).toMatchObject({
code: expect.any(String),
category: expect.any(String),
});
},
);
const stoppedPreparations = [
{ event: "outside-origin", endReason: "target_left_scope" },
{ event: "session-detached", endReason: "session_detached" },
{ event: "target-crashed", endReason: "target_terminated" },
] as const;
processTest.for(stoppedPreparations)(
"does not arm when preparation already stopped collection: $event",
async ({ event, endReason }, { processes }) => {
const browser = await startFakeCdpBrowser({
eventsBeforeReply: ["Page.setLifecycleEventsEnabled"],
commandEvents: (command) => {
if (command.method !== "Page.setLifecycleEventsEnabled") return [];
const sessionId = z.string().parse(command.sessionId);
if (event === "outside-origin")
return [
{
method: "Page.navigatedWithinDocument",
sessionId,
params: {
frameId: "frame-main",
url: "https://outside.example/withheld",
navigationType: "historyApi",
},
},
];
if (event === "session-detached")
return [
{
method: "Target.detachedFromTarget",
params: { sessionId, targetId: "allowed-page" },
},
];
return [{ method: "Inspector.targetCrashed", sessionId, params: {} }];
},
});
onTestFinished(() => browser.close());
let actions = 0;
const result = await observe(processes, browser, ({ completed }) => {
if (completed === 1) actions += 1;
});
expect(result.code, result.stderr).toBe(0);
expect(actions).toBe(0);
expect(
result.updates.map(({ message: _message, ...facts }) => facts),
).toEqual([
{ phase: "browser_observation", completed: 2, total: 2, terminal: true },
]);
const capture = webObservationSessionSchema.parse(
parseEvidence(JSON.parse(result.stdout)).normalized_result,
);
expect(capture.window.end_reason).toBe(endReason);
expect(result.stdout).not.toContain("https://outside.example/withheld");
},
);