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. */