From a0523b375eb3147f1d2d1e7eb10a2f4546f5d0fc Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Fri, 9 Oct 2026 23:47:10 +0800 Subject: [PATCH] fix(browser): retain attached scenario emulation until cleanup --- docs/testing.md | 5 + scripts/fixtures/browser-verifier-site.mjs | 14 ++ .../lib/browser-scenario-environment-e2e.mjs | 200 ++++++++++++++++++ scripts/verify-real-browser.mjs | 7 + src/browser/PlaywrightScenarioBrowser.ts | 30 ++- 5 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 scripts/lib/browser-scenario-environment-e2e.mjs diff --git a/docs/testing.md b/docs/testing.md index 9079160a1..76d09d7af 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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, diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 7a5095e52..208a314a2 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -11,6 +11,20 @@ export async function startBrowserVerifierSite() { response.destroy(); return; } + if (request.url === "/scenario-environment") { + response.setHeader("content-type", "text/html"); + response.end(`
+ `); + return; + } if (request.url === "/slow-json") { response.setHeader("content-type", "application/json"); response.write('{"pending":'); diff --git a/scripts/lib/browser-scenario-environment-e2e.mjs b/scripts/lib/browser-scenario-environment-e2e.mjs new file mode 100644 index 000000000..bc460192d --- /dev/null +++ b/scripts/lib/browser-scenario-environment-e2e.mjs @@ -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>/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",
+ );
+}
diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs
index 5f05981d6..4dd492854 100644
--- a/scripts/verify-real-browser.mjs
+++ b/scripts/verify-real-browser.mjs
@@ -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 {
diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts
index 10942cbe2..392c96278 100644
--- a/src/browser/PlaywrightScenarioBrowser.ts
+++ b/src/browser/PlaywrightScenarioBrowser.ts
@@ -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. */