fix(browser): retain attached scenario emulation until cleanup
This commit is contained in:
parent
5e8fb531e2
commit
a0523b375e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue