fix(browser): retain attached scenario emulation until cleanup

This commit is contained in:
dakjdakd 2026-10-09 23:47:10 +08:00 committed by morluto
parent 5e8fb531e2
commit a0523b375e
5 changed files with 240 additions and 16 deletions

View File

@ -281,6 +281,11 @@ regressions until a real fixture establishes equivalent coverage.
decoding. Its SDK client explicitly permits the larger inline JSON response;
this lane does not establish large image-comparison request transport coverage.
The scenario checks read locale, timezone, device scale and viewport dimensions
from actual page JavaScript through CLI and stdio MCP DOM captures. They also
verify that attached-page emulation is released after success, initialization
failure, action failure and cancellation while the external target stays open.
`verify:browser:network` is a focused real-browser lane for transaction identity,
selected request/response bytes, binary and compressed responses, duplicate
headers, credential and declared-secret redaction, redirects, streaming cutoff,

View File

@ -11,6 +11,20 @@ export async function startBrowserVerifierSite() {
response.destroy();
return;
}
if (request.url === "/scenario-environment") {
response.setHeader("content-type", "text/html");
response.end(`<!doctype html><html><body><pre id="environment"></pre>
<button id="refresh-environment">Refresh environment</button><script>
const refresh = () => document.querySelector('#environment').textContent = JSON.stringify({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: Intl.DateTimeFormat().resolvedOptions().locale,
dpr: devicePixelRatio, width: innerWidth, height: innerHeight
});
document.querySelector('#refresh-environment').onclick = refresh;
refresh();
</script></body></html>`);
return;
}
if (request.url === "/slow-json") {
response.setHeader("content-type", "application/json");
response.write('{"pending":');

View File

@ -0,0 +1,200 @@
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 { CdpConnection } from "../../dist/browser/CdpConnection.js";
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
import { requireMcpEvidenceResult } from "./mcp-verifier-results.mjs";
const expected = {
timezone: "Pacific/Honolulu",
locale: "fr-FR",
dpr: 2,
width: 900,
height: 600,
};
const expression = `JSON.stringify({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: Intl.DateTimeFormat().resolvedOptions().locale,
dpr: devicePixelRatio, width: innerWidth, height: innerHeight
})`;
const environmentScenario = (endpoint, targetId, origin) =>
browserScenarioSchema.parse({
browser: { mode: "connect", cdp_endpoint: endpoint, target_id: targetId },
start_url: { url: `${origin}/scenario-environment` },
environment: {
locale: expected.locale,
timezone: expected.timezone,
viewport: {
width: expected.width,
height: expected.height,
device_scale_factor: expected.dpr,
},
},
actions: [
{
step_id: "refresh",
action: "click",
locator: { kind: "css", selector: "#refresh-environment" },
},
],
capture: { after_each_step: ["dom"], at_end: ["dom"] },
});
const assertCapture = (capture) => {
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]), expected);
}
};
/** Verify actual attached-page emulation and restoration across scenario outcomes. */
export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
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 () => {
const value = await connection.send("Runtime.evaluate", {
expression,
returnByValue: true,
});
assert.equal(value.result.type, "string");
return JSON.parse(value.result.value);
};
const baseline = await readEnvironment();
const scenario = environmentScenario(endpoint, targetId, origin);
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
};
try {
assertCapture((await runScenarioCli(scenario)).normalized_result);
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);
assertCapture(
requireMcpEvidenceResult(
await client.callTool({
name: "capture_browser_scenario",
arguments: scenario,
}),
"capture_browser_scenario",
),
);
} finally {
await client.close();
await transport.close();
}
await assertRestored();
const provider = createBrowserScenarioProvider(process.env);
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,
readEnvironment,
);
await assertRestored();
return {
mocked: false,
cli: true,
stdio_mcp: true,
observed: expected,
restored: baseline,
initialization_failure_cleanup: true,
action_failure_cleanup: true,
cancellation_cleanup: true,
external_target_preserved: true,
};
} finally {
await connection.close();
}
}
async function verifyCancelledEnvironment(
provider,
connection,
scenario,
readEnvironment,
) {
const controller = new AbortController();
const priorNavigation = await connection.send("Runtime.evaluate", {
expression: "performance.timeOrigin",
returnByValue: true,
});
const pending = provider.captureScenario(
{ ...scenario, actions: [{ ...scenario.actions[0], timeout_ms: 30_000 }] },
{ signal: controller.signal },
);
try {
const deadline = Date.now() + 10_000;
while (true) {
const navigation = await connection.send("Runtime.evaluate", {
expression: "performance.timeOrigin",
returnByValue: true,
});
if (
navigation.result.value !== priorNavigation.result.value &&
JSON.stringify(await readEnvironment()) === JSON.stringify(expected)
)
break;
assert.ok(Date.now() < deadline, "Scenario never applied emulation");
await delay(25);
}
await delay(100);
} finally {
controller.abort();
}
const cancelled = await pending;
assert.ok(
!cancelled.ok ||
cancelled.value.steps.some(({ status }) => status === "cancelled"),
"Cancellation unexpectedly completed every action",
);
}

View File

@ -44,6 +44,7 @@ import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs";
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metadata-budget-e2e.mjs";
import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs";
import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs";
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
@ -274,6 +275,11 @@ try {
throw new Error("Scenario attachment terminated its external browser");
const profilesBefore = await scenarioProfiles();
const scenarioEnvironment = await verifyScenarioEnvironment(
endpoint,
target,
site.origin,
);
const launchedScenario = await createBrowserScenarioProvider(
process.env,
).captureScenario(
@ -366,6 +372,7 @@ try {
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
scenarioFailure,
scenarioEnvironment,
verified: true,
};
} finally {

View File

@ -173,22 +173,20 @@ const configureAttachedEnvironment = async (
reducedMotion: scenario.environment.reduced_motion,
});
const session = await context.newCDPSession(page);
try {
await session.send("Emulation.setDeviceMetricsOverride", {
width: scenario.environment.viewport.width,
height: scenario.environment.viewport.height,
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
mobile: false,
});
await session.send("Emulation.setLocaleOverride", {
locale: scenario.environment.locale,
});
await session.send("Emulation.setTimezoneOverride", {
timezoneId: scenario.environment.timezone,
});
} finally {
await session.detach();
}
// Chromium resets these overrides when the CDP session detaches. Keep it on
// the scenario transport until cleanup disconnects the attached browser.
await session.send("Emulation.setDeviceMetricsOverride", {
width: scenario.environment.viewport.width,
height: scenario.environment.viewport.height,
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
mobile: false,
});
await session.send("Emulation.setLocaleOverride", {
locale: scenario.environment.locale,
});
await session.send("Emulation.setTimezoneOverride", {
timezoneId: scenario.environment.timezone,
});
};
/** Preserve an operation failure while reporting any incomplete cleanup. */