Files
rea/scripts/lib/browser-scenario-environment-e2e.mjs
T
c7697595d8 fix(browser): report attached locale coverage limits
Fixes #3395.

Disclose attached locale coverage in complete and retained partial captures,
clarify the shared locale contract, and verify actual CLI/MCP caller-language
preservation and browser cleanup. Launch supplies full page/request locale.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-15 19:37:19 +08:00

439 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { CdpCommandRejection } from "../../dist/browser/CdpCommandRejection.js";
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { AnalysisCancelledError } from "../../dist/domain/analysisErrorCore.js";
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
import { closeVerifierResources } from "./verifier-cleanup.mjs";
const expected = {
timezone: "Pacific/Honolulu",
locale: "fr-FR",
dpr: 2,
width: 900,
height: 600,
};
const alternate = {
timezone: "UTC",
locale: "en-US",
dpr: 1,
width: 700,
height: 500,
};
const environmentExpression = `{
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: Intl.DateTimeFormat().resolvedOptions().locale,
dpr: devicePixelRatio, width: innerWidth, height: innerHeight
}`;
const expression = `JSON.stringify(${environmentExpression})`;
const scenarioSnapshotExpression = `JSON.stringify({
timeOrigin: performance.timeOrigin,
environment: ${environmentExpression},
run: document.body?.dataset.run ?? null
})`;
const isNavigationTransitionRejection = (cause) =>
cause instanceof CdpCommandRejection &&
cause.command === "Runtime.evaluate" &&
cause.code === -32_000 &&
cause.reportedMessage === "Inspected target navigated or closed";
const environmentScenario = ({ endpoint, targetId, origin, values, run }) =>
browserScenarioSchema.parse({
browser: { mode: "connect", cdp_endpoint: endpoint, target_id: targetId },
start_url: {
url: `${origin}/scenario-environment${run === undefined ? "" : `?run=${run}`}`,
},
environment: {
locale: values.locale,
timezone: values.timezone,
viewport: {
width: values.width,
height: values.height,
device_scale_factor: values.dpr,
},
},
actions:
run === undefined
? [
{
step_id: "refresh",
action: "click",
locator: { kind: "css", selector: "#refresh-environment" },
},
]
: [
{
step_id: `gate_${run}`,
action: "wait_for",
locator: { kind: "css", selector: `#release-${run}` },
state: "attached",
timeout_ms: 30_000,
},
{
step_id: `refresh_${run}`,
action: "click",
locator: { kind: "css", selector: "#refresh-environment" },
},
],
capture: { after_each_step: ["dom"], at_end: ["dom"] },
});
const assertCapture = (capture, values = expected) => {
assert.equal(capture.browser.cleanup, "disconnected-external");
for (const step of capture.steps) {
assert.equal(step.artifacts.dom.state, "captured");
const text = step.artifacts.dom.value.text;
const observed = /<pre id="environment">([^<]+)<\/pre>/u.exec(text);
assert.ok(observed, "DOM omitted the page's actual environment values");
assert.deepEqual(JSON.parse(observed[1]), values);
}
};
const assertCallerLanguage = (capture, baseline) => {
assert.ok(
capture.limitations.some((value) =>
value.includes("Attached locale fr-FR applies to Intl formatting only"),
),
"Attached capture must identify its page/request language limitation",
);
for (const step of capture.steps) {
const text = step.artifacts.dom.value.text;
const observed = /<pre id="language">([^<]+)<\/pre>/u.exec(text);
assert.ok(
observed,
"DOM omitted the navigator/request language observations",
);
assert.deepEqual(JSON.parse(observed[1]), baseline);
}
};
/** Verify actual attached-page emulation and restoration across scenario outcomes. */
export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
const languageExpression = `(async () => JSON.stringify({
language: navigator.language, languages: [...navigator.languages],
request_language: await (await fetch(
${JSON.stringify(`${origin}/scenario-request-language`)}, { cache: "no-store" }
)).json()
}))()`;
const targets = await (await fetch(`${endpoint}/json/list`)).json();
const target = targets.find(({ id }) => id === targetId);
assert.ok(target, "External fixture target is missing");
const connection = await CdpConnection.connect(
target.webSocketDebuggerUrl,
"capture_browser_scenario",
);
const readEnvironment = async (expressionToRead = expression) => {
const value = await connection.send("Runtime.evaluate", {
expression: expressionToRead,
returnByValue: true,
awaitPromise: true,
});
assert.equal(value.result.type, "string");
return JSON.parse(value.result.value);
};
const provider = createBrowserScenarioProvider(process.env);
let verificationError;
try {
const baseline = await readEnvironment();
const callerLanguage = await readEnvironment(languageExpression);
const scenario = environmentScenario({
endpoint,
targetId,
origin,
values: expected,
});
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
assert.deepEqual(
await readEnvironment(languageExpression),
callerLanguage,
);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
};
const cliCapture = (await runScenarioCli(scenario)).normalized_result;
assertCapture(cliCapture);
assertCallerLanguage(cliCapture, callerLanguage);
await assertRestored();
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
env: process.env,
stderr: "pipe",
});
const client = new Client({
name: "browser-environment-e2e",
version: "1",
});
try {
await client.connect(transport);
const definition = (await client.listTools()).tools.find(
({ name }) => name === "capture_browser_scenario",
);
assert.ok(definition, "Scenario tool is absent from the current catalog");
const mcpCapture = requireMcpEvidenceResult(
await client.callTool(
{ name: "capture_browser_scenario", arguments: scenario },
{ toolDefinition: definition },
),
"capture_browser_scenario",
);
assertCapture(mcpCapture);
assertCallerLanguage(mcpCapture, callerLanguage);
await verifyConcurrentMcpCapture({
client,
definition,
connection,
endpoint,
targetId,
origin,
assertRestored,
});
} finally {
await client.close();
await transport.close();
}
await assertRestored();
const initializationFailure = await provider.captureScenario({
...scenario,
environment: { ...scenario.environment, timezone: "REA/Invalid" },
});
assert.equal(initializationFailure.ok, false);
await assertRestored();
const waiting = browserScenarioSchema.parse({
...scenario,
actions: [
{
step_id: "missing",
action: "wait_for",
locator: { kind: "css", selector: "#missing-environment-element" },
state: "visible",
timeout_ms: 100,
},
],
});
const actionFailure = await provider.captureScenario(waiting);
assert.equal(actionFailure.ok, true);
assertCapture(actionFailure.value);
assert.equal(actionFailure.value.steps.at(-1).status, "failed");
await assertRestored();
await verifyCancelledEnvironment(provider, connection, waiting);
await assertRestored();
return {
mocked: false,
cli: true,
stdio_mcp: true,
observed: expected,
restored: baseline,
caller_language_preserved: callerLanguage,
attached_locale_limitation: true,
initialization_failure_cleanup: true,
action_failure_cleanup: true,
cancellation_cleanup: true,
external_target_preserved: true,
};
} catch (cause) {
verificationError = cause;
throw cause;
} finally {
await closeVerifierResources(
[() => provider.close(), () => connection.close()],
verificationError,
);
}
}
const verifyConcurrentMcpCapture = async ({
client,
definition,
connection,
endpoint,
targetId,
origin,
assertRestored,
}) => {
const firstScenario = environmentScenario({
endpoint,
targetId,
origin,
values: expected,
run: "A",
});
const secondScenario = environmentScenario({
endpoint,
targetId,
origin,
values: alternate,
run: "B",
});
const firstCancellation = new AbortController();
const secondCancellation = new AbortController();
const cancellation = new AbortController();
const pendingCalls = [];
const callTool = (arguments_, signal) => {
const pending = client.callTool(
{ name: "capture_browser_scenario", arguments: arguments_ },
{ signal, timeout: 60_000, toolDefinition: definition },
);
void pending.catch(() => {});
pendingCalls.push(pending);
return pending;
};
try {
const first = callTool(firstScenario, firstCancellation.signal);
await waitForEnvironment(connection, expected, "A");
const second = callTool(secondScenario, secondCancellation.signal);
await client.ping();
const firstSnapshot = await readScenarioSnapshot(connection);
assert.deepEqual(firstSnapshot.environment, expected);
assert.equal(firstSnapshot.run, "A");
const cancelled = callTool(
environmentScenario({
endpoint,
targetId,
origin,
values: alternate,
run: "C",
}),
cancellation.signal,
);
await client.ping();
cancellation.abort(new Error("cancel queued browser scenario"));
await assert.rejects(cancelled, /cancel queued browser scenario/u);
const queuedSnapshot = await readScenarioSnapshot(connection);
assert.deepEqual(queuedSnapshot.environment, expected);
assert.equal(queuedSnapshot.run, "A");
await releaseScenario(connection);
assertCapture(
requireMcpEvidenceResult(await first, "capture_browser_scenario"),
expected,
);
await waitForEnvironment(connection, alternate, "B");
await releaseScenario(connection);
assertCapture(
requireMcpEvidenceResult(await second, "capture_browser_scenario"),
alternate,
);
await client.ping();
await assertRestored();
} finally {
firstCancellation.abort();
secondCancellation.abort();
cancellation.abort();
await releaseScenario(connection).catch(() => undefined);
await Promise.allSettled(pendingCalls);
}
};
const waitForEnvironment = async (connection, expectedValues, run) => {
await waitForScenarioSnapshot(
connection,
(snapshot) =>
JSON.stringify(snapshot.environment) === JSON.stringify(expectedValues) &&
snapshot.run === run,
`Scenario ${run} never reached its page-controlled gate`,
);
};
const waitForScenarioSnapshot = async (connection, accept, message) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
let lastTransitionRejection;
try {
while (!controller.signal.aborted) {
try {
const snapshot = await readScenarioSnapshot(
connection,
controller.signal,
);
if (controller.signal.aborted) break;
if (accept(snapshot)) return;
} catch (cause) {
if (
controller.signal.aborted &&
cause instanceof AnalysisCancelledError
)
break;
if (!isNavigationTransitionRejection(cause)) throw cause;
lastTransitionRejection = cause;
}
await delay(25);
}
throw new Error(message, {
cause: lastTransitionRejection ?? controller.signal.reason,
});
} finally {
clearTimeout(timer);
}
};
const readScenarioSnapshot = async (connection, signal) => {
const value = await connection.send(
"Runtime.evaluate",
{ expression: scenarioSnapshotExpression, returnByValue: true },
undefined,
signal,
);
assert.equal(value.result.type, "string");
return JSON.parse(value.result.value);
};
const releaseScenario = async (connection) => {
await connection.send("Runtime.evaluate", {
expression: "window.releaseScenario()",
returnByValue: true,
});
};
export async function verifyCancelledEnvironment(
provider,
connection,
scenario,
) {
const controller = new AbortController();
const priorSnapshot = await readScenarioSnapshot(connection);
const pending = provider.captureScenario(
{ ...scenario, actions: [{ ...scenario.actions[0], timeout_ms: 30_000 }] },
{ signal: controller.signal },
);
const settlement = Promise.allSettled([pending]);
try {
await waitForScenarioSnapshot(
connection,
(snapshot) =>
snapshot.timeOrigin !== priorSnapshot.timeOrigin &&
JSON.stringify(snapshot.environment) === JSON.stringify(expected),
"Scenario never applied emulation",
);
await delay(100);
} finally {
controller.abort();
await settlement;
}
const cancelled = await pending;
if (!cancelled.ok) {
if (!(cancelled.error instanceof AnalysisCancelledError))
throw cancelled.error;
assert.equal(cancelled.error.cleanupIncomplete, false);
} else {
assert.ok(
cancelled.value.steps.some(({ status }) => status === "cancelled"),
"Cancellation unexpectedly completed every action",
);
assert.equal(cancelled.value.browser.cleanup, "disconnected-external");
}
}