mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
feat(browser): capture controlled Playwright scenarios
This commit is contained in:
Generated
+13
@@ -28,6 +28,7 @@
|
||||
"incur": "0.4.13",
|
||||
"isomorphic-git": "1.38.7",
|
||||
"pino": "9.14.0",
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "^1.7.0",
|
||||
@@ -4311,6 +4312,18 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-5.0.0.tgz",
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"incur": "0.4.13",
|
||||
"isomorphic-git": "1.38.7",
|
||||
"pino": "9.14.0",
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "^1.7.0",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
/** Build the source-owned full-capture browser scenario. */
|
||||
export function browserScenario(browser, origin) {
|
||||
return browserScenarioSchema.parse({
|
||||
schema_version: 1,
|
||||
browser,
|
||||
start_url: { url: `${origin}/app`, query: [] },
|
||||
allowed_origins: [origin],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
state: "visible",
|
||||
},
|
||||
{
|
||||
step_id: "verify",
|
||||
action: "click",
|
||||
locator: {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
storage: {},
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: ["token"],
|
||||
},
|
||||
capture: {
|
||||
after_each_step: [
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
],
|
||||
at_end: [
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
],
|
||||
events: [
|
||||
"console",
|
||||
"page-errors",
|
||||
"network",
|
||||
"websockets",
|
||||
"frames",
|
||||
"workers",
|
||||
"popups",
|
||||
"downloads",
|
||||
],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 30_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
max_events: 10_000,
|
||||
max_frames: 100,
|
||||
max_workers: 20,
|
||||
max_popups: 10,
|
||||
max_websockets: 100,
|
||||
max_dom_nodes: 10_000,
|
||||
max_accessibility_nodes: 10_000,
|
||||
max_screenshots: 8,
|
||||
max_screenshot_bytes: 4 * 1_024 * 1_024,
|
||||
max_storage_entries: 256,
|
||||
max_total_metadata_bytes: 8 * 1_024 * 1_024,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Run scenario capture through the public one-shot CLI. */
|
||||
export async function runScenarioCli(scenario, configuration) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
REA_BROWSER_SCENARIO_ENABLED: "true",
|
||||
REA_BROWSER_SCENARIO_AUTO_GRANT: "true",
|
||||
REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([
|
||||
dirname(scenario.browser.executable_path ?? process.execPath),
|
||||
]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: "[]",
|
||||
...configuration,
|
||||
},
|
||||
maxBuffer: 64 * 1_024 * 1_024,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
/** Snapshot provider-owned profile names for cleanup comparison. */
|
||||
export async function scenarioProfiles() {
|
||||
return new Set(
|
||||
(await readdir(tmpdir())).filter((entry) =>
|
||||
entry.startsWith("rea-browser-scenario-"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert full real-browser scenario evidence and cleanup prerequisites. */
|
||||
export function assertScenarioCapture(capture) {
|
||||
if (
|
||||
capture.steps.length !== 3 ||
|
||||
capture.steps.some(({ status }) => status !== "completed")
|
||||
)
|
||||
throw new Error("Scenario launch did not complete every declared step");
|
||||
const final = capture.steps.at(-1);
|
||||
if (
|
||||
final === undefined ||
|
||||
Object.values(final.artifacts).some(({ state }) => state !== "captured")
|
||||
)
|
||||
throw new Error(
|
||||
`Scenario launch did not capture every requested artifact: ${JSON.stringify(final?.artifacts ?? null)}`,
|
||||
);
|
||||
if (
|
||||
capture.events.items.length === 0 ||
|
||||
capture.completeness.equality_eligible !== true
|
||||
)
|
||||
throw new Error(
|
||||
"Scenario launch did not produce complete runtime evidence",
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,167 @@ export function sourceMapSummary(sourceMaps) {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Assert passive real-browser inventory, event, privacy, and metadata claims. */
|
||||
export function assertObservation(result, origin) {
|
||||
assertObservationInventory(result, origin);
|
||||
assertObservationEvents(result);
|
||||
assertObservationPrivacy(result);
|
||||
assertObservationMetadata(result);
|
||||
}
|
||||
|
||||
function assertObservationInventory(result, origin) {
|
||||
if (result.target.origin !== origin)
|
||||
throw new Error("Real Chrome target origin was not preserved exactly");
|
||||
if (result.frames.length < 1 || result.dom.nodes.length < 1)
|
||||
throw new Error(
|
||||
`Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`,
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome resource metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationEvents(result) {
|
||||
if (!result.network.requests.some((request) => request.url.includes("/api")))
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationPrivacy(result) {
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
}
|
||||
|
||||
function assertObservationMetadata(result) {
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
/** Assert real-browser static bundle and source-map findings. */
|
||||
export function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert explicitly approved console, JSON, and WebSocket shape capture. */
|
||||
export function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find(
|
||||
(item) =>
|
||||
item.url.includes("/api") &&
|
||||
item.body_shapes.request?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
) &&
|
||||
item.body_shapes.response?.properties.some(
|
||||
({ path }) => path === "/secret",
|
||||
),
|
||||
);
|
||||
if (request === undefined)
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify({
|
||||
console: result.console,
|
||||
network: result.network,
|
||||
});
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
+75
-162
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../dist/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
@@ -20,7 +21,17 @@ import {
|
||||
} from "../dist/domain/webScreenshot.js";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs";
|
||||
import { sourceMapSummary } from "./lib/browser-verifier-assertions.mjs";
|
||||
import {
|
||||
assertBundleAnalysis,
|
||||
assertObservation,
|
||||
assertSensitiveShapes,
|
||||
} from "./lib/browser-verifier-assertions.mjs";
|
||||
import {
|
||||
assertScenarioCapture,
|
||||
browserScenario,
|
||||
runScenarioCli,
|
||||
scenarioProfiles,
|
||||
} from "./lib/browser-scenario-verifier.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
@@ -208,6 +219,66 @@ try {
|
||||
|
||||
pageProxy = await startPageCdpProxy(endpoint);
|
||||
await verifyPageScopedTransport(provider, pageProxy, site.origin);
|
||||
const attachedScenario = await runScenarioCli(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "connect",
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
{
|
||||
REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: JSON.stringify([site.origin]),
|
||||
},
|
||||
);
|
||||
if (
|
||||
attachedScenario.normalized_result?.browser?.cleanup !==
|
||||
"disconnected-external" ||
|
||||
attachedScenario.normalized_result?.browser?.process_ownership !==
|
||||
"external"
|
||||
)
|
||||
throw new Error(
|
||||
"Scenario CLI did not report external disconnect ownership",
|
||||
);
|
||||
const versionResponse = await fetch(`${endpoint}/json/version`);
|
||||
if (!versionResponse.ok || browser.exitCode !== null)
|
||||
throw new Error("Scenario attachment terminated its external browser");
|
||||
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
const launchedScenario =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
);
|
||||
if (!launchedScenario.ok) throw launchedScenario.error;
|
||||
if (
|
||||
launchedScenario.value.browser.cleanup !== "terminated-owned-process" ||
|
||||
launchedScenario.value.browser.process_ownership !== "provider-owned"
|
||||
)
|
||||
throw new Error("Scenario launch did not report owned-process cleanup");
|
||||
const profilesAfter = await scenarioProfiles();
|
||||
if ([...profilesAfter].some((entry) => !profilesBefore.has(entry)))
|
||||
throw new Error("Scenario launch retained a temporary browser profile");
|
||||
assertScenarioCapture(launchedScenario.value);
|
||||
for (const secret of ["network-secret-value", "websocket-url-secret"])
|
||||
if (
|
||||
JSON.stringify([attachedScenario, launchedScenario.value]).includes(
|
||||
secret,
|
||||
)
|
||||
)
|
||||
throw new Error(`Browser scenario retained sensitive value: ${secret}`);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -226,6 +297,9 @@ try {
|
||||
sessionEvents: session.value.timeline.length,
|
||||
pageScopedTransport: true,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
browserScenarioCli: true,
|
||||
browserScenarioAttachCleanup: "disconnected-external",
|
||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
@@ -337,167 +411,6 @@ async function pageTarget(provider, endpoint, origin) {
|
||||
throw new Error("Real Chrome did not expose the local test page target");
|
||||
}
|
||||
|
||||
function assertObservation(result, origin) {
|
||||
assertObservationInventory(result, origin);
|
||||
assertObservationEvents(result);
|
||||
assertObservationPrivacy(result);
|
||||
assertObservationMetadata(result);
|
||||
}
|
||||
|
||||
function assertObservationInventory(result, origin) {
|
||||
if (result.target.origin !== origin)
|
||||
throw new Error("Real Chrome target origin was not preserved exactly");
|
||||
if (result.frames.length < 1 || result.dom.nodes.length < 1)
|
||||
throw new Error(
|
||||
`Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`,
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome resource metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationEvents(result) {
|
||||
if (!result.network.requests.some((request) => request.url.includes("/api")))
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationPrivacy(result) {
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
}
|
||||
|
||||
function assertObservationMetadata(result) {
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find(
|
||||
(item) =>
|
||||
item.url.includes("/api") &&
|
||||
item.body_shapes.request?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
) &&
|
||||
item.body_shapes.response?.properties.some(
|
||||
({ path }) => path === "/secret",
|
||||
),
|
||||
);
|
||||
if (request === undefined)
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify({
|
||||
console: result.console,
|
||||
network: result.network,
|
||||
});
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Provider-neutral application boundary for controlled browser scenarios. */
|
||||
export interface BrowserScenarioCapturePort {
|
||||
identity(): ProviderIdentity;
|
||||
captureScenario(
|
||||
scenario: BrowserScenario,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<BrowserScenarioCapture, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
|
||||
const networkScope = (origins: readonly string[]): "loopback" | "external" =>
|
||||
origins.every((origin) => isLiteralLoopbackHostname(new URL(origin).hostname))
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
||||
const authorizeScenario = async (
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
): Promise<Result<true, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"browser scenario permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "browser_automate",
|
||||
roots: [],
|
||||
executables:
|
||||
scenario.browser.mode === "launch"
|
||||
? [scenario.browser.executable_path]
|
||||
: [],
|
||||
environment_names: scenario.secrets.map(
|
||||
({ environment_variable: name }) => name,
|
||||
),
|
||||
origins: [
|
||||
...(scenario.browser.mode === "connect"
|
||||
? [scenario.browser.cdp_endpoint]
|
||||
: []),
|
||||
...scenario.allowed_origins,
|
||||
],
|
||||
network: networkScope(scenario.allowed_origins),
|
||||
mount: false,
|
||||
operation_identity: `${OPERATION}:${digestJson(scenario)}`,
|
||||
},
|
||||
"write",
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return err(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
return { ok: true, value: true };
|
||||
};
|
||||
|
||||
/** Authorize and execute one controlled browser scenario. */
|
||||
export const captureBrowserScenario = async (
|
||||
provider: BrowserScenarioCapturePort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const authorized = await authorizeScenario(authority, scenario);
|
||||
if (!authorized.ok) return authorized;
|
||||
if (provider === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"browser scenario provider is not configured",
|
||||
),
|
||||
);
|
||||
const captured = await provider.captureScenario(scenario, options);
|
||||
return captured.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
captured.value,
|
||||
provider.identity(),
|
||||
),
|
||||
}
|
||||
: captured;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
const browserScenarioParameters = (
|
||||
scenario: BrowserScenario,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
scenario_sha256: digestJson(scenario),
|
||||
browser_mode: scenario.browser.mode,
|
||||
start_url: sanitizeBrowserUrl(scenario.start_url.url),
|
||||
allowed_origins: scenario.allowed_origins,
|
||||
actions: scenario.actions.map(({ step_id, action }) => ({
|
||||
step_id,
|
||||
action,
|
||||
})),
|
||||
secret_declarations: scenario.secrets.map(
|
||||
({ secret_id, environment_variable, purpose, redaction }) => ({
|
||||
secret_id,
|
||||
environment_variable,
|
||||
purpose,
|
||||
redaction,
|
||||
}),
|
||||
),
|
||||
capture: scenario.capture,
|
||||
limits: scenario.limits,
|
||||
});
|
||||
|
||||
/** Create Evidence v2 without retaining resolved scenario secret values. */
|
||||
export const createBrowserScenarioEvidence = (
|
||||
scenario: BrowserScenario,
|
||||
capture: BrowserScenarioCapture,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: "rea.browser-scenario-capture/v1",
|
||||
operation: "capture_browser_scenario",
|
||||
parameters: browserScenarioParameters(scenario),
|
||||
result: jsonValueSchema.parse(capture),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
id: `${capture.browser.product}@${capture.browser.version}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation:
|
||||
capture.browser.process_ownership === "provider-owned"
|
||||
? "process"
|
||||
: "none",
|
||||
},
|
||||
limitations: capture.limitations,
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type {
|
||||
BrowserScenario,
|
||||
BrowserScenarioUrl,
|
||||
BrowserScenarioValue,
|
||||
} from "../domain/browserScenario.js";
|
||||
|
||||
const REDACTION_PREFIX = "[REDACTED:";
|
||||
|
||||
/** Resolved secret values kept only for one in-memory scenario session. */
|
||||
export class BrowserScenarioSecrets {
|
||||
private constructor(private readonly values: ReadonlyMap<string, string>) {}
|
||||
|
||||
static resolve(
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): BrowserScenarioSecrets | undefined {
|
||||
const values = new Map<string, string>();
|
||||
for (const declaration of scenario.secrets) {
|
||||
const value = environment[declaration.environment_variable];
|
||||
if (value === undefined) return undefined;
|
||||
values.set(declaration.secret_id, value);
|
||||
}
|
||||
return new BrowserScenarioSecrets(values);
|
||||
}
|
||||
|
||||
value(source: BrowserScenarioValue): string {
|
||||
if (source.source === "literal") return source.value;
|
||||
const value = this.values.get(source.secret_id);
|
||||
if (value === undefined)
|
||||
throw new Error("Validated browser secret was not resolved");
|
||||
return value;
|
||||
}
|
||||
|
||||
url(destination: BrowserScenarioUrl): string {
|
||||
const url = new URL(destination.url);
|
||||
for (const { name, value } of destination.query)
|
||||
url.searchParams.append(name, this.value(value));
|
||||
return url.href;
|
||||
}
|
||||
|
||||
redact(value: string): string {
|
||||
let output = value;
|
||||
for (const [id, secret] of this.values)
|
||||
if (secret !== "")
|
||||
output = output.replaceAll(secret, `${REDACTION_PREFIX}${id}]`);
|
||||
return output;
|
||||
}
|
||||
|
||||
fingerprint(value: string): {
|
||||
readonly value_state: "hashed" | "redacted-secret";
|
||||
readonly value_sha256: string | null;
|
||||
} {
|
||||
for (const secret of this.values.values())
|
||||
if (secret !== "" && value.includes(secret))
|
||||
return { value_state: "redacted-secret", value_sha256: null };
|
||||
return {
|
||||
value_state: "hashed",
|
||||
value_sha256: createHash("sha256")
|
||||
.update(this.redact(value))
|
||||
.digest("hex"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
BrowserScenario,
|
||||
BrowserScenarioAction,
|
||||
} from "../domain/browserScenario.js";
|
||||
import type {
|
||||
BrowserScenarioEvent,
|
||||
BrowserStepArtifacts,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import type { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
|
||||
|
||||
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
||||
|
||||
export interface BrowserScenarioSessionPort {
|
||||
readonly mode: "launch" | "connect";
|
||||
readonly processOwnership: "provider-owned" | "external";
|
||||
readonly product: string;
|
||||
readonly version: string;
|
||||
readonly initialUrl: string;
|
||||
currentUrl(): string;
|
||||
setStep(index: number): void;
|
||||
nextEventSequence(): number;
|
||||
lastEventSequence(): number;
|
||||
events(): {
|
||||
readonly retained: number;
|
||||
readonly dropped: number;
|
||||
readonly items: readonly BrowserScenarioEvent[];
|
||||
};
|
||||
eventTruncationSections(): readonly (
|
||||
| "events"
|
||||
| "frames"
|
||||
| "workers"
|
||||
| "popups"
|
||||
| "websockets"
|
||||
)[];
|
||||
perform(
|
||||
action: BrowserScenarioAction,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void>;
|
||||
capture(
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
budget: BrowserScenarioCaptureBudget,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserStepArtifacts>;
|
||||
close(): Promise<"terminated-owned-process" | "disconnected-external">;
|
||||
redactError(error: unknown): string;
|
||||
}
|
||||
|
||||
export interface BrowserScenarioSessionFactory {
|
||||
open(
|
||||
scenario: BrowserScenario,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly budget?: BrowserScenarioCaptureBudget;
|
||||
readonly deadlineAt?: number;
|
||||
},
|
||||
): Promise<BrowserScenarioSessionPort>;
|
||||
}
|
||||
@@ -74,13 +74,13 @@ export const discoverCdpEndpoint = async (
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpEndpointDiscovery> => {
|
||||
const versionInput = await readJson(
|
||||
const versionInput = await readBoundedCdpJson(
|
||||
new URL("/json/version", endpoint),
|
||||
MAX_VERSION_BYTES,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
const targetsInput = await readJson(
|
||||
const targetsInput = await readBoundedCdpJson(
|
||||
new URL("/json/list", endpoint),
|
||||
MAX_TARGET_LIST_BYTES,
|
||||
operation,
|
||||
@@ -289,7 +289,8 @@ const targetWebSocket = (
|
||||
}
|
||||
};
|
||||
|
||||
const readJson = async (
|
||||
/** Read one bounded loopback CDP discovery document without redirects. */
|
||||
export const readBoundedCdpJson = async (
|
||||
url: URL,
|
||||
maximumBytes: number,
|
||||
operation: BrowserObservationOperation,
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import type {
|
||||
BrowserScenario,
|
||||
BrowserScenarioAction,
|
||||
} from "../domain/browserScenario.js";
|
||||
import {
|
||||
browserScenarioCaptureSchema,
|
||||
browserScenarioCompletenessSchema,
|
||||
browserScenarioStepSchema,
|
||||
browserStepArtifactsSchema,
|
||||
type BrowserScenarioCapture,
|
||||
type BrowserScenarioCompleteness,
|
||||
type BrowserScenarioStep,
|
||||
type BrowserStepArtifacts,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
BrowserObservationError,
|
||||
ProviderAdapterError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
BrowserScenarioSessionFactory,
|
||||
BrowserScenarioSessionPort,
|
||||
} from "./BrowserScenarioSessionPort.js";
|
||||
import { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioSessionFactory } from "./PlaywrightScenarioSession.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
|
||||
Object.freeze({
|
||||
id: "rea-playwright-browser-scenario",
|
||||
name: "REA Playwright browser scenario capture provider",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
||||
|
||||
const requestedForStep = (
|
||||
scenario: BrowserScenario,
|
||||
stepIndex: number,
|
||||
): ReadonlySet<SnapshotKind> =>
|
||||
new Set([
|
||||
...scenario.capture.after_each_step,
|
||||
...(stepIndex === scenario.actions.length ? scenario.capture.at_end : []),
|
||||
]);
|
||||
|
||||
const unavailableArtifacts = (
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
reason: string,
|
||||
): BrowserStepArtifacts => {
|
||||
const state = (kind: SnapshotKind) =>
|
||||
requested.has(kind)
|
||||
? { state: "missing" as const, reason }
|
||||
: { state: "not_requested" as const };
|
||||
return browserStepArtifactsSchema.parse({
|
||||
screenshot: state("screenshot"),
|
||||
dom: state("dom"),
|
||||
accessibility: state("accessibility"),
|
||||
url: state("url"),
|
||||
history: state("history"),
|
||||
storage: state("storage"),
|
||||
});
|
||||
};
|
||||
|
||||
const stepCompleteness = (
|
||||
artifacts: BrowserStepArtifacts,
|
||||
status: BrowserScenarioStep["status"],
|
||||
): BrowserScenarioCompleteness => {
|
||||
const missing = Object.entries(artifacts)
|
||||
.filter(([, state]) => state.state === "missing")
|
||||
.map(([section]) => section);
|
||||
const truncated = Object.entries(artifacts)
|
||||
.filter(([, state]) => state.state === "truncated")
|
||||
.map(([section]) => section);
|
||||
if (status !== "completed") missing.push("action");
|
||||
const condition =
|
||||
truncated.length > 0
|
||||
? "truncated"
|
||||
: missing.length > 0
|
||||
? "incomplete"
|
||||
: "complete";
|
||||
return browserScenarioCompletenessSchema.parse({
|
||||
status: condition,
|
||||
equality_eligible: condition === "complete",
|
||||
missing_sections: [...new Set(missing)].sort(),
|
||||
truncated_sections: [...new Set(truncated)].sort(),
|
||||
});
|
||||
};
|
||||
|
||||
const createStep = (input: {
|
||||
readonly stepIndex: number;
|
||||
readonly stepId: string;
|
||||
readonly action: string;
|
||||
readonly status: BrowserScenarioStep["status"];
|
||||
readonly elapsedMs: number;
|
||||
readonly beforeUrl: string;
|
||||
readonly afterUrl: string;
|
||||
readonly error: string | null;
|
||||
readonly eventStart: number;
|
||||
readonly eventEnd: number;
|
||||
readonly artifacts: BrowserStepArtifacts;
|
||||
}): BrowserScenarioStep =>
|
||||
browserScenarioStepSchema.parse({
|
||||
step_index: input.stepIndex,
|
||||
step_id: input.stepId,
|
||||
action: input.action,
|
||||
status: input.status,
|
||||
elapsed_ms: input.elapsedMs,
|
||||
before_url: sanitizeBrowserUrl(input.beforeUrl),
|
||||
after_url: sanitizeBrowserUrl(input.afterUrl),
|
||||
error: input.error,
|
||||
event_sequence_start: input.eventStart,
|
||||
event_sequence_end: input.eventEnd,
|
||||
artifacts: input.artifacts,
|
||||
completeness: stepCompleteness(input.artifacts, input.status),
|
||||
});
|
||||
|
||||
const initialStep = async (input: {
|
||||
readonly session: BrowserScenarioSessionPort;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
readonly elapsedMs: number;
|
||||
readonly maximumTimeoutMs: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<BrowserScenarioStep> => {
|
||||
const { session, scenario, budget, elapsedMs, maximumTimeoutMs, signal } =
|
||||
input;
|
||||
const artifacts = await session.capture(
|
||||
requestedForStep(scenario, 0),
|
||||
budget,
|
||||
maximumTimeoutMs,
|
||||
signal,
|
||||
);
|
||||
return createStep({
|
||||
stepIndex: 0,
|
||||
stepId: "scenario_start",
|
||||
action: "goto_start",
|
||||
status: "completed",
|
||||
elapsedMs,
|
||||
beforeUrl: session.initialUrl,
|
||||
afterUrl: session.currentUrl(),
|
||||
error: null,
|
||||
eventStart: 1,
|
||||
eventEnd: session.lastEventSequence(),
|
||||
artifacts,
|
||||
});
|
||||
};
|
||||
|
||||
const executeStep = async (input: {
|
||||
readonly session: BrowserScenarioSessionPort;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
readonly action: BrowserScenarioAction;
|
||||
readonly stepIndex: number;
|
||||
readonly startedAt: number;
|
||||
readonly priorFailure: boolean;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}): Promise<BrowserScenarioStep> => {
|
||||
const {
|
||||
session,
|
||||
scenario,
|
||||
budget,
|
||||
action,
|
||||
stepIndex,
|
||||
startedAt,
|
||||
priorFailure,
|
||||
signal,
|
||||
} = input;
|
||||
const beforeUrl = session.currentUrl();
|
||||
const eventStart = session.nextEventSequence();
|
||||
const requested = requestedForStep(scenario, stepIndex);
|
||||
if (priorFailure)
|
||||
return createStep({
|
||||
stepIndex,
|
||||
stepId: action.step_id,
|
||||
action: action.action,
|
||||
status: "cancelled",
|
||||
elapsedMs: 0,
|
||||
beforeUrl,
|
||||
afterUrl: beforeUrl,
|
||||
error: "not executed after an earlier action failure",
|
||||
eventStart,
|
||||
eventEnd: session.lastEventSequence(),
|
||||
artifacts: unavailableArtifacts(
|
||||
requested,
|
||||
"action was not executed after an earlier failure",
|
||||
),
|
||||
});
|
||||
|
||||
const actionStartedAt = Date.now();
|
||||
session.setStep(stepIndex);
|
||||
const requestCancelled = (): boolean => signal?.aborted === true;
|
||||
let status: BrowserScenarioStep["status"] = "completed";
|
||||
let error: string | null = null;
|
||||
if (requestCancelled()) {
|
||||
status = "cancelled";
|
||||
error = "scenario request cancelled";
|
||||
} else {
|
||||
try {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const remaining = scenario.limits.max_duration_ms - elapsed;
|
||||
if (remaining <= 0) throw new Error("Scenario duration limit reached");
|
||||
await session.perform(action, remaining, signal);
|
||||
} catch (cause) {
|
||||
status = requestCancelled() ? "cancelled" : "failed";
|
||||
error = session.redactError(cause);
|
||||
}
|
||||
}
|
||||
const remaining = scenario.limits.max_duration_ms - (Date.now() - startedAt);
|
||||
const artifacts =
|
||||
status === "cancelled" || remaining <= 0
|
||||
? unavailableArtifacts(
|
||||
requested,
|
||||
status === "cancelled"
|
||||
? "scenario request cancelled"
|
||||
: "scenario duration limit reached",
|
||||
)
|
||||
: await session.capture(requested, budget, remaining, signal);
|
||||
return createStep({
|
||||
stepIndex,
|
||||
stepId: action.step_id,
|
||||
action: action.action,
|
||||
status,
|
||||
elapsedMs: Date.now() - actionStartedAt,
|
||||
beforeUrl,
|
||||
afterUrl: session.currentUrl(),
|
||||
error,
|
||||
eventStart,
|
||||
eventEnd: session.lastEventSequence(),
|
||||
artifacts,
|
||||
});
|
||||
};
|
||||
|
||||
const globalCompleteness = (
|
||||
scenario: BrowserScenario,
|
||||
session: BrowserScenarioSessionPort,
|
||||
steps: readonly BrowserScenarioStep[],
|
||||
): BrowserScenarioCompleteness => {
|
||||
const missing = steps.flatMap(
|
||||
({ completeness }) => completeness.missing_sections,
|
||||
);
|
||||
const truncated = steps.flatMap(
|
||||
({ completeness }) => completeness.truncated_sections,
|
||||
);
|
||||
truncated.push(...session.eventTruncationSections());
|
||||
if (session.mode === "connect") missing.push("events");
|
||||
const status =
|
||||
truncated.length > 0
|
||||
? "truncated"
|
||||
: missing.length > 0
|
||||
? "incomplete"
|
||||
: "complete";
|
||||
return browserScenarioCompletenessSchema.parse({
|
||||
status,
|
||||
equality_eligible: status === "complete",
|
||||
missing_sections: [...new Set(missing)].sort(),
|
||||
truncated_sections: [...new Set(truncated)].sort(),
|
||||
});
|
||||
};
|
||||
|
||||
const runScenario = async (
|
||||
factory: BrowserScenarioSessionFactory,
|
||||
scenario: BrowserScenario,
|
||||
options: ExecutionOptions,
|
||||
): Promise<BrowserScenarioCapture> => {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const startedAt = Date.now();
|
||||
const budget = new BrowserScenarioCaptureBudget(
|
||||
scenario.limits.max_screenshots,
|
||||
scenario.limits.max_total_metadata_bytes,
|
||||
);
|
||||
const session = await factory.open(scenario, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
budget,
|
||||
deadlineAt: startedAt + scenario.limits.max_duration_ms,
|
||||
});
|
||||
const steps: BrowserScenarioStep[] = [];
|
||||
let cleanup: "terminated-owned-process" | "disconnected-external" | undefined;
|
||||
try {
|
||||
steps.push(
|
||||
await initialStep({
|
||||
session,
|
||||
scenario,
|
||||
budget,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
maximumTimeoutMs:
|
||||
scenario.limits.max_duration_ms - (Date.now() - startedAt),
|
||||
signal: options.signal,
|
||||
}),
|
||||
);
|
||||
let failed = false;
|
||||
for (const [offset, action] of scenario.actions.entries()) {
|
||||
const step = await executeStep({
|
||||
session,
|
||||
scenario,
|
||||
budget,
|
||||
action,
|
||||
stepIndex: offset + 1,
|
||||
startedAt,
|
||||
priorFailure: failed,
|
||||
signal: options.signal,
|
||||
});
|
||||
steps.push(step);
|
||||
failed ||= step.status !== "completed";
|
||||
}
|
||||
} finally {
|
||||
cleanup = await session.close();
|
||||
}
|
||||
const events = session.events();
|
||||
const completeness = globalCompleteness(scenario, session, steps);
|
||||
return browserScenarioCaptureSchema.parse({
|
||||
schema_version: 1,
|
||||
browser: {
|
||||
mode: session.mode,
|
||||
process_ownership: session.processOwnership,
|
||||
cleanup,
|
||||
product: session.product,
|
||||
version: session.version,
|
||||
},
|
||||
scenario: {
|
||||
start_origin: new URL(scenario.start_url.url).origin,
|
||||
allowed_origins: scenario.allowed_origins,
|
||||
action_count: scenario.actions.length,
|
||||
secret_references: scenario.secrets.map(({ secret_id: id }) => id).sort(),
|
||||
},
|
||||
duration_ms: Date.now() - startedAt,
|
||||
steps,
|
||||
events,
|
||||
completeness,
|
||||
limitations: [
|
||||
"Event sequence records provider receipt order; simultaneous browser causality is not inferred.",
|
||||
"Response bodies are not retained.",
|
||||
"Storage values are hashed only after declared-secret redaction.",
|
||||
...(session.mode === "connect"
|
||||
? [
|
||||
"CDP attachment cannot recover pre-attach events or guarantee launch-time context options.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** Controlled Playwright/CDP scenario driver with exact process ownership. */
|
||||
export class PlaywrightBrowserScenarioProvider
|
||||
implements BrowserScenarioCapturePort
|
||||
{
|
||||
constructor(
|
||||
private readonly factory: BrowserScenarioSessionFactory = new PlaywrightScenarioSessionFactory(),
|
||||
) {}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
return PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY;
|
||||
}
|
||||
|
||||
async captureScenario(
|
||||
scenario: BrowserScenario,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
||||
try {
|
||||
return ok(await runScenario(this.factory, scenario, options));
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/errors.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
export const withPlaywrightExecutionBoundary = <Value>(
|
||||
work: () => Promise<Value>,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Value> => {
|
||||
if (maximumTimeoutMs <= 0)
|
||||
return Promise.reject(
|
||||
new AnalysisTimeoutError(OPERATION, maximumTimeoutMs),
|
||||
);
|
||||
if (signal?.aborted === true)
|
||||
return Promise.reject(new AnalysisCancelledError(OPERATION));
|
||||
|
||||
return new Promise<Value>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = (): void => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const succeed = (value: Value): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
fail(new AnalysisCancelledError(OPERATION));
|
||||
};
|
||||
timer = setTimeout(
|
||||
() => fail(new AnalysisTimeoutError(OPERATION, maximumTimeoutMs)),
|
||||
maximumTimeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
void work().then(succeed, fail);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Locator, Page } from "playwright-core";
|
||||
|
||||
import type {
|
||||
BrowserScenarioAction,
|
||||
BrowserScenarioValue,
|
||||
} from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
|
||||
type ScenarioLocator = Extract<
|
||||
BrowserScenarioAction,
|
||||
{ readonly locator: unknown }
|
||||
>["locator"];
|
||||
|
||||
const resolveLocator = (page: Page, locator: ScenarioLocator): Locator => {
|
||||
switch (locator.kind) {
|
||||
case "test_id":
|
||||
return page.getByTestId(locator.value);
|
||||
case "role":
|
||||
return page.getByRole(locator.role, {
|
||||
name: locator.name,
|
||||
exact: locator.exact,
|
||||
});
|
||||
case "css":
|
||||
return page.locator(locator.selector);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveValue = (
|
||||
value: BrowserScenarioValue,
|
||||
secrets: BrowserScenarioSecrets,
|
||||
): string => secrets.value(value);
|
||||
|
||||
interface PerformActionInput {
|
||||
readonly page: Page;
|
||||
readonly action: BrowserScenarioAction;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly defaultTimeoutMs: number;
|
||||
readonly maximumTimeoutMs: number;
|
||||
}
|
||||
|
||||
/** Execute one admitted declarative action with no caller-supplied code. */
|
||||
export const performPlaywrightScenarioAction = async (
|
||||
input: PerformActionInput,
|
||||
): Promise<void> => {
|
||||
const { page, action, secrets, defaultTimeoutMs, maximumTimeoutMs } = input;
|
||||
const timeout = "timeout_ms" in action ? action.timeout_ms : undefined;
|
||||
const options = {
|
||||
timeout: Math.min(timeout ?? defaultTimeoutMs, maximumTimeoutMs),
|
||||
};
|
||||
switch (action.action) {
|
||||
case "goto":
|
||||
await page.goto(secrets.url(action.destination), {
|
||||
timeout: options.timeout,
|
||||
waitUntil: action.wait_until,
|
||||
});
|
||||
return;
|
||||
case "click":
|
||||
await resolveLocator(page, action.locator).click({
|
||||
...options,
|
||||
button: action.button,
|
||||
clickCount: action.click_count,
|
||||
});
|
||||
return;
|
||||
case "fill":
|
||||
await resolveLocator(page, action.locator).fill(
|
||||
resolveValue(action.value, secrets),
|
||||
options,
|
||||
);
|
||||
return;
|
||||
case "press":
|
||||
await resolveLocator(page, action.locator).press(action.key, options);
|
||||
return;
|
||||
case "select_option":
|
||||
await resolveLocator(page, action.locator).selectOption(
|
||||
resolveValue(action.value, secrets),
|
||||
options,
|
||||
);
|
||||
return;
|
||||
case "check":
|
||||
await resolveLocator(page, action.locator).check(options);
|
||||
return;
|
||||
case "uncheck":
|
||||
await resolveLocator(page, action.locator).uncheck(options);
|
||||
return;
|
||||
case "wait_for":
|
||||
await resolveLocator(page, action.locator).waitFor({
|
||||
...options,
|
||||
state: action.state,
|
||||
});
|
||||
return;
|
||||
case "wait_for_timeout":
|
||||
if (action.duration_ms > maximumTimeoutMs)
|
||||
throw new Error("Scenario duration limit reached");
|
||||
await page.waitForTimeout(action.duration_ms);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
import type { BrowserContext, Page } from "playwright-core";
|
||||
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import {
|
||||
browserStepArtifactsSchema,
|
||||
type BrowserStepArtifacts,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
|
||||
const TEXT_ARTIFACT_MAX_BYTES = 1_048_576;
|
||||
|
||||
const historyValueSchema = z.strictObject({
|
||||
length: z.number().int().min(0),
|
||||
navigation_entries: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.max(10_000),
|
||||
});
|
||||
|
||||
const storageValueSchema = z.strictObject({
|
||||
local_storage: z.array(z.tuple([z.string(), z.string()])).max(10_000),
|
||||
session_storage: z.array(z.tuple([z.string(), z.string()])).max(10_000),
|
||||
});
|
||||
|
||||
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
||||
|
||||
/** Shared retained-count budget for one scenario's screenshot artifacts. */
|
||||
export class BrowserScenarioCaptureBudget {
|
||||
private screenshotCount = 0;
|
||||
private metadataBytes = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maximumScreenshots: number,
|
||||
private readonly maximumMetadataBytes: number,
|
||||
) {}
|
||||
|
||||
claimScreenshot(): boolean {
|
||||
if (this.screenshotCount >= this.maximumScreenshots) return false;
|
||||
this.screenshotCount += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
claimMetadata(bytes: number): boolean {
|
||||
if (this.metadataBytes + bytes > this.maximumMetadataBytes) return false;
|
||||
this.metadataBytes += bytes;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const notRequested = () => ({ state: "not_requested" as const });
|
||||
const missing = (reason: string) => ({ state: "missing" as const, reason });
|
||||
const truncated = (observed: number, retained: number, reason: string) => ({
|
||||
state: "truncated" as const,
|
||||
observed,
|
||||
retained,
|
||||
reason,
|
||||
});
|
||||
|
||||
const textArtifact = (
|
||||
text: string,
|
||||
maximumBytes: number,
|
||||
budget: BrowserScenarioCaptureBudget,
|
||||
) => {
|
||||
const bytes = Buffer.from(text);
|
||||
if (bytes.byteLength > maximumBytes)
|
||||
return truncated(
|
||||
bytes.byteLength,
|
||||
0,
|
||||
`artifact exceeds ${maximumBytes} bytes`,
|
||||
);
|
||||
if (!budget.claimMetadata(bytes.byteLength))
|
||||
return truncated(
|
||||
bytes.byteLength,
|
||||
0,
|
||||
"scenario metadata byte limit reached",
|
||||
);
|
||||
return {
|
||||
state: "captured" as const,
|
||||
value: {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
text,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const captureScreenshot = async (
|
||||
page: Page,
|
||||
scenario: BrowserScenario,
|
||||
budget: BrowserScenarioCaptureBudget,
|
||||
) => {
|
||||
if (!budget.claimScreenshot())
|
||||
return truncated(1, 0, "scenario screenshot count limit reached");
|
||||
try {
|
||||
const bytes = await page.screenshot({
|
||||
type: "png",
|
||||
fullPage: false,
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
});
|
||||
if (bytes.byteLength > scenario.limits.max_screenshot_bytes)
|
||||
return truncated(bytes.byteLength, 0, "screenshot byte limit reached");
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
return {
|
||||
state: "captured" as const,
|
||||
value: {
|
||||
uri: `rea://web-screenshot/sha256/${sha256}`,
|
||||
sha256,
|
||||
bytes: bytes.byteLength,
|
||||
media_type: "image/png" as const,
|
||||
data_base64: bytes.toString("base64"),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return missing("screenshot capture failed");
|
||||
}
|
||||
};
|
||||
|
||||
const captureDom = async (input: {
|
||||
readonly page: Page;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly maximumBytes: number;
|
||||
readonly maximumNodes: number;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
}) => {
|
||||
const { page, secrets, maximumBytes, maximumNodes, budget } = input;
|
||||
try {
|
||||
const nodes = await page.locator("*").count();
|
||||
if (nodes > maximumNodes)
|
||||
return truncated(nodes, 0, "DOM node limit reached");
|
||||
return textArtifact(
|
||||
secrets.redact(await page.content()),
|
||||
Math.min(maximumBytes, TEXT_ARTIFACT_MAX_BYTES),
|
||||
budget,
|
||||
);
|
||||
} catch {
|
||||
return missing("DOM capture failed");
|
||||
}
|
||||
};
|
||||
|
||||
const captureAccessibility = async (input: {
|
||||
readonly page: Page;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly maximumBytes: number;
|
||||
readonly maximumNodes: number;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
}) => {
|
||||
const { page, secrets, maximumBytes, maximumNodes, budget } = input;
|
||||
try {
|
||||
const text = await page.locator("html").ariaSnapshot();
|
||||
const nodes = text.split("\n").filter((line) => line.trim() !== "").length;
|
||||
if (nodes > maximumNodes)
|
||||
return truncated(nodes, 0, "accessibility node limit reached");
|
||||
return textArtifact(
|
||||
secrets.redact(text),
|
||||
Math.min(maximumBytes, TEXT_ARTIFACT_MAX_BYTES),
|
||||
budget,
|
||||
);
|
||||
} catch {
|
||||
return missing("accessibility capture failed");
|
||||
}
|
||||
};
|
||||
|
||||
const navigationType = (
|
||||
value: string,
|
||||
): "navigate" | "reload" | "back_forward" | "prerender" | "unknown" => {
|
||||
switch (value) {
|
||||
case "navigate":
|
||||
case "reload":
|
||||
case "back_forward":
|
||||
case "prerender":
|
||||
return value;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const captureHistory = async (
|
||||
page: Page,
|
||||
budget: BrowserScenarioCaptureBudget,
|
||||
) => {
|
||||
try {
|
||||
const raw = historyValueSchema.parse(
|
||||
await page.evaluate(`(() => ({
|
||||
length: window.history.length,
|
||||
navigation_entries: performance.getEntriesByType("navigation").map(
|
||||
entry => ({
|
||||
type: typeof entry.type === "string" ? entry.type : "unknown",
|
||||
name: entry.name
|
||||
})
|
||||
)
|
||||
}))()`),
|
||||
);
|
||||
if (raw.navigation_entries.length > 256)
|
||||
return truncated(
|
||||
raw.navigation_entries.length,
|
||||
0,
|
||||
"navigation history entry limit reached",
|
||||
);
|
||||
const value = {
|
||||
length: raw.length,
|
||||
current_url: sanitizeBrowserUrl(page.url()),
|
||||
navigation_entries: raw.navigation_entries.map(({ type, name }) => ({
|
||||
type: navigationType(type),
|
||||
name: sanitizeBrowserUrl(name),
|
||||
})),
|
||||
};
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value));
|
||||
if (!budget.claimMetadata(bytes))
|
||||
return truncated(bytes, 0, "scenario metadata byte limit reached");
|
||||
return {
|
||||
state: "captured" as const,
|
||||
value,
|
||||
};
|
||||
} catch {
|
||||
return missing("history capture failed");
|
||||
}
|
||||
};
|
||||
|
||||
const captureStorage = async (input: {
|
||||
readonly context: BrowserContext;
|
||||
readonly page: Page;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
}) => {
|
||||
const { context, page, scenario, secrets, budget } = input;
|
||||
try {
|
||||
const cookies = await context.cookies(scenario.allowed_origins);
|
||||
const pageStorage = storageValueSchema.parse(
|
||||
await page.evaluate(`(() => ({
|
||||
local_storage: Object.entries(window.localStorage),
|
||||
session_storage: Object.entries(window.sessionStorage)
|
||||
}))()`),
|
||||
);
|
||||
const observed =
|
||||
cookies.length +
|
||||
pageStorage.local_storage.length +
|
||||
pageStorage.session_storage.length;
|
||||
if (observed > scenario.limits.max_storage_entries)
|
||||
return truncated(observed, 0, "storage entry limit reached");
|
||||
const value = {
|
||||
cookies: cookies.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
http_only: cookie.httpOnly,
|
||||
same_site: cookie.sameSite,
|
||||
...secrets.fingerprint(cookie.value),
|
||||
})),
|
||||
local_storage: pageStorage.local_storage.map(([name, value]) => ({
|
||||
name,
|
||||
...secrets.fingerprint(value),
|
||||
})),
|
||||
session_storage: pageStorage.session_storage.map(([name, value]) => ({
|
||||
name,
|
||||
...secrets.fingerprint(value),
|
||||
})),
|
||||
};
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value));
|
||||
if (!budget.claimMetadata(bytes))
|
||||
return truncated(bytes, 0, "scenario metadata byte limit reached");
|
||||
return {
|
||||
state: "captured" as const,
|
||||
value,
|
||||
};
|
||||
} catch {
|
||||
return missing("storage capture failed");
|
||||
}
|
||||
};
|
||||
|
||||
const isAllowedPage = (
|
||||
page: Page,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(page.url()).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Capture selected step artifacts without retaining data after scope loss. */
|
||||
export const capturePlaywrightStepArtifacts = async (input: {
|
||||
readonly context: BrowserContext;
|
||||
readonly page: Page;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly requested: ReadonlySet<SnapshotKind>;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
}): Promise<BrowserStepArtifacts> => {
|
||||
const { context, page, scenario, secrets, requested, budget } = input;
|
||||
const allowed = isAllowedPage(page, new Set(scenario.allowed_origins));
|
||||
const denied = () => missing("current page is outside approved origins");
|
||||
const state = <Value>(
|
||||
kind: SnapshotKind,
|
||||
capture: () => Promise<Value>,
|
||||
): Promise<
|
||||
Value | ReturnType<typeof notRequested> | ReturnType<typeof missing>
|
||||
> =>
|
||||
!requested.has(kind)
|
||||
? Promise.resolve(notRequested())
|
||||
: allowed || kind === "url"
|
||||
? capture()
|
||||
: Promise.resolve(denied());
|
||||
return browserStepArtifactsSchema.parse({
|
||||
screenshot: await state("screenshot", () =>
|
||||
captureScreenshot(page, scenario, budget),
|
||||
),
|
||||
dom: await state("dom", () =>
|
||||
captureDom({
|
||||
page,
|
||||
secrets,
|
||||
maximumBytes: scenario.limits.max_total_metadata_bytes,
|
||||
maximumNodes: scenario.limits.max_dom_nodes,
|
||||
budget,
|
||||
}),
|
||||
),
|
||||
accessibility: await state("accessibility", () =>
|
||||
captureAccessibility({
|
||||
page,
|
||||
secrets,
|
||||
maximumBytes: scenario.limits.max_total_metadata_bytes,
|
||||
maximumNodes: scenario.limits.max_accessibility_nodes,
|
||||
budget,
|
||||
}),
|
||||
),
|
||||
url: await state("url", async () => {
|
||||
const value = sanitizeBrowserUrl(page.url());
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value));
|
||||
return budget.claimMetadata(bytes)
|
||||
? { state: "captured" as const, value }
|
||||
: truncated(bytes, 0, "scenario metadata byte limit reached");
|
||||
}),
|
||||
history: await state("history", () => captureHistory(page, budget)),
|
||||
storage: await state("storage", () =>
|
||||
captureStorage({ context, page, scenario, secrets, budget }),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from "playwright-core";
|
||||
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
export interface OpenedScenarioBrowser {
|
||||
readonly context: BrowserContext;
|
||||
readonly page: Page;
|
||||
readonly browser: Browser;
|
||||
readonly profilePath: string | undefined;
|
||||
}
|
||||
|
||||
const allowedBrowserEnvironment = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Record<string, string | undefined> => {
|
||||
const allowed = [
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"DISPLAY",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LD_LIBRARY_PATH",
|
||||
"PATH",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
];
|
||||
return Object.fromEntries(
|
||||
allowed
|
||||
.map((name) => [name, environment[name]] as const)
|
||||
.filter(
|
||||
(entry): entry is readonly [string, string] => entry[1] !== undefined,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const originAllowed = (
|
||||
value: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(value).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const findConnectedPage = async (
|
||||
browser: Browser,
|
||||
targetId: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): Promise<{ readonly context: BrowserContext; readonly page: Page }> => {
|
||||
for (const context of browser.contexts())
|
||||
for (const page of context.pages()) {
|
||||
const session = await context.newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await session.send("Target.getTargetInfo");
|
||||
if (targetInfo.targetId !== targetId) continue;
|
||||
if (
|
||||
page.url() !== "about:blank" &&
|
||||
!originAllowed(page.url(), allowedOrigins)
|
||||
)
|
||||
throw new BrowserObservationError(OPERATION, "target_not_allowed");
|
||||
return { context, page };
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
throw new BrowserObservationError(OPERATION, "target_not_found");
|
||||
};
|
||||
|
||||
const configureAttachedEnvironment = async (
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
scenario: BrowserScenario,
|
||||
): Promise<void> => {
|
||||
await page.setViewportSize({
|
||||
width: scenario.environment.viewport.width,
|
||||
height: scenario.environment.viewport.height,
|
||||
});
|
||||
await page.emulateMedia({
|
||||
colorScheme: scenario.environment.color_scheme,
|
||||
reducedMotion: scenario.environment.reduced_motion,
|
||||
});
|
||||
const session = await context.newCDPSession(page);
|
||||
try {
|
||||
await session.send("Emulation.setLocaleOverride", {
|
||||
locale: scenario.environment.locale,
|
||||
});
|
||||
await session.send("Emulation.setTimezoneOverride", {
|
||||
timezoneId: scenario.environment.timezone,
|
||||
});
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
};
|
||||
|
||||
export const openPlaywrightScenarioBrowser = async (
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
maximumTimeoutMs: number,
|
||||
): Promise<OpenedScenarioBrowser> => {
|
||||
if (scenario.browser.mode === "connect") {
|
||||
const browser = await chromium.connectOverCDP(
|
||||
scenario.browser.cdp_endpoint,
|
||||
{
|
||||
timeout: Math.min(
|
||||
scenario.limits.navigation_timeout_ms,
|
||||
maximumTimeoutMs,
|
||||
),
|
||||
},
|
||||
);
|
||||
try {
|
||||
const target = await findConnectedPage(
|
||||
browser,
|
||||
scenario.browser.target_id,
|
||||
new Set(scenario.allowed_origins),
|
||||
);
|
||||
await configureAttachedEnvironment(target.context, target.page, scenario);
|
||||
return { ...target, browser, profilePath: undefined };
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
|
||||
await chmod(profilePath, 0o700);
|
||||
try {
|
||||
const context = await chromium.launchPersistentContext(profilePath, {
|
||||
executablePath: scenario.browser.executable_path,
|
||||
headless: true,
|
||||
acceptDownloads: false,
|
||||
viewport: {
|
||||
width: scenario.environment.viewport.width,
|
||||
height: scenario.environment.viewport.height,
|
||||
},
|
||||
deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
|
||||
locale: scenario.environment.locale,
|
||||
timezoneId: scenario.environment.timezone,
|
||||
colorScheme: scenario.environment.color_scheme,
|
||||
reducedMotion: scenario.environment.reduced_motion,
|
||||
serviceWorkers: "block",
|
||||
env: allowedBrowserEnvironment(environment),
|
||||
handleSIGHUP: false,
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
timeout: maximumTimeoutMs,
|
||||
});
|
||||
const browser = context.browser();
|
||||
if (browser === null) {
|
||||
await context.close();
|
||||
throw new BrowserObservationError(OPERATION, "protocol_error");
|
||||
}
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
return { context, page, browser, profilePath };
|
||||
} catch (error) {
|
||||
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const closePlaywrightScenarioBrowser = async ({
|
||||
context,
|
||||
browser,
|
||||
profilePath,
|
||||
}: OpenedScenarioBrowser): Promise<void> => {
|
||||
try {
|
||||
if (profilePath === undefined) await browser.close();
|
||||
else await context.close();
|
||||
} finally {
|
||||
if (profilePath !== undefined)
|
||||
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
ConsoleMessage,
|
||||
Download,
|
||||
Frame,
|
||||
Page,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
Worker,
|
||||
} from "playwright-core";
|
||||
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
browserScenarioEventSchema,
|
||||
type BrowserScenarioEvent,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
import type { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
|
||||
type EventName =
|
||||
| "console"
|
||||
| "page-errors"
|
||||
| "network"
|
||||
| "websockets"
|
||||
| "frames"
|
||||
| "workers"
|
||||
| "popups"
|
||||
| "downloads";
|
||||
|
||||
type UnindexedEvent = BrowserScenarioEvent extends infer Event
|
||||
? Event extends BrowserScenarioEvent
|
||||
? Omit<Event, "sequence" | "step_index">
|
||||
: never
|
||||
: never;
|
||||
|
||||
type LimitedSection = "frames" | "workers" | "popups" | "websockets";
|
||||
type TruncatedSection = "events" | LimitedSection;
|
||||
|
||||
interface EventCaptureOptions {
|
||||
readonly page: Page;
|
||||
readonly enabled: ReadonlySet<EventName>;
|
||||
readonly limits: Pick<
|
||||
BrowserScenario["limits"],
|
||||
| "max_events"
|
||||
| "max_frames"
|
||||
| "max_workers"
|
||||
| "max_popups"
|
||||
| "max_websockets"
|
||||
>;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
}
|
||||
|
||||
/** Bounded, arrival-ordered Playwright event capture with current-step attribution. */
|
||||
export class PlaywrightScenarioEvents {
|
||||
private readonly items: BrowserScenarioEvent[] = [];
|
||||
private readonly truncated = new Set<TruncatedSection>();
|
||||
private readonly admitted = {
|
||||
frames: new WeakSet<object>(),
|
||||
workers: new WeakSet<object>(),
|
||||
popups: new WeakSet<object>(),
|
||||
websockets: new WeakSet<object>(),
|
||||
};
|
||||
private readonly rejected = {
|
||||
frames: new WeakSet<object>(),
|
||||
workers: new WeakSet<object>(),
|
||||
popups: new WeakSet<object>(),
|
||||
websockets: new WeakSet<object>(),
|
||||
};
|
||||
private readonly counts: Record<LimitedSection, number> = {
|
||||
frames: 0,
|
||||
workers: 0,
|
||||
popups: 0,
|
||||
websockets: 0,
|
||||
};
|
||||
private dropped = 0;
|
||||
private stepIndex = 0;
|
||||
private sequence = 0;
|
||||
private readonly enabled: ReadonlySet<EventName>;
|
||||
private readonly limits: EventCaptureOptions["limits"];
|
||||
private readonly secrets: BrowserScenarioSecrets;
|
||||
private readonly budget: BrowserScenarioCaptureBudget;
|
||||
|
||||
constructor(options: EventCaptureOptions) {
|
||||
this.enabled = options.enabled;
|
||||
this.limits = options.limits;
|
||||
this.secrets = options.secrets;
|
||||
this.budget = options.budget;
|
||||
this.observePage(options.page);
|
||||
}
|
||||
|
||||
setStep(index: number): void {
|
||||
this.stepIndex = index;
|
||||
}
|
||||
|
||||
nextSequence(): number {
|
||||
return this.sequence + 1;
|
||||
}
|
||||
|
||||
lastSequence(): number {
|
||||
return this.sequence;
|
||||
}
|
||||
|
||||
result(): {
|
||||
readonly retained: number;
|
||||
readonly dropped: number;
|
||||
readonly items: readonly BrowserScenarioEvent[];
|
||||
} {
|
||||
return {
|
||||
retained: this.items.length,
|
||||
dropped: this.dropped,
|
||||
items: this.items,
|
||||
};
|
||||
}
|
||||
|
||||
truncationSections(): readonly TruncatedSection[] {
|
||||
return [...this.truncated].sort();
|
||||
}
|
||||
|
||||
private push(event: UnindexedEvent): void {
|
||||
this.sequence += 1;
|
||||
if (this.items.length >= this.limits.max_events) {
|
||||
this.dropped += 1;
|
||||
this.truncated.add("events");
|
||||
return;
|
||||
}
|
||||
const parsed = browserScenarioEventSchema.parse({
|
||||
...event,
|
||||
sequence: this.sequence,
|
||||
step_index: this.stepIndex,
|
||||
});
|
||||
if (!this.budget.claimMetadata(Buffer.byteLength(JSON.stringify(parsed)))) {
|
||||
this.dropped += 1;
|
||||
this.truncated.add("events");
|
||||
return;
|
||||
}
|
||||
this.items.push(parsed);
|
||||
}
|
||||
|
||||
private admit(
|
||||
section: LimitedSection,
|
||||
value: object,
|
||||
maximum: number,
|
||||
): boolean {
|
||||
if (this.admitted[section].has(value)) return true;
|
||||
if (this.rejected[section].has(value)) return false;
|
||||
if (this.counts[section] >= maximum) {
|
||||
this.rejected[section].add(value);
|
||||
this.sequence += 1;
|
||||
this.dropped += 1;
|
||||
this.truncated.add("events");
|
||||
this.truncated.add(section);
|
||||
return false;
|
||||
}
|
||||
this.admitted[section].add(value);
|
||||
this.counts[section] += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private observePage(page: Page): void {
|
||||
if (this.enabled.has("console"))
|
||||
page.on("console", (message) => this.console(message));
|
||||
if (this.enabled.has("page-errors"))
|
||||
page.on("pageerror", (error) =>
|
||||
this.push({
|
||||
kind: "page-error",
|
||||
message: this.secrets.redact(error.message),
|
||||
stack:
|
||||
error.stack === undefined ? null : this.secrets.redact(error.stack),
|
||||
}),
|
||||
);
|
||||
if (this.enabled.has("network")) {
|
||||
page.on("request", (request) => this.request("request", request));
|
||||
page.on("response", (response) => this.response(response));
|
||||
page.on("requestfailed", (request) =>
|
||||
this.request("request-failed", request),
|
||||
);
|
||||
}
|
||||
if (this.enabled.has("websockets"))
|
||||
page.on("websocket", (socket) => this.webSocket(socket));
|
||||
if (this.enabled.has("frames")) {
|
||||
page.on("frameattached", (frame) => this.frame("frame-attached", frame));
|
||||
page.on("framedetached", (frame) => this.frame("frame-detached", frame));
|
||||
page.on("framenavigated", (frame) =>
|
||||
this.frame("frame-navigated", frame),
|
||||
);
|
||||
}
|
||||
if (this.enabled.has("workers"))
|
||||
page.on("worker", (worker) => this.worker(worker));
|
||||
if (this.enabled.has("popups"))
|
||||
page.on("popup", (popup) => this.popup(popup));
|
||||
if (this.enabled.has("downloads"))
|
||||
page.on("download", (download) => this.download(download));
|
||||
}
|
||||
|
||||
private console(message: ConsoleMessage): void {
|
||||
const location = message.location();
|
||||
this.push({
|
||||
kind: "console",
|
||||
level: message.type(),
|
||||
text: this.secrets.redact(message.text()).slice(0, 65_536),
|
||||
url: location.url === "" ? null : sanitizeBrowserUrl(location.url),
|
||||
});
|
||||
}
|
||||
|
||||
private request(kind: "request" | "request-failed", request: Request): void {
|
||||
this.push({
|
||||
kind,
|
||||
method: request.method(),
|
||||
url: sanitizeBrowserUrl(request.url()),
|
||||
resource_type: request.resourceType(),
|
||||
status: null,
|
||||
header_names: Object.keys(request.headers()).sort().slice(0, 256),
|
||||
failure:
|
||||
kind === "request-failed"
|
||||
? (request.failure()?.errorText ?? "unknown")
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
private response(response: Response): void {
|
||||
const request = response.request();
|
||||
this.push({
|
||||
kind: "response",
|
||||
method: request.method(),
|
||||
url: sanitizeBrowserUrl(response.url()),
|
||||
resource_type: request.resourceType(),
|
||||
status: response.status(),
|
||||
header_names: Object.keys(response.headers()).sort().slice(0, 256),
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
|
||||
private webSocket(socket: WebSocket): void {
|
||||
if (!this.admit("websockets", socket, this.limits.max_websockets)) return;
|
||||
const url = sanitizeBrowserUrl(socket.url());
|
||||
this.push({ kind: "websocket-opened", url });
|
||||
socket.on("framesent", ({ payload }) =>
|
||||
this.webSocketFrame("websocket-frame-sent", url, payload),
|
||||
);
|
||||
socket.on("framereceived", ({ payload }) =>
|
||||
this.webSocketFrame("websocket-frame-received", url, payload),
|
||||
);
|
||||
socket.on("close", () => this.push({ kind: "websocket-closed", url }));
|
||||
}
|
||||
|
||||
private webSocketFrame(
|
||||
kind: "websocket-frame-sent" | "websocket-frame-received",
|
||||
url: ReturnType<typeof sanitizeBrowserUrl>,
|
||||
payload: string | Buffer,
|
||||
): void {
|
||||
const bytes = Buffer.byteLength(payload);
|
||||
const isText = typeof payload === "string";
|
||||
const text = isText ? this.secrets.redact(payload).slice(0, 65_536) : null;
|
||||
this.push({
|
||||
kind,
|
||||
url,
|
||||
payload_type: isText ? "text" : "binary",
|
||||
payload_bytes: bytes,
|
||||
payload_text: text,
|
||||
truncated: isText && text !== null && text.length < payload.length,
|
||||
});
|
||||
}
|
||||
|
||||
private frame(
|
||||
kind: "frame-attached" | "frame-detached" | "frame-navigated",
|
||||
frame: Frame,
|
||||
): void {
|
||||
if (!this.admit("frames", frame, this.limits.max_frames)) return;
|
||||
this.push({
|
||||
kind,
|
||||
url: frame.url() === "" ? null : sanitizeBrowserUrl(frame.url()),
|
||||
name: frame.name() || null,
|
||||
});
|
||||
}
|
||||
|
||||
private worker(worker: Worker): void {
|
||||
if (!this.admit("workers", worker, this.limits.max_workers)) return;
|
||||
const details = {
|
||||
url: sanitizeBrowserUrl(worker.url()),
|
||||
name: null,
|
||||
};
|
||||
this.push({ kind: "worker-created", ...details });
|
||||
worker.on("close", () => this.push({ kind: "worker-closed", ...details }));
|
||||
}
|
||||
|
||||
private popup(page: Page): void {
|
||||
if (!this.admit("popups", page, this.limits.max_popups)) return;
|
||||
const opened = {
|
||||
url: page.url() === "" ? null : sanitizeBrowserUrl(page.url()),
|
||||
name: null,
|
||||
};
|
||||
this.push({ kind: "popup-opened", ...opened });
|
||||
page.on("close", () =>
|
||||
this.push({
|
||||
kind: "popup-closed",
|
||||
url: page.url() === "" ? null : sanitizeBrowserUrl(page.url()),
|
||||
name: null,
|
||||
}),
|
||||
);
|
||||
this.observePage(page);
|
||||
}
|
||||
|
||||
private download(download: Download): void {
|
||||
this.push({
|
||||
kind: "download-cancelled",
|
||||
suggested_filename: download.suggestedFilename().slice(0, 1_024),
|
||||
url: sanitizeBrowserUrl(download.url()),
|
||||
});
|
||||
void download.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
type Route,
|
||||
type WebSocketRoute,
|
||||
} from "playwright-core";
|
||||
|
||||
import type {
|
||||
BrowserScenario,
|
||||
BrowserScenarioAction,
|
||||
} from "../domain/browserScenario.js";
|
||||
import { BrowserObservationError } from "../domain/errors.js";
|
||||
import type {
|
||||
BrowserScenarioSessionFactory,
|
||||
BrowserScenarioSessionPort,
|
||||
} from "./BrowserScenarioSessionPort.js";
|
||||
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||
import {
|
||||
closePlaywrightScenarioBrowser,
|
||||
openPlaywrightScenarioBrowser,
|
||||
type OpenedScenarioBrowser,
|
||||
} from "./PlaywrightScenarioBrowser.js";
|
||||
import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js";
|
||||
import {
|
||||
BrowserScenarioCaptureBudget,
|
||||
capturePlaywrightStepArtifacts,
|
||||
} from "./PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
|
||||
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
|
||||
const originAllowed = (
|
||||
value: string,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(value).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const installStorageSeeds = async (
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
scenario: BrowserScenario,
|
||||
secrets: BrowserScenarioSecrets,
|
||||
): Promise<void> => {
|
||||
await context.addCookies(
|
||||
scenario.storage.cookies.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: secrets.value(cookie.value),
|
||||
url: secrets.url(cookie.destination),
|
||||
httpOnly: cookie.http_only,
|
||||
secure: cookie.secure,
|
||||
sameSite: cookie.same_site,
|
||||
})),
|
||||
);
|
||||
const storage = {
|
||||
local: Object.fromEntries(
|
||||
scenario.storage.local_storage.map(({ origin, entries }) => [
|
||||
origin,
|
||||
entries.map(({ name, value }) => [name, secrets.value(value)]),
|
||||
]),
|
||||
),
|
||||
session: Object.fromEntries(
|
||||
scenario.storage.session_storage.map(({ origin, entries }) => [
|
||||
origin,
|
||||
entries.map(({ name, value }) => [name, secrets.value(value)]),
|
||||
]),
|
||||
),
|
||||
};
|
||||
const payload = JSON.stringify(storage).replaceAll("<", "\\u003c");
|
||||
await page.addInitScript(`(() => {
|
||||
const seeds = ${payload};
|
||||
const local = seeds.local[window.location.origin] ?? [];
|
||||
const session = seeds.session[window.location.origin] ?? [];
|
||||
for (const [name, value] of local) window.localStorage.setItem(name, value);
|
||||
for (const [name, value] of session)
|
||||
window.sessionStorage.setItem(name, value);
|
||||
})()`);
|
||||
};
|
||||
|
||||
const installHttpPolicy = async (input: {
|
||||
readonly context: BrowserContext;
|
||||
readonly page: Page;
|
||||
readonly scenario: BrowserScenario;
|
||||
readonly secrets: BrowserScenarioSecrets;
|
||||
readonly ownedPages: Set<Page>;
|
||||
}): Promise<void> => {
|
||||
const { context, page, scenario, secrets, ownedPages } = input;
|
||||
const routes =
|
||||
scenario.request_replay.mode === "exact"
|
||||
? new Map(
|
||||
scenario.request_replay.routes.map((route) => [
|
||||
`${route.method} ${secrets.url(route.request)}`,
|
||||
route,
|
||||
]),
|
||||
)
|
||||
: new Map();
|
||||
await context.route("**/*", async (route, request) => {
|
||||
let requestPage: Page | undefined;
|
||||
try {
|
||||
requestPage = request.frame().page();
|
||||
} catch {
|
||||
requestPage = undefined;
|
||||
}
|
||||
if (
|
||||
scenario.browser.mode === "connect" &&
|
||||
(requestPage === undefined || !ownedPages.has(requestPage))
|
||||
) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
if (!originAllowed(request.url(), new Set(scenario.allowed_origins))) {
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
const replay = routes.get(`${request.method()} ${request.url()}`);
|
||||
if (replay !== undefined) {
|
||||
await fulfillReplayRoute(route, replay.response, secrets);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
scenario.request_replay.mode === "exact" &&
|
||||
scenario.request_replay.unmatched === "abort"
|
||||
) {
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
page.on("popup", (popup) => ownedPages.add(popup));
|
||||
};
|
||||
|
||||
type ReplayResponse = Extract<
|
||||
BrowserScenario["request_replay"],
|
||||
{ readonly mode: "exact" }
|
||||
>["routes"][number]["response"];
|
||||
|
||||
const fulfillReplayRoute = async (
|
||||
route: Route,
|
||||
response: ReplayResponse,
|
||||
secrets: BrowserScenarioSecrets,
|
||||
): Promise<void> => {
|
||||
if (response.kind === "redirect") {
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
headers: { location: secrets.url(response.destination) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(
|
||||
response.headers.map(({ name, value }) => [name, secrets.value(value)]),
|
||||
),
|
||||
...(response.body === undefined
|
||||
? {}
|
||||
: { body: secrets.value(response.body) }),
|
||||
});
|
||||
};
|
||||
|
||||
const installWebSocketPolicy = async (
|
||||
page: Page,
|
||||
scenario: BrowserScenario,
|
||||
): Promise<void> => {
|
||||
const allowed = new Set(scenario.allowed_origins);
|
||||
await page.routeWebSocket("**/*", async (route: WebSocketRoute) => {
|
||||
const url = new URL(route.url());
|
||||
const httpOrigin = `${url.protocol === "wss:" ? "https:" : "http:"}//${url.host}`;
|
||||
if (
|
||||
!allowed.has(httpOrigin) ||
|
||||
(scenario.request_replay.mode === "exact" &&
|
||||
scenario.request_replay.unmatched === "abort")
|
||||
) {
|
||||
await route.close({ code: 1008, reason: "outside scenario policy" });
|
||||
return;
|
||||
}
|
||||
route.connectToServer();
|
||||
});
|
||||
};
|
||||
|
||||
const initializePage = async (
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
scenario: BrowserScenario,
|
||||
secrets: BrowserScenarioSecrets,
|
||||
): Promise<Set<Page>> => {
|
||||
context.setDefaultTimeout(scenario.limits.action_timeout_ms);
|
||||
context.setDefaultNavigationTimeout(scenario.limits.navigation_timeout_ms);
|
||||
const ownedPages = new Set([page]);
|
||||
await installHttpPolicy({ context, page, scenario, secrets, ownedPages });
|
||||
await installWebSocketPolicy(page, scenario);
|
||||
await installStorageSeeds(context, page, scenario, secrets);
|
||||
return ownedPages;
|
||||
};
|
||||
|
||||
export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
||||
readonly mode: "launch" | "connect";
|
||||
readonly processOwnership: "provider-owned" | "external";
|
||||
readonly product = "Chromium";
|
||||
readonly version: string;
|
||||
readonly initialUrl: string;
|
||||
private closed = false;
|
||||
|
||||
private constructor(
|
||||
private readonly opened: OpenedScenarioBrowser,
|
||||
private readonly scenario: BrowserScenario,
|
||||
private readonly secrets: BrowserScenarioSecrets,
|
||||
private readonly eventCapture: PlaywrightScenarioEvents,
|
||||
) {
|
||||
this.mode = scenario.browser.mode;
|
||||
this.processOwnership =
|
||||
scenario.browser.mode === "launch" ? "provider-owned" : "external";
|
||||
this.version = opened.browser.version();
|
||||
this.initialUrl = opened.page.url();
|
||||
}
|
||||
|
||||
static async open(
|
||||
scenario: BrowserScenario,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly budget: BrowserScenarioCaptureBudget;
|
||||
readonly deadlineAt: number;
|
||||
},
|
||||
): Promise<PlaywrightScenarioSession> {
|
||||
const remaining = (): number => options.deadlineAt - Date.now();
|
||||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
|
||||
if (secrets === undefined)
|
||||
throw new BrowserObservationError(OPERATION, "secret_unavailable");
|
||||
const opening = openPlaywrightScenarioBrowser(
|
||||
scenario,
|
||||
environment,
|
||||
remaining(),
|
||||
);
|
||||
let opened: OpenedScenarioBrowser;
|
||||
try {
|
||||
opened = await withPlaywrightExecutionBoundary(
|
||||
() => opening,
|
||||
remaining(),
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
void opening
|
||||
.then((lateOpened) => closePlaywrightScenarioBrowser(lateOpened))
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() => initializePage(opened.context, opened.page, scenario, secrets),
|
||||
remaining(),
|
||||
options.signal,
|
||||
);
|
||||
const events = new PlaywrightScenarioEvents({
|
||||
page: opened.page,
|
||||
enabled: new Set(scenario.capture.events),
|
||||
limits: scenario.limits,
|
||||
secrets,
|
||||
budget: options.budget,
|
||||
});
|
||||
const session = new PlaywrightScenarioSession(
|
||||
opened,
|
||||
scenario,
|
||||
secrets,
|
||||
events,
|
||||
);
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() =>
|
||||
opened.page.goto(secrets.url(scenario.start_url), {
|
||||
waitUntil: "load",
|
||||
timeout: Math.min(
|
||||
scenario.limits.navigation_timeout_ms,
|
||||
remaining(),
|
||||
),
|
||||
}),
|
||||
remaining(),
|
||||
options.signal,
|
||||
);
|
||||
return session;
|
||||
} catch (error) {
|
||||
await closePlaywrightScenarioBrowser(opened);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
currentUrl(): string {
|
||||
return this.opened.page.url();
|
||||
}
|
||||
|
||||
setStep(index: number): void {
|
||||
this.eventCapture.setStep(index);
|
||||
}
|
||||
|
||||
nextEventSequence(): number {
|
||||
return this.eventCapture.nextSequence();
|
||||
}
|
||||
|
||||
lastEventSequence(): number {
|
||||
return this.eventCapture.lastSequence();
|
||||
}
|
||||
|
||||
events() {
|
||||
return this.eventCapture.result();
|
||||
}
|
||||
|
||||
eventTruncationSections() {
|
||||
return this.eventCapture.truncationSections();
|
||||
}
|
||||
|
||||
async perform(
|
||||
action: BrowserScenarioAction,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await withPlaywrightExecutionBoundary(
|
||||
() =>
|
||||
performPlaywrightScenarioAction({
|
||||
page: this.opened.page,
|
||||
action,
|
||||
secrets: this.secrets,
|
||||
defaultTimeoutMs: this.scenario.limits.action_timeout_ms,
|
||||
maximumTimeoutMs,
|
||||
}),
|
||||
maximumTimeoutMs,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async capture(
|
||||
requested: ReadonlySet<
|
||||
BrowserScenario["capture"]["after_each_step"][number]
|
||||
>,
|
||||
budget: BrowserScenarioCaptureBudget,
|
||||
maximumTimeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return withPlaywrightExecutionBoundary(
|
||||
() =>
|
||||
capturePlaywrightStepArtifacts({
|
||||
context: this.opened.context,
|
||||
page: this.opened.page,
|
||||
scenario: this.scenario,
|
||||
secrets: this.secrets,
|
||||
requested,
|
||||
budget,
|
||||
}),
|
||||
maximumTimeoutMs,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed)
|
||||
return this.mode === "launch"
|
||||
? ("terminated-owned-process" as const)
|
||||
: ("disconnected-external" as const);
|
||||
this.closed = true;
|
||||
await closePlaywrightScenarioBrowser(this.opened);
|
||||
return this.mode === "launch"
|
||||
? ("terminated-owned-process" as const)
|
||||
: ("disconnected-external" as const);
|
||||
}
|
||||
|
||||
redactError(error: unknown): string {
|
||||
return this.secrets
|
||||
.redact(error instanceof Error ? error.message : "browser action failed")
|
||||
.slice(0, 4_096);
|
||||
}
|
||||
}
|
||||
|
||||
/** Production Playwright/CDP session factory. */
|
||||
export class PlaywrightScenarioSessionFactory
|
||||
implements BrowserScenarioSessionFactory
|
||||
{
|
||||
constructor(
|
||||
private readonly environment: Readonly<
|
||||
Record<string, string | undefined>
|
||||
> = process.env,
|
||||
) {}
|
||||
|
||||
open(
|
||||
scenario: BrowserScenario,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly budget?: BrowserScenarioCaptureBudget;
|
||||
readonly deadlineAt?: number;
|
||||
} = {},
|
||||
): Promise<BrowserScenarioSessionPort> {
|
||||
return PlaywrightScenarioSession.open(scenario, this.environment, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
budget:
|
||||
options.budget ??
|
||||
new BrowserScenarioCaptureBudget(
|
||||
scenario.limits.max_screenshots,
|
||||
scenario.limits.max_total_metadata_bytes,
|
||||
),
|
||||
deadlineAt:
|
||||
options.deadlineAt ?? Date.now() + scenario.limits.max_duration_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Cli } from "incur";
|
||||
import { z } from "zod";
|
||||
|
||||
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { CLI_COMMANDS } from "./cliCommandNames.js";
|
||||
import { parseCliJsonInput } from "./cliJsonInput.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
|
||||
import { browserScenarioSchema } from "./domain/browserScenario.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario";
|
||||
|
||||
/** Register the one-shot controlled browser scenario command. */
|
||||
export const registerBrowserScenarioCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.captureBrowserScenario, {
|
||||
description:
|
||||
"Run one approved, bounded Playwright browser scenario and return Evidence",
|
||||
args: z.object({
|
||||
inputJson: z
|
||||
.string()
|
||||
.describe("Inline browser scenario JSON or JSON file path"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
|
||||
const input = await parseCliJsonInput(args.inputJson, OPERATION);
|
||||
if (!input.ok) return input.error;
|
||||
const scenario = browserScenarioSchema.safeParse(input.value);
|
||||
if (!scenario.success)
|
||||
return cliError(new AnalysisInputError(OPERATION));
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) return cliError(authority.error);
|
||||
const result = await captureBrowserScenario(
|
||||
new PlaywrightBrowserScenarioProvider(),
|
||||
authority.value,
|
||||
scenario.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const cliError = (
|
||||
error: Parameters<typeof projectAnalysisError>[0],
|
||||
): JsonValue => ({
|
||||
error: "Browser scenario capture failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "./browserScenarioToolContracts.js";
|
||||
import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js";
|
||||
|
||||
/** Complete ordered browser-provider contract inventory. */
|
||||
export const BROWSER_PROVIDER_TOOL_CONTRACTS = [
|
||||
...BROWSER_TOOL_CONTRACTS,
|
||||
...BROWSER_SCENARIO_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemas.js";
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const example: Record<string, JsonValue> = {
|
||||
schema_version: 1,
|
||||
browser: {
|
||||
mode: "launch",
|
||||
executable_path: "/opt/chromium/chrome",
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: { url: "https://app.example.test/", query: [] },
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720, device_scale_factor: 1 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: { kind: "test_id", value: "application-ready" },
|
||||
state: "visible",
|
||||
},
|
||||
],
|
||||
storage: { cookies: [], local_storage: [], session_storage: [] },
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: [],
|
||||
header_names: [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
],
|
||||
},
|
||||
capture: {
|
||||
after_each_step: ["screenshot", "url", "accessibility"],
|
||||
at_end: ["dom", "history", "storage"],
|
||||
events: ["console", "page-errors", "network", "websockets"],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 60_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
max_events: 2_000,
|
||||
max_frames: 100,
|
||||
max_workers: 20,
|
||||
max_popups: 10,
|
||||
max_websockets: 100,
|
||||
max_dom_nodes: 10_000,
|
||||
max_accessibility_nodes: 10_000,
|
||||
max_screenshots: 16,
|
||||
max_screenshot_bytes: 4_194_304,
|
||||
max_storage_entries: 256,
|
||||
max_total_metadata_bytes: 4_194_304,
|
||||
},
|
||||
approved: true,
|
||||
};
|
||||
|
||||
/** Controlled browser scenario contract shared by MCP and catalog generation. */
|
||||
export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "capture_browser_scenario",
|
||||
...toolContractMetadata("capture_browser_scenario"),
|
||||
description:
|
||||
"Run one approved, bounded browser scenario through Playwright. Launch mode owns and terminates a temporary browser profile; connect mode attaches to one exact loopback CDP target and disconnects without terminating the external browser. Only the fixed action vocabulary is accepted. Navigation, storage, requests, redirects, secrets, redaction, captures, and limits must be declared before execution. Returns step-indexed Evidence with screenshots, DOM, accessibility, URL/history/storage state, runtime events, explicit missing/truncated sections, and equality eligibility.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: browserScenarioSchema,
|
||||
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
||||
examples: [
|
||||
{ title: "Capture a deterministic browser scenario", input: example },
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Public browser, Electron, Inspector, and scenario operations. */
|
||||
export type BrowserObservationOperation =
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "compare_web_captures"
|
||||
| "capture_web_screenshot"
|
||||
| "compare_web_screenshots"
|
||||
| "list_electron_targets"
|
||||
| "inspect_electron_page"
|
||||
| "list_javascript_runtime_targets"
|
||||
| "observe_javascript_runtime"
|
||||
| "capture_browser_scenario";
|
||||
|
||||
/** Stable failure reasons shared by browser-family provider adapters. */
|
||||
export type BrowserObservationFailureReason =
|
||||
| "endpoint_unreachable"
|
||||
| "invalid_endpoint_response"
|
||||
| "target_not_found"
|
||||
| "target_not_allowed"
|
||||
| "target_changed"
|
||||
| "protocol_error"
|
||||
| "disconnected"
|
||||
| "payload_limit"
|
||||
| "cancelled"
|
||||
| "secret_unavailable";
|
||||
@@ -0,0 +1,338 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BROWSER_SCENARIO_LIMITS,
|
||||
browserScenarioActionSchema,
|
||||
browserScenarioAllowedOriginsSchema,
|
||||
browserScenarioBrowserSchema,
|
||||
browserScenarioCaptureLimitsSchema,
|
||||
browserScenarioCaptureSchema,
|
||||
browserScenarioEnvironmentSchema,
|
||||
browserScenarioRedactionSchema,
|
||||
browserScenarioRequestReplaySchema,
|
||||
browserScenarioSecretSchema,
|
||||
browserScenarioStorageSchema,
|
||||
browserScenarioUrlSchema,
|
||||
type BrowserScenarioAction,
|
||||
type BrowserScenarioUrl,
|
||||
type BrowserScenarioValue,
|
||||
} from "./browserScenarioValues.js";
|
||||
|
||||
export {
|
||||
BROWSER_SCENARIO_LIMITS,
|
||||
browserScenarioActionSchema,
|
||||
browserScenarioUrlSchema,
|
||||
browserScenarioValueSchema,
|
||||
type BrowserScenarioAction,
|
||||
type BrowserScenarioUrl,
|
||||
type BrowserScenarioValue,
|
||||
} from "./browserScenarioValues.js";
|
||||
|
||||
const scenarioShapeSchema = z.strictObject({
|
||||
schema_version: z.literal(1),
|
||||
browser: browserScenarioBrowserSchema,
|
||||
start_url: browserScenarioUrlSchema,
|
||||
allowed_origins: browserScenarioAllowedOriginsSchema,
|
||||
environment: browserScenarioEnvironmentSchema,
|
||||
actions: z
|
||||
.array(browserScenarioActionSchema)
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actions),
|
||||
storage: browserScenarioStorageSchema,
|
||||
request_replay: browserScenarioRequestReplaySchema,
|
||||
secrets: z
|
||||
.array(browserScenarioSecretSchema)
|
||||
.max(BROWSER_SCENARIO_LIMITS.secrets)
|
||||
.default([]),
|
||||
redaction: browserScenarioRedactionSchema,
|
||||
capture: browserScenarioCaptureSchema,
|
||||
limits: browserScenarioCaptureLimitsSchema,
|
||||
approved: z.literal(true),
|
||||
});
|
||||
|
||||
type ScenarioShape = z.infer<typeof scenarioShapeSchema>;
|
||||
|
||||
const addIssue = (
|
||||
context: z.RefinementCtx,
|
||||
path: PropertyKey[],
|
||||
message: string,
|
||||
): void => context.addIssue({ code: "custom", path, message });
|
||||
|
||||
const assertUnique = (
|
||||
values: readonly string[],
|
||||
path: PropertyKey[],
|
||||
label: string,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
if (new Set(values).size !== values.length)
|
||||
addIssue(context, path, `${label} must be unique`);
|
||||
};
|
||||
|
||||
const originFor = (destination: BrowserScenarioUrl): string =>
|
||||
new URL(destination.url).origin;
|
||||
|
||||
const assertAllowedDestination = (
|
||||
destination: BrowserScenarioUrl,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
path: PropertyKey[],
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
if (!allowedOrigins.has(originFor(destination)))
|
||||
addIssue(
|
||||
context,
|
||||
path,
|
||||
`URL origin ${originFor(destination)} is not declared in allowed_origins`,
|
||||
);
|
||||
};
|
||||
|
||||
const secretReferencesInValue = (
|
||||
value: BrowserScenarioValue | undefined,
|
||||
): readonly string[] => (value?.source === "secret" ? [value.secret_id] : []);
|
||||
|
||||
const destinationSecretReferences = (
|
||||
destination: BrowserScenarioUrl,
|
||||
): readonly string[] =>
|
||||
destination.query.flatMap(({ value }) => secretReferencesInValue(value));
|
||||
|
||||
const actionSecretReferences = (
|
||||
action: BrowserScenarioAction,
|
||||
): readonly string[] => {
|
||||
if (action.action === "fill" || action.action === "select_option")
|
||||
return secretReferencesInValue(action.value);
|
||||
if (action.action === "goto")
|
||||
return destinationSecretReferences(action.destination);
|
||||
return [];
|
||||
};
|
||||
|
||||
const validateStorage = (
|
||||
scenario: ScenarioShape,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
context: z.RefinementCtx,
|
||||
): readonly string[] => {
|
||||
const references: string[] = [];
|
||||
scenario.storage.cookies.forEach((cookie, index) => {
|
||||
assertAllowedDestination(
|
||||
cookie.destination,
|
||||
allowedOrigins,
|
||||
["storage", "cookies", index, "destination"],
|
||||
context,
|
||||
);
|
||||
references.push(...secretReferencesInValue(cookie.value));
|
||||
references.push(...destinationSecretReferences(cookie.destination));
|
||||
});
|
||||
for (const collection of ["local_storage", "session_storage"] as const)
|
||||
scenario.storage[collection].forEach((storage, index) => {
|
||||
if (!allowedOrigins.has(storage.origin))
|
||||
addIssue(
|
||||
context,
|
||||
["storage", collection, index, "origin"],
|
||||
`Storage origin ${storage.origin} is not declared in allowed_origins`,
|
||||
);
|
||||
for (const entry of storage.entries)
|
||||
references.push(...secretReferencesInValue(entry.value));
|
||||
});
|
||||
return references;
|
||||
};
|
||||
|
||||
const SENSITIVE_HEADERS = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
]);
|
||||
const FORBIDDEN_REPLAY_HEADERS = new Set([
|
||||
"content-length",
|
||||
"location",
|
||||
"transfer-encoding",
|
||||
]);
|
||||
|
||||
const validateRequestReplay = (
|
||||
scenario: ScenarioShape,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
context: z.RefinementCtx,
|
||||
): readonly string[] => {
|
||||
if (scenario.request_replay.mode === "disabled") return [];
|
||||
assertUnique(
|
||||
scenario.request_replay.routes.map(({ route_id: id }) => id),
|
||||
["request_replay", "routes"],
|
||||
"Replay route IDs",
|
||||
context,
|
||||
);
|
||||
assertUnique(
|
||||
scenario.request_replay.routes.map(
|
||||
({ method, request }) =>
|
||||
`${method} ${request.url}?${request.query
|
||||
.map(({ name, value }) =>
|
||||
value.source === "secret"
|
||||
? `${name}=\${secret:${value.secret_id}}`
|
||||
: `${name}=${value.value}`,
|
||||
)
|
||||
.join("&")}`,
|
||||
),
|
||||
["request_replay", "routes"],
|
||||
"Replay request matchers",
|
||||
context,
|
||||
);
|
||||
const references: string[] = [];
|
||||
scenario.request_replay.routes.forEach((route, routeIndex) => {
|
||||
assertAllowedDestination(
|
||||
route.request,
|
||||
allowedOrigins,
|
||||
["request_replay", "routes", routeIndex, "request"],
|
||||
context,
|
||||
);
|
||||
references.push(...destinationSecretReferences(route.request));
|
||||
if (route.response.kind === "redirect") {
|
||||
assertAllowedDestination(
|
||||
route.response.destination,
|
||||
allowedOrigins,
|
||||
["request_replay", "routes", routeIndex, "response", "destination"],
|
||||
context,
|
||||
);
|
||||
references.push(
|
||||
...destinationSecretReferences(route.response.destination),
|
||||
);
|
||||
return;
|
||||
}
|
||||
route.response.headers.forEach((header, headerIndex) => {
|
||||
if (FORBIDDEN_REPLAY_HEADERS.has(header.name))
|
||||
addIssue(
|
||||
context,
|
||||
[
|
||||
"request_replay",
|
||||
"routes",
|
||||
routeIndex,
|
||||
"response",
|
||||
"headers",
|
||||
headerIndex,
|
||||
],
|
||||
`Replay header ${header.name} must be modeled by the provider`,
|
||||
);
|
||||
if (
|
||||
SENSITIVE_HEADERS.has(header.name) &&
|
||||
header.value.source === "literal"
|
||||
)
|
||||
addIssue(
|
||||
context,
|
||||
[
|
||||
"request_replay",
|
||||
"routes",
|
||||
routeIndex,
|
||||
"response",
|
||||
"headers",
|
||||
headerIndex,
|
||||
"value",
|
||||
],
|
||||
`Credential header ${header.name} requires a declared secret`,
|
||||
);
|
||||
references.push(...secretReferencesInValue(header.value));
|
||||
});
|
||||
references.push(...secretReferencesInValue(route.response.body));
|
||||
});
|
||||
return references;
|
||||
};
|
||||
|
||||
const validateSecretReferences = (
|
||||
scenario: ScenarioShape,
|
||||
references: readonly string[],
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const declared = new Set(scenario.secrets.map(({ secret_id: id }) => id));
|
||||
for (const reference of references)
|
||||
if (!declared.has(reference))
|
||||
addIssue(
|
||||
context,
|
||||
["secrets"],
|
||||
`Secret reference ${reference} is not declared`,
|
||||
);
|
||||
const used = new Set(references);
|
||||
scenario.secrets.forEach(({ secret_id: id }, index) => {
|
||||
if (!used.has(id))
|
||||
addIssue(
|
||||
context,
|
||||
["secrets", index, "secret_id"],
|
||||
`Secret declaration ${id} is unused`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const validateRedactionCoverage = (
|
||||
scenario: ScenarioShape,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const redactedQueries = new Set(scenario.redaction.query_parameter_names);
|
||||
const destinations: BrowserScenarioUrl[] = [scenario.start_url];
|
||||
for (const action of scenario.actions)
|
||||
if (action.action === "goto") destinations.push(action.destination);
|
||||
for (const cookie of scenario.storage.cookies)
|
||||
destinations.push(cookie.destination);
|
||||
if (scenario.request_replay.mode === "exact")
|
||||
for (const route of scenario.request_replay.routes) {
|
||||
destinations.push(route.request);
|
||||
if (route.response.kind === "redirect")
|
||||
destinations.push(route.response.destination);
|
||||
}
|
||||
for (const destination of destinations)
|
||||
for (const query of destination.query)
|
||||
if (
|
||||
query.value.source === "secret" &&
|
||||
!redactedQueries.has(query.name.toLowerCase())
|
||||
)
|
||||
addIssue(
|
||||
context,
|
||||
["redaction", "query_parameter_names"],
|
||||
`Secret query parameter ${query.name} must be declared for redaction`,
|
||||
);
|
||||
};
|
||||
|
||||
const validateBrowserScenario = (
|
||||
scenario: ScenarioShape,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const allowedOrigins = new Set(scenario.allowed_origins);
|
||||
assertAllowedDestination(
|
||||
scenario.start_url,
|
||||
allowedOrigins,
|
||||
["start_url"],
|
||||
context,
|
||||
);
|
||||
assertUnique(
|
||||
scenario.actions.map(({ step_id: id }) => id),
|
||||
["actions"],
|
||||
"Action step IDs",
|
||||
context,
|
||||
);
|
||||
assertUnique(
|
||||
scenario.secrets.map(({ secret_id: id }) => id),
|
||||
["secrets"],
|
||||
"Secret IDs",
|
||||
context,
|
||||
);
|
||||
assertUnique(
|
||||
scenario.secrets.map(({ environment_variable: name }) => name),
|
||||
["secrets"],
|
||||
"Secret environment variables",
|
||||
context,
|
||||
);
|
||||
|
||||
const references = scenario.actions.flatMap(actionSecretReferences);
|
||||
for (const [index, action] of scenario.actions.entries())
|
||||
if (action.action === "goto")
|
||||
assertAllowedDestination(
|
||||
action.destination,
|
||||
allowedOrigins,
|
||||
["actions", index, "destination"],
|
||||
context,
|
||||
);
|
||||
references.push(...destinationSecretReferences(scenario.start_url));
|
||||
references.push(...validateStorage(scenario, allowedOrigins, context));
|
||||
references.push(...validateRequestReplay(scenario, allowedOrigins, context));
|
||||
validateSecretReferences(scenario, references, context);
|
||||
validateRedactionCoverage(scenario, context);
|
||||
};
|
||||
|
||||
/** Strict provider-neutral contract for one bounded browser capture scenario. */
|
||||
export const browserScenarioSchema = scenarioShapeSchema.superRefine(
|
||||
validateBrowserScenario,
|
||||
);
|
||||
export type BrowserScenario = z.infer<typeof browserScenarioSchema>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
browserScenarioCompletenessSchema,
|
||||
browserScenarioEventSchema,
|
||||
browserScenarioStepSchema,
|
||||
} from "./browserScenarioCaptureValues.js";
|
||||
|
||||
export {
|
||||
browserScenarioCompletenessSchema,
|
||||
browserScenarioEventSchema,
|
||||
browserScenarioStepSchema,
|
||||
browserStepArtifactsSchema,
|
||||
captureStateSchema,
|
||||
type BrowserScenarioCompleteness,
|
||||
type BrowserScenarioEvent,
|
||||
type BrowserScenarioStep,
|
||||
type BrowserStepArtifacts,
|
||||
} from "./browserScenarioCaptureValues.js";
|
||||
|
||||
/** Step-indexed, explicitly bounded browser scenario observation. */
|
||||
export const browserScenarioCaptureSchema = z
|
||||
.strictObject({
|
||||
schema_version: z.literal(1),
|
||||
browser: z.strictObject({
|
||||
mode: z.enum(["launch", "connect"]),
|
||||
process_ownership: z.enum(["provider-owned", "external"]),
|
||||
cleanup: z.enum(["terminated-owned-process", "disconnected-external"]),
|
||||
product: z.string().min(1).max(1_024),
|
||||
version: z.string().min(1).max(256),
|
||||
}),
|
||||
scenario: z.strictObject({
|
||||
start_origin: z.string().min(1).max(2_048),
|
||||
allowed_origins: z.array(z.string().min(1).max(2_048)).min(1).max(32),
|
||||
action_count: z.number().int().min(1).max(128),
|
||||
secret_references: z.array(z.string().min(1).max(64)).max(64),
|
||||
}),
|
||||
duration_ms: z.number().int().min(0),
|
||||
steps: z.array(browserScenarioStepSchema).min(1).max(129),
|
||||
events: z.strictObject({
|
||||
retained: z.number().int().min(0),
|
||||
dropped: z.number().int().min(0),
|
||||
items: z.array(browserScenarioEventSchema).max(100_000),
|
||||
}),
|
||||
completeness: browserScenarioCompletenessSchema,
|
||||
limitations: z.array(z.string().min(1).max(2_048)).max(64),
|
||||
})
|
||||
.superRefine((capture, context) => {
|
||||
if (capture.events.retained !== capture.events.items.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["events", "retained"],
|
||||
message: "Retained event count must equal items length",
|
||||
});
|
||||
if (capture.steps.length !== capture.scenario.action_count + 1)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["steps"],
|
||||
message: "Capture must include initial state and one entry per action",
|
||||
});
|
||||
const expectedIndices = capture.steps.map((_, index) => index);
|
||||
if (
|
||||
capture.steps.some(
|
||||
({ step_index: index }, offset) => index !== expectedIndices[offset],
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["steps"],
|
||||
message: "Step indices must be contiguous from zero",
|
||||
});
|
||||
if (
|
||||
capture.events.items.some(
|
||||
({ step_index: index }) => index >= capture.steps.length,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["events", "items"],
|
||||
message: "Event references an unknown step index",
|
||||
});
|
||||
});
|
||||
export type BrowserScenarioCapture = z.infer<
|
||||
typeof browserScenarioCaptureSchema
|
||||
>;
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { sanitizedBrowserUrlSchema } from "./browserObservation.js";
|
||||
import { webScreenshotArtifactSchema } from "./webScreenshot.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const boundedTextSchema = z.string().max(1_048_576);
|
||||
|
||||
const textArtifactSchema = z
|
||||
.strictObject({
|
||||
sha256: digestSchema,
|
||||
bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(16 * 1_024 * 1_024),
|
||||
text: boundedTextSchema,
|
||||
})
|
||||
.superRefine((artifact, context) => {
|
||||
const bytes = Buffer.from(artifact.text);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (bytes.byteLength !== artifact.bytes || sha256 !== artifact.sha256)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Text artifact digest or size mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
export const captureStateSchema = <Schema extends z.ZodType>(value: Schema) =>
|
||||
z.discriminatedUnion("state", [
|
||||
z.strictObject({ state: z.literal("captured"), value }),
|
||||
z.strictObject({
|
||||
state: z.literal("not_requested"),
|
||||
}),
|
||||
z.strictObject({
|
||||
state: z.literal("missing"),
|
||||
reason: z.string().min(1).max(1_024),
|
||||
}),
|
||||
z.strictObject({
|
||||
state: z.literal("truncated"),
|
||||
observed: z.number().int().min(0),
|
||||
retained: z.number().int().min(0),
|
||||
reason: z.string().min(1).max(1_024),
|
||||
}),
|
||||
]);
|
||||
|
||||
const historySchema = z.strictObject({
|
||||
length: z.number().int().min(0),
|
||||
current_url: sanitizedBrowserUrlSchema,
|
||||
navigation_entries: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
type: z.enum([
|
||||
"navigate",
|
||||
"reload",
|
||||
"back_forward",
|
||||
"prerender",
|
||||
"unknown",
|
||||
]),
|
||||
name: sanitizedBrowserUrlSchema,
|
||||
}),
|
||||
)
|
||||
.max(256),
|
||||
});
|
||||
|
||||
const storageValueSchema = z.strictObject({
|
||||
name: z.string().max(1_024),
|
||||
value_state: z.enum(["hashed", "redacted-secret"]),
|
||||
value_sha256: digestSchema.nullable(),
|
||||
});
|
||||
|
||||
const storageSnapshotSchema = z.strictObject({
|
||||
cookies: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string().max(1_024),
|
||||
domain: z.string().max(2_048),
|
||||
path: z.string().max(4_096),
|
||||
secure: z.boolean(),
|
||||
http_only: z.boolean(),
|
||||
same_site: z.enum(["Strict", "Lax", "None"]),
|
||||
value_state: z.enum(["hashed", "redacted-secret"]),
|
||||
value_sha256: digestSchema.nullable(),
|
||||
}),
|
||||
)
|
||||
.max(512),
|
||||
local_storage: z.array(storageValueSchema).max(512),
|
||||
session_storage: z.array(storageValueSchema).max(512),
|
||||
});
|
||||
|
||||
export const browserStepArtifactsSchema = z.strictObject({
|
||||
screenshot: captureStateSchema(webScreenshotArtifactSchema),
|
||||
dom: captureStateSchema(textArtifactSchema),
|
||||
accessibility: captureStateSchema(textArtifactSchema),
|
||||
url: captureStateSchema(sanitizedBrowserUrlSchema),
|
||||
history: captureStateSchema(historySchema),
|
||||
storage: captureStateSchema(storageSnapshotSchema),
|
||||
});
|
||||
export type BrowserStepArtifacts = z.infer<typeof browserStepArtifactsSchema>;
|
||||
|
||||
const eventBase = {
|
||||
sequence: z.number().int().min(1),
|
||||
step_index: z.number().int().min(0),
|
||||
};
|
||||
|
||||
const eventUrl = sanitizedBrowserUrlSchema.nullable();
|
||||
|
||||
export const browserScenarioEventSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.literal("console"),
|
||||
level: z.string().min(1).max(64),
|
||||
text: z.string().max(65_536),
|
||||
url: eventUrl,
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.literal("page-error"),
|
||||
message: z.string().max(65_536),
|
||||
stack: z.string().max(262_144).nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.enum(["request", "response", "request-failed"]),
|
||||
method: z.string().min(1).max(32),
|
||||
url: sanitizedBrowserUrlSchema,
|
||||
resource_type: z.string().min(1).max(64),
|
||||
status: z.number().int().min(100).max(599).nullable(),
|
||||
header_names: z.array(z.string().max(256)).max(256),
|
||||
failure: z.string().max(1_024).nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.enum(["websocket-opened", "websocket-closed"]),
|
||||
url: sanitizedBrowserUrlSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.enum(["websocket-frame-sent", "websocket-frame-received"]),
|
||||
url: sanitizedBrowserUrlSchema,
|
||||
payload_type: z.enum(["text", "binary"]),
|
||||
payload_bytes: z.number().int().min(0),
|
||||
payload_text: z.string().max(65_536).nullable(),
|
||||
truncated: z.boolean(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.enum([
|
||||
"frame-attached",
|
||||
"frame-detached",
|
||||
"frame-navigated",
|
||||
"worker-created",
|
||||
"worker-closed",
|
||||
"popup-opened",
|
||||
"popup-closed",
|
||||
]),
|
||||
url: eventUrl,
|
||||
name: z.string().max(1_024).nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...eventBase,
|
||||
kind: z.literal("download-cancelled"),
|
||||
suggested_filename: z.string().max(1_024),
|
||||
url: sanitizedBrowserUrlSchema,
|
||||
}),
|
||||
]);
|
||||
export type BrowserScenarioEvent = z.infer<typeof browserScenarioEventSchema>;
|
||||
|
||||
const completenessSectionSchema = z.enum([
|
||||
"action",
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
"events",
|
||||
"frames",
|
||||
"workers",
|
||||
"popups",
|
||||
"websockets",
|
||||
]);
|
||||
|
||||
export const browserScenarioCompletenessSchema = z
|
||||
.strictObject({
|
||||
status: z.enum(["complete", "incomplete", "truncated"]),
|
||||
equality_eligible: z.boolean(),
|
||||
missing_sections: z.array(completenessSectionSchema),
|
||||
truncated_sections: z.array(completenessSectionSchema),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const expectedStatus =
|
||||
value.truncated_sections.length > 0
|
||||
? "truncated"
|
||||
: value.missing_sections.length > 0
|
||||
? "incomplete"
|
||||
: "complete";
|
||||
if (value.status !== expectedStatus)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["status"],
|
||||
message: "Completeness status does not match section accounting",
|
||||
});
|
||||
if (value.equality_eligible !== (expectedStatus === "complete"))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["equality_eligible"],
|
||||
message: "Missing or truncated evidence is never equality-eligible",
|
||||
});
|
||||
});
|
||||
export type BrowserScenarioCompleteness = z.infer<
|
||||
typeof browserScenarioCompletenessSchema
|
||||
>;
|
||||
|
||||
export const browserScenarioStepSchema = z.strictObject({
|
||||
step_index: z.number().int().min(0),
|
||||
step_id: z.string().min(1).max(64),
|
||||
action: z.string().min(1).max(64),
|
||||
status: z.enum(["completed", "failed", "cancelled"]),
|
||||
elapsed_ms: z.number().int().min(0),
|
||||
before_url: sanitizedBrowserUrlSchema,
|
||||
after_url: sanitizedBrowserUrlSchema,
|
||||
error: z.string().max(4_096).nullable(),
|
||||
event_sequence_start: z.number().int().min(1),
|
||||
event_sequence_end: z.number().int().min(0),
|
||||
artifacts: browserStepArtifactsSchema,
|
||||
completeness: browserScenarioCompletenessSchema,
|
||||
});
|
||||
export type BrowserScenarioStep = z.infer<typeof browserScenarioStepSchema>;
|
||||
@@ -0,0 +1,424 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
browserAllowedOriginsSchema,
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
} from "./browserObservation.js";
|
||||
|
||||
export const BROWSER_SCENARIO_LIMITS = {
|
||||
actions: 128,
|
||||
secrets: 64,
|
||||
storageEntries: 512,
|
||||
replayRoutes: 256,
|
||||
durationMs: 300_000,
|
||||
actionTimeoutMs: 60_000,
|
||||
} as const;
|
||||
|
||||
export const scenarioIdentifierSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u);
|
||||
|
||||
const absoluteExecutablePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/u.test(value) ||
|
||||
value.startsWith("\\\\"),
|
||||
"Expected an absolute browser executable path",
|
||||
);
|
||||
|
||||
const browserScenarioBaseUrlSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(65_536)
|
||||
.transform((value, context) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
context.addIssue({ code: "custom", message: "Invalid browser URL" });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (
|
||||
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Browser URLs must be HTTP(S), omit credentials, and declare query values separately",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
export const browserScenarioValueSchema = z.discriminatedUnion("source", [
|
||||
z.strictObject({
|
||||
source: z.literal("literal"),
|
||||
value: z.string().max(65_536),
|
||||
classification: z.literal("public"),
|
||||
}),
|
||||
z.strictObject({
|
||||
source: z.literal("secret"),
|
||||
secret_id: scenarioIdentifierSchema,
|
||||
}),
|
||||
]);
|
||||
export type BrowserScenarioValue = z.infer<typeof browserScenarioValueSchema>;
|
||||
|
||||
const queryEntrySchema = z.strictObject({
|
||||
name: z.string().min(1).max(256),
|
||||
value: browserScenarioValueSchema,
|
||||
});
|
||||
|
||||
/** URL whose values remain explicit public literals or declared secret references. */
|
||||
export const browserScenarioUrlSchema = z.strictObject({
|
||||
url: browserScenarioBaseUrlSchema,
|
||||
query: z.array(queryEntrySchema).max(64).default([]),
|
||||
});
|
||||
export type BrowserScenarioUrl = z.infer<typeof browserScenarioUrlSchema>;
|
||||
|
||||
export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [
|
||||
z.strictObject({
|
||||
mode: z.literal("launch"),
|
||||
executable_path: absoluteExecutablePathSchema,
|
||||
headless: z.literal(true),
|
||||
user_data: z.literal("temporary-owned"),
|
||||
cleanup: z.literal("close-and-delete-profile"),
|
||||
}),
|
||||
z.strictObject({
|
||||
mode: z.literal("connect"),
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
ownership: z.literal("external"),
|
||||
cleanup: z.literal("disconnect-only"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const browserScenarioEnvironmentSchema = z.strictObject({
|
||||
viewport: z.strictObject({
|
||||
width: z.number().int().min(320).max(7_680),
|
||||
height: z.number().int().min(240).max(4_320),
|
||||
device_scale_factor: z.number().min(0.5).max(4).default(1),
|
||||
}),
|
||||
locale: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)*$/u),
|
||||
color_scheme: z.enum(["light", "dark", "no-preference"]),
|
||||
reduced_motion: z.enum(["reduce", "no-preference"]),
|
||||
service_workers: z.literal("block"),
|
||||
});
|
||||
|
||||
const locatorSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("test_id"),
|
||||
value: z.string().min(1).max(512),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("role"),
|
||||
role: z.enum([
|
||||
"button",
|
||||
"checkbox",
|
||||
"combobox",
|
||||
"dialog",
|
||||
"link",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"option",
|
||||
"radio",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"switch",
|
||||
"tab",
|
||||
"textbox",
|
||||
]),
|
||||
name: z.string().min(1).max(1_024),
|
||||
exact: z.literal(true),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("css"),
|
||||
selector: z.string().min(1).max(4_096),
|
||||
approved: z.literal(true),
|
||||
}),
|
||||
]);
|
||||
|
||||
const stepBase = {
|
||||
step_id: scenarioIdentifierSchema,
|
||||
timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
export const browserScenarioActionSchema = z.discriminatedUnion("action", [
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("goto"),
|
||||
destination: browserScenarioUrlSchema,
|
||||
wait_until: z.enum(["commit", "domcontentloaded", "load"]),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("click"),
|
||||
locator: locatorSchema,
|
||||
button: z.enum(["left", "middle", "right"]).default("left"),
|
||||
click_count: z.number().int().min(1).max(3).default(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("fill"),
|
||||
locator: locatorSchema,
|
||||
value: browserScenarioValueSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("press"),
|
||||
locator: locatorSchema,
|
||||
key: z.string().min(1).max(64),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("select_option"),
|
||||
locator: locatorSchema,
|
||||
value: browserScenarioValueSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.enum(["check", "uncheck"]),
|
||||
locator: locatorSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...stepBase,
|
||||
action: z.literal("wait_for"),
|
||||
locator: locatorSchema,
|
||||
state: z.enum(["attached", "detached", "visible", "hidden"]),
|
||||
}),
|
||||
z.strictObject({
|
||||
step_id: scenarioIdentifierSchema,
|
||||
action: z.literal("wait_for_timeout"),
|
||||
duration_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs),
|
||||
}),
|
||||
]);
|
||||
export type BrowserScenarioAction = z.infer<typeof browserScenarioActionSchema>;
|
||||
|
||||
const storageEntrySchema = z.strictObject({
|
||||
name: z.string().min(1).max(1_024),
|
||||
value: browserScenarioValueSchema,
|
||||
});
|
||||
|
||||
export const browserScenarioStorageSchema = z.strictObject({
|
||||
cookies: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1).max(1_024),
|
||||
value: browserScenarioValueSchema,
|
||||
destination: browserScenarioUrlSchema,
|
||||
http_only: z.boolean(),
|
||||
secure: z.boolean(),
|
||||
same_site: z.enum(["Strict", "Lax", "None"]),
|
||||
}),
|
||||
)
|
||||
.max(128)
|
||||
.default([]),
|
||||
local_storage: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
origin: browserOriginSchema,
|
||||
entries: z.array(storageEntrySchema).max(128),
|
||||
}),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
session_storage: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
origin: browserOriginSchema,
|
||||
entries: z.array(storageEntrySchema).max(128),
|
||||
}),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const headerNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u)
|
||||
.transform((value) => value.toLowerCase());
|
||||
|
||||
const replayHeaderSchema = z.strictObject({
|
||||
name: headerNameSchema,
|
||||
value: browserScenarioValueSchema,
|
||||
});
|
||||
|
||||
const replayResponseSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("response"),
|
||||
status: z.number().int().min(100).max(599),
|
||||
headers: z.array(replayHeaderSchema).max(64).default([]),
|
||||
body: browserScenarioValueSchema.optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("redirect"),
|
||||
status: z.union([
|
||||
z.literal(301),
|
||||
z.literal(302),
|
||||
z.literal(303),
|
||||
z.literal(307),
|
||||
z.literal(308),
|
||||
]),
|
||||
destination: browserScenarioUrlSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const replayRouteSchema = z.strictObject({
|
||||
route_id: scenarioIdentifierSchema,
|
||||
method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]),
|
||||
request: browserScenarioUrlSchema,
|
||||
response: replayResponseSchema,
|
||||
});
|
||||
|
||||
export const browserScenarioRequestReplaySchema = z.discriminatedUnion("mode", [
|
||||
z.strictObject({ mode: z.literal("disabled") }),
|
||||
z.strictObject({
|
||||
mode: z.literal("exact"),
|
||||
unmatched: z.enum(["abort", "passthrough-approved-origins"]),
|
||||
routes: z
|
||||
.array(replayRouteSchema)
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.replayRoutes),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const browserScenarioSecretSchema = z.strictObject({
|
||||
secret_id: scenarioIdentifierSchema,
|
||||
environment_variable: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/u),
|
||||
purpose: z.enum(["input", "storage", "request-replay"]),
|
||||
redaction: z.literal("replace-with-secret-reference"),
|
||||
});
|
||||
|
||||
const REQUIRED_REDACTED_HEADERS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
] as const;
|
||||
|
||||
const normalizedNames = (maximum: number) =>
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.transform((value) => value.toLowerCase()),
|
||||
)
|
||||
.max(maximum)
|
||||
.transform((values) => [...new Set(values)].sort());
|
||||
|
||||
export const browserScenarioRedactionSchema = z
|
||||
.strictObject({
|
||||
secret_values: z.literal("replace-with-secret-reference"),
|
||||
query_parameter_names: normalizedNames(128).default([]),
|
||||
header_names: normalizedNames(128).default([...REQUIRED_REDACTED_HEADERS]),
|
||||
})
|
||||
.superRefine(({ header_names: names }, context) => {
|
||||
for (const required of REQUIRED_REDACTED_HEADERS)
|
||||
if (!names.includes(required))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["header_names"],
|
||||
message: `Required credential header ${required} must be redacted`,
|
||||
});
|
||||
});
|
||||
|
||||
const snapshotKindSchema = z.enum([
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
]);
|
||||
|
||||
export const browserScenarioCaptureSchema = z.strictObject({
|
||||
after_each_step: z.array(snapshotKindSchema).max(6),
|
||||
at_end: z.array(snapshotKindSchema).max(6),
|
||||
events: z.array(
|
||||
z.enum([
|
||||
"console",
|
||||
"page-errors",
|
||||
"network",
|
||||
"websockets",
|
||||
"frames",
|
||||
"workers",
|
||||
"popups",
|
||||
"downloads",
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
export const browserScenarioCaptureLimitsSchema = z.strictObject({
|
||||
max_duration_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100)
|
||||
.max(BROWSER_SCENARIO_LIMITS.durationMs),
|
||||
action_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs),
|
||||
navigation_timeout_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs),
|
||||
max_events: z.number().int().min(1).max(100_000),
|
||||
max_frames: z.number().int().min(1).max(1_000),
|
||||
max_workers: z.number().int().min(0).max(1_000),
|
||||
max_popups: z.number().int().min(0).max(100),
|
||||
max_websockets: z.number().int().min(0).max(10_000),
|
||||
max_dom_nodes: z.number().int().min(1).max(100_000),
|
||||
max_accessibility_nodes: z.number().int().min(1).max(100_000),
|
||||
max_screenshots: z.number().int().min(0).max(256),
|
||||
max_screenshot_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(8 * 1_024 * 1_024),
|
||||
max_storage_entries: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(BROWSER_SCENARIO_LIMITS.storageEntries),
|
||||
max_total_metadata_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1_024)
|
||||
.max(64 * 1_024 * 1_024),
|
||||
});
|
||||
|
||||
export const browserScenarioAllowedOriginsSchema = browserAllowedOriginsSchema;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
const MAX_SCREENSHOT_BYTES = 8 * 1_024 * 1_024;
|
||||
|
||||
/** Self-verifying inline PNG artifact for CLI/MCP parity. */
|
||||
const webScreenshotArtifactSchema = z
|
||||
export const webScreenshotArtifactSchema = z
|
||||
.object({
|
||||
uri: z.string().regex(/^rea:\/\/web-screenshot\/sha256\/[a-f0-9]{64}$/u),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
|
||||
import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js";
|
||||
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
|
||||
import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../contracts/browserScenarioToolContracts.js";
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { safeParseToolInput } from "./toolInputValidation.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
interface BrowserScenarioToolRegistration {
|
||||
readonly logger: Logger;
|
||||
readonly provider: BrowserScenarioCapturePort | undefined;
|
||||
readonly permissionAuthority: PermissionAuthority | undefined;
|
||||
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
|
||||
}
|
||||
|
||||
/** Register the controlled browser scenario tool even when policy disables it. */
|
||||
export const registerBrowserScenarioTool = (
|
||||
server: McpServer,
|
||||
options: BrowserScenarioToolRegistration,
|
||||
): void => {
|
||||
const [contract] = BROWSER_SCENARIO_TOOL_CONTRACTS;
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) => {
|
||||
const parsed = safeParseToolInput(
|
||||
browserScenarioSchema,
|
||||
input,
|
||||
contract.name,
|
||||
);
|
||||
if (!parsed.ok) return toCallToolResult(parsed, contract);
|
||||
const result = await logToolExecution(options.logger, contract.name, () =>
|
||||
captureBrowserScenario(
|
||||
options.provider,
|
||||
options.permissionAuthority,
|
||||
parsed.value,
|
||||
{ signal: context.mcpReq.signal },
|
||||
),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, contract);
|
||||
const recorded = options.recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult({ ok: true, value: result.value }, contract, {
|
||||
evidenceResourcesAvailable: recorded !== undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { browserScenarioSchema } from "../src/domain/browserScenario.js";
|
||||
|
||||
const secret = (secret_id: string) => ({ source: "secret", secret_id });
|
||||
const literal = (value: string) => ({
|
||||
source: "literal",
|
||||
value,
|
||||
classification: "public",
|
||||
});
|
||||
|
||||
const baseScenario = () => ({
|
||||
schema_version: 1,
|
||||
browser: {
|
||||
mode: "launch",
|
||||
executable_path: "/opt/chromium/chrome",
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: {
|
||||
url: "https://app.example.test/",
|
||||
query: [{ name: "token", value: secret("session") }],
|
||||
},
|
||||
allowed_origins: ["https://api.example.test", "https://app.example.test"],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "dark",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "login",
|
||||
action: "fill",
|
||||
locator: { kind: "test_id", value: "password" },
|
||||
value: secret("password"),
|
||||
},
|
||||
{
|
||||
step_id: "submit",
|
||||
action: "click",
|
||||
locator: {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "Sign in",
|
||||
exact: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
step_id: "dashboard",
|
||||
action: "goto",
|
||||
destination: { url: "https://app.example.test/dashboard" },
|
||||
wait_until: "load",
|
||||
},
|
||||
],
|
||||
storage: {
|
||||
cookies: [
|
||||
{
|
||||
name: "sid",
|
||||
value: secret("cookie"),
|
||||
destination: { url: "https://app.example.test/" },
|
||||
http_only: true,
|
||||
secure: true,
|
||||
same_site: "Lax",
|
||||
},
|
||||
],
|
||||
local_storage: [
|
||||
{
|
||||
origin: "https://app.example.test",
|
||||
entries: [{ name: "theme", value: literal("dark") }],
|
||||
},
|
||||
],
|
||||
},
|
||||
request_replay: {
|
||||
mode: "exact",
|
||||
unmatched: "abort",
|
||||
routes: [
|
||||
{
|
||||
route_id: "profile",
|
||||
method: "GET",
|
||||
request: { url: "https://api.example.test/profile" },
|
||||
response: {
|
||||
kind: "response",
|
||||
status: 200,
|
||||
headers: [
|
||||
{
|
||||
name: "content-type",
|
||||
value: literal("application/json"),
|
||||
},
|
||||
],
|
||||
body: literal('{"name":"Ada"}'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
secrets: [
|
||||
{
|
||||
secret_id: "session",
|
||||
environment_variable: "REA_TEST_SESSION",
|
||||
purpose: "input",
|
||||
redaction: "replace-with-secret-reference",
|
||||
},
|
||||
{
|
||||
secret_id: "password",
|
||||
environment_variable: "REA_TEST_PASSWORD",
|
||||
purpose: "input",
|
||||
redaction: "replace-with-secret-reference",
|
||||
},
|
||||
{
|
||||
secret_id: "cookie",
|
||||
environment_variable: "REA_TEST_COOKIE",
|
||||
purpose: "storage",
|
||||
redaction: "replace-with-secret-reference",
|
||||
},
|
||||
],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: ["token"],
|
||||
},
|
||||
capture: {
|
||||
after_each_step: ["screenshot", "url", "accessibility"],
|
||||
at_end: ["dom", "storage"],
|
||||
events: ["console", "page-errors", "network", "websockets"],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 60_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
max_events: 2_000,
|
||||
max_frames: 100,
|
||||
max_workers: 20,
|
||||
max_popups: 10,
|
||||
max_websockets: 100,
|
||||
max_dom_nodes: 10_000,
|
||||
max_accessibility_nodes: 10_000,
|
||||
max_screenshots: 16,
|
||||
max_screenshot_bytes: 4 * 1_024 * 1_024,
|
||||
max_storage_entries: 256,
|
||||
max_total_metadata_bytes: 4 * 1_024 * 1_024,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
|
||||
describe("browserScenarioSchema", () => {
|
||||
it("accepts and normalizes a bounded declared scenario", () => {
|
||||
const parsed = browserScenarioSchema.parse(baseScenario());
|
||||
expect(parsed.allowed_origins).toEqual([
|
||||
"https://api.example.test",
|
||||
"https://app.example.test",
|
||||
]);
|
||||
expect(parsed.environment.viewport.device_scale_factor).toBe(1);
|
||||
expect(parsed.redaction.header_names).toEqual([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported actions", () => {
|
||||
const scenario = baseScenario();
|
||||
scenario.actions = [
|
||||
{
|
||||
step_id: "script",
|
||||
action: "evaluate",
|
||||
expression: "document.cookie",
|
||||
} as never,
|
||||
];
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects every undeclared navigation, storage, and replay origin", () => {
|
||||
for (const mutate of [
|
||||
(scenario: ReturnType<typeof baseScenario>) => {
|
||||
scenario.start_url.url = "https://other.example.test/";
|
||||
},
|
||||
(scenario: ReturnType<typeof baseScenario>) => {
|
||||
scenario.actions[2] = {
|
||||
...scenario.actions[2],
|
||||
destination: { url: "https://other.example.test/" },
|
||||
} as never;
|
||||
},
|
||||
(scenario: ReturnType<typeof baseScenario>) => {
|
||||
scenario.storage.local_storage[0]!.origin =
|
||||
"https://other.example.test";
|
||||
},
|
||||
(scenario: ReturnType<typeof baseScenario>) => {
|
||||
scenario.request_replay.routes[0]!.request.url =
|
||||
"https://other.example.test/data";
|
||||
},
|
||||
]) {
|
||||
const scenario = baseScenario();
|
||||
mutate(scenario);
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects redirects outside declared origins", () => {
|
||||
const scenario = baseScenario();
|
||||
scenario.request_replay.routes[0]!.response = {
|
||||
kind: "redirect",
|
||||
status: 302,
|
||||
destination: { url: "https://other.example.test/" },
|
||||
} as never;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing, duplicate, and unused secret declarations", () => {
|
||||
const missing = baseScenario();
|
||||
missing.secrets = missing.secrets.filter(
|
||||
({ secret_id }) => secret_id !== "password",
|
||||
);
|
||||
expect(browserScenarioSchema.safeParse(missing).success).toBe(false);
|
||||
|
||||
const duplicate = baseScenario();
|
||||
duplicate.secrets.push({ ...duplicate.secrets[0]! });
|
||||
expect(browserScenarioSchema.safeParse(duplicate).success).toBe(false);
|
||||
|
||||
const unused = baseScenario();
|
||||
unused.secrets.push({
|
||||
secret_id: "unused",
|
||||
environment_variable: "REA_TEST_UNUSED",
|
||||
purpose: "input",
|
||||
redaction: "replace-with-secret-reference",
|
||||
});
|
||||
expect(browserScenarioSchema.safeParse(unused).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects secret query values without named redaction", () => {
|
||||
const scenario = baseScenario();
|
||||
scenario.redaction.query_parameter_names = [];
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects literal credential headers and provider-owned headers", () => {
|
||||
for (const name of ["authorization", "content-length"]) {
|
||||
const scenario = baseScenario();
|
||||
scenario.request_replay.routes[0]!.response = {
|
||||
kind: "response",
|
||||
status: 200,
|
||||
headers: [{ name, value: literal("raw") }],
|
||||
} as never;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects raw secret-shaped action values", () => {
|
||||
const scenario = baseScenario();
|
||||
scenario.actions[0] = {
|
||||
...scenario.actions[0],
|
||||
value: "raw-password",
|
||||
} as never;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects embedded URL credentials and query values", () => {
|
||||
for (const url of [
|
||||
"https://user:password@app.example.test/",
|
||||
"https://app.example.test/?token=raw",
|
||||
]) {
|
||||
const scenario = baseScenario();
|
||||
scenario.start_url.url = url;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires explicit external ownership for CDP connections", () => {
|
||||
const scenario = baseScenario();
|
||||
scenario.browser = {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "page-1",
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
} as never;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(true);
|
||||
|
||||
scenario.browser.cleanup = "close-browser" as never;
|
||||
expect(browserScenarioSchema.safeParse(scenario).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { dirname } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { BrowserScenarioCapturePort } from "../src/application/BrowserScenarioCapturePort.js";
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js";
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../src/application/AnalysisProvider.js";
|
||||
import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js";
|
||||
import { parseConfig } from "../src/config.js";
|
||||
import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../src/domain/browserScenario.js";
|
||||
import {
|
||||
browserScenarioCaptureSchema,
|
||||
type BrowserScenarioCapture,
|
||||
} from "../src/domain/browserScenarioCapture.js";
|
||||
import type { AnalysisError } from "../src/domain/errors.js";
|
||||
import { ok, type Result } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
|
||||
const providerIdentity: ProviderIdentity = {
|
||||
id: "test-browser-scenario",
|
||||
name: "Test browser scenario provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
class FakeBrowserScenarioProvider implements BrowserScenarioCapturePort {
|
||||
readonly scenarios: BrowserScenario[] = [];
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
return providerIdentity;
|
||||
}
|
||||
|
||||
captureScenario(
|
||||
scenario: BrowserScenario,
|
||||
_options?: ExecutionOptions,
|
||||
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
|
||||
this.scenarios.push(scenario);
|
||||
return Promise.resolve(ok(captureFor(scenario)));
|
||||
}
|
||||
}
|
||||
|
||||
const artifacts = {
|
||||
screenshot: { state: "not_requested" },
|
||||
dom: { state: "not_requested" },
|
||||
accessibility: { state: "not_requested" },
|
||||
url: { state: "not_requested" },
|
||||
history: { state: "not_requested" },
|
||||
storage: { state: "not_requested" },
|
||||
} as const;
|
||||
|
||||
const captureFor = (scenario: BrowserScenario): BrowserScenarioCapture => {
|
||||
const start = scenario.start_url.url;
|
||||
const step = (stepIndex: number, stepId: string, action: string) => ({
|
||||
step_index: stepIndex,
|
||||
step_id: stepId,
|
||||
action,
|
||||
status: "completed",
|
||||
elapsed_ms: 1,
|
||||
before_url: sanitizeBrowserUrl(start),
|
||||
after_url: sanitizeBrowserUrl(start),
|
||||
error: null,
|
||||
event_sequence_start: 1,
|
||||
event_sequence_end: 0,
|
||||
artifacts,
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
});
|
||||
return browserScenarioCaptureSchema.parse({
|
||||
schema_version: 1,
|
||||
browser: {
|
||||
mode: scenario.browser.mode,
|
||||
process_ownership: "provider-owned",
|
||||
cleanup: "terminated-owned-process",
|
||||
product: "Test Browser",
|
||||
version: "1",
|
||||
},
|
||||
scenario: {
|
||||
start_origin: new URL(start).origin,
|
||||
allowed_origins: scenario.allowed_origins,
|
||||
action_count: scenario.actions.length,
|
||||
secret_references: scenario.secrets.map(({ secret_id: id }) => id),
|
||||
},
|
||||
duration_ms: 2,
|
||||
steps: [
|
||||
step(0, "scenario_start", "goto_start"),
|
||||
...scenario.actions.map((action, index) =>
|
||||
step(index + 1, action.step_id, action.action),
|
||||
),
|
||||
],
|
||||
events: { retained: 0, dropped: 0, items: [] },
|
||||
completeness: {
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
missing_sections: [],
|
||||
truncated_sections: [],
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
const scenario = (origin = "https://app.example.test") => ({
|
||||
schema_version: 1,
|
||||
browser: {
|
||||
mode: "launch",
|
||||
executable_path: process.execPath,
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: { url: `${origin}/`, query: [] },
|
||||
allowed_origins: [origin],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "password",
|
||||
action: "fill",
|
||||
locator: { kind: "test_id", value: "password" },
|
||||
value: { source: "secret", secret_id: "password" },
|
||||
},
|
||||
],
|
||||
storage: {},
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [
|
||||
{
|
||||
secret_id: "password",
|
||||
environment_variable: "REA_TEST_PASSWORD",
|
||||
purpose: "input",
|
||||
redaction: "replace-with-secret-reference",
|
||||
},
|
||||
],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: [],
|
||||
},
|
||||
capture: { after_each_step: [], at_end: [], events: [] },
|
||||
limits: {
|
||||
max_duration_ms: 10_000,
|
||||
action_timeout_ms: 1_000,
|
||||
navigation_timeout_ms: 1_000,
|
||||
max_events: 100,
|
||||
max_frames: 10,
|
||||
max_workers: 5,
|
||||
max_popups: 2,
|
||||
max_websockets: 10,
|
||||
max_dom_nodes: 1_000,
|
||||
max_accessibility_nodes: 1_000,
|
||||
max_screenshots: 2,
|
||||
max_screenshot_bytes: 1_048_576,
|
||||
max_storage_entries: 100,
|
||||
max_total_metadata_bytes: 1_048_576,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
|
||||
describe("browser scenario MCP tool", () => {
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(resources.splice(0).map((item) => item.close()));
|
||||
});
|
||||
|
||||
test("authorizes exact scope, records Evidence, and retains no secret value", async () => {
|
||||
const configured = parseConfig({
|
||||
REA_BROWSER_SCENARIO_ENABLED: "true",
|
||||
REA_BROWSER_SCENARIO_AUTO_GRANT: "true",
|
||||
REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([
|
||||
dirname(process.execPath),
|
||||
]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]',
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: '["REA_TEST_PASSWORD"]',
|
||||
});
|
||||
if (!configured.ok) throw configured.error;
|
||||
const authority = await loadConfiguredPermissionAuthority(configured.value);
|
||||
if (!authority.ok) throw authority.error;
|
||||
const provider = new FakeBrowserScenarioProvider();
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const server = createServer(session, session, {
|
||||
browserObservation: new CdpBrowserProvider(),
|
||||
browserScenarioCapture: provider,
|
||||
permissionAuthority: authority.value,
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 0,
|
||||
browserObservationEnabled: true,
|
||||
browserScenarioEnabled: true,
|
||||
}),
|
||||
});
|
||||
const client = new Client({ name: "browser-scenario-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const captured = await client.callTool({
|
||||
name: "capture_browser_scenario",
|
||||
arguments: scenario(),
|
||||
});
|
||||
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
|
||||
expect(captured.structuredContent).toMatchObject({
|
||||
result: {
|
||||
scenario: {
|
||||
secret_references: ["password"],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(provider.scenarios).toHaveLength(1);
|
||||
expect(JSON.stringify(captured.structuredContent)).not.toContain(
|
||||
"correct horse battery staple",
|
||||
);
|
||||
const evidenceId = Reflect.get(
|
||||
captured.structuredContent ?? {},
|
||||
"evidence_id",
|
||||
);
|
||||
expect(session.evidenceById(String(evidenceId))).toMatchObject({
|
||||
predicate_type: "rea.browser-scenario-capture/v1",
|
||||
});
|
||||
const captureResult = Reflect.get(
|
||||
captured.structuredContent ?? {},
|
||||
"result",
|
||||
);
|
||||
const compared = await client.callTool({
|
||||
name: "compare_web_captures",
|
||||
arguments: {
|
||||
before_scenario: captureResult,
|
||||
after_scenario: captureResult,
|
||||
normalization: { rules: [] },
|
||||
},
|
||||
});
|
||||
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
|
||||
expect(compared.structuredContent).toMatchObject({
|
||||
result: {
|
||||
comparison_kind: "browser_scenario",
|
||||
overall_status: "unchanged",
|
||||
alignment: { status: "aligned", aligned_steps: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
const denied = await client.callTool({
|
||||
name: "capture_browser_scenario",
|
||||
arguments: scenario("https://other.example.test"),
|
||||
});
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(provider.scenarios).toHaveLength(1);
|
||||
|
||||
const unapprovedEnvironment = scenario();
|
||||
const secretDeclaration = unapprovedEnvironment.secrets.at(0);
|
||||
if (secretDeclaration === undefined)
|
||||
throw new Error("Scenario secret fixture disappeared");
|
||||
secretDeclaration.environment_variable = "REA_UNAPPROVED_SECRET";
|
||||
const deniedSecret = await client.callTool({
|
||||
name: "capture_browser_scenario",
|
||||
arguments: unapprovedEnvironment,
|
||||
});
|
||||
expect(deniedSecret.isError).toBe(true);
|
||||
expect(provider.scenarios).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BrowserScenarioSessionPort } from "../src/browser/BrowserScenarioSessionPort.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../src/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js";
|
||||
import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js";
|
||||
import {
|
||||
browserScenarioSchema,
|
||||
type BrowserScenario,
|
||||
type BrowserScenarioAction,
|
||||
} from "../src/domain/browserScenario.js";
|
||||
import {
|
||||
browserStepArtifactsSchema,
|
||||
type BrowserScenarioEvent,
|
||||
type BrowserStepArtifacts,
|
||||
} from "../src/domain/browserScenarioCapture.js";
|
||||
|
||||
const scenario = (
|
||||
options: {
|
||||
readonly mode?: "launch" | "connect";
|
||||
readonly captures?: readonly (
|
||||
| "screenshot"
|
||||
| "dom"
|
||||
| "accessibility"
|
||||
| "url"
|
||||
| "history"
|
||||
| "storage"
|
||||
)[];
|
||||
readonly events?: readonly (
|
||||
| "console"
|
||||
| "page-errors"
|
||||
| "network"
|
||||
| "websockets"
|
||||
| "frames"
|
||||
| "workers"
|
||||
| "popups"
|
||||
| "downloads"
|
||||
)[];
|
||||
readonly actions?: number;
|
||||
} = {},
|
||||
): BrowserScenario =>
|
||||
browserScenarioSchema.parse({
|
||||
schema_version: 1,
|
||||
browser:
|
||||
options.mode === "connect"
|
||||
? {
|
||||
mode: "connect",
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "page-1",
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
}
|
||||
: {
|
||||
mode: "launch",
|
||||
executable_path: "/opt/chromium",
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
start_url: { url: "https://app.example.test/" },
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: Array.from({ length: options.actions ?? 1 }, (_, index) => ({
|
||||
step_id: `wait_${index}`,
|
||||
action: "wait_for_timeout",
|
||||
duration_ms: 1,
|
||||
})),
|
||||
storage: {},
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: [],
|
||||
},
|
||||
capture: {
|
||||
after_each_step: options.captures ?? ["url"],
|
||||
at_end: [],
|
||||
events: options.events ?? [],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 10_000,
|
||||
action_timeout_ms: 1_000,
|
||||
navigation_timeout_ms: 1_000,
|
||||
max_events: 100,
|
||||
max_frames: 10,
|
||||
max_workers: 10,
|
||||
max_popups: 10,
|
||||
max_websockets: 10,
|
||||
max_dom_nodes: 100,
|
||||
max_accessibility_nodes: 100,
|
||||
max_screenshots: 2,
|
||||
max_screenshot_bytes: 1_024,
|
||||
max_storage_entries: 10,
|
||||
max_total_metadata_bytes: 16_384,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
|
||||
type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number];
|
||||
|
||||
const artifactState = (
|
||||
kind: SnapshotKind,
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
incomplete: boolean,
|
||||
) => {
|
||||
if (!requested.has(kind)) return { state: "not_requested" as const };
|
||||
if (kind === "url")
|
||||
return {
|
||||
state: "captured" as const,
|
||||
value: sanitizeBrowserUrl("https://app.example.test/current"),
|
||||
};
|
||||
if (incomplete && kind === "screenshot")
|
||||
return {
|
||||
state: "truncated" as const,
|
||||
observed: 2_048,
|
||||
retained: 0,
|
||||
reason: "fixture limit",
|
||||
};
|
||||
return { state: "missing" as const, reason: "fixture unavailable" };
|
||||
};
|
||||
|
||||
class FakeSession implements BrowserScenarioSessionPort {
|
||||
readonly product = "Fake Chromium";
|
||||
readonly version = "1";
|
||||
readonly initialUrl = "about:blank";
|
||||
readonly processOwnership: "provider-owned" | "external";
|
||||
closeCalls = 0;
|
||||
performCalls = 0;
|
||||
captureCalls = 0;
|
||||
eventTruncations: (
|
||||
| "events"
|
||||
| "frames"
|
||||
| "workers"
|
||||
| "popups"
|
||||
| "websockets"
|
||||
)[] = [];
|
||||
onPerform: (() => void) | undefined;
|
||||
private step = 0;
|
||||
|
||||
constructor(
|
||||
readonly mode: "launch" | "connect",
|
||||
private readonly incomplete = false,
|
||||
private readonly failAction = false,
|
||||
private readonly failCapture = false,
|
||||
) {
|
||||
this.processOwnership = mode === "launch" ? "provider-owned" : "external";
|
||||
}
|
||||
|
||||
currentUrl() {
|
||||
return "https://app.example.test/current";
|
||||
}
|
||||
|
||||
setStep(index: number) {
|
||||
this.step = index;
|
||||
}
|
||||
|
||||
nextEventSequence() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
lastEventSequence() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
events(): {
|
||||
readonly retained: number;
|
||||
readonly dropped: number;
|
||||
readonly items: readonly BrowserScenarioEvent[];
|
||||
} {
|
||||
return { retained: 0, dropped: 0, items: [] };
|
||||
}
|
||||
|
||||
eventTruncationSections() {
|
||||
return this.eventTruncations;
|
||||
}
|
||||
|
||||
async perform(
|
||||
_action: BrowserScenarioAction,
|
||||
_timeout: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
this.performCalls += 1;
|
||||
this.onPerform?.();
|
||||
if (signal?.aborted === true) throw new Error("request cancelled");
|
||||
if (this.failAction) throw new Error("fixture secret action failure");
|
||||
}
|
||||
|
||||
capture(
|
||||
requested: ReadonlySet<SnapshotKind>,
|
||||
_budget: BrowserScenarioCaptureBudget,
|
||||
): Promise<BrowserStepArtifacts> {
|
||||
this.captureCalls += 1;
|
||||
if (this.failCapture) return Promise.reject(new Error("capture failed"));
|
||||
return Promise.resolve(
|
||||
browserStepArtifactsSchema.parse({
|
||||
screenshot: artifactState("screenshot", requested, this.incomplete),
|
||||
dom: artifactState("dom", requested, this.incomplete),
|
||||
accessibility: artifactState(
|
||||
"accessibility",
|
||||
requested,
|
||||
this.incomplete,
|
||||
),
|
||||
url: artifactState("url", requested, this.incomplete),
|
||||
history: artifactState("history", requested, this.incomplete),
|
||||
storage: artifactState("storage", requested, this.incomplete),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCalls += 1;
|
||||
return Promise.resolve(
|
||||
this.mode === "launch"
|
||||
? ("terminated-owned-process" as const)
|
||||
: ("disconnected-external" as const),
|
||||
);
|
||||
}
|
||||
|
||||
redactError(error: unknown) {
|
||||
return error instanceof Error
|
||||
? error.message.replaceAll("secret", "[REDACTED]")
|
||||
: "fixture failure";
|
||||
}
|
||||
}
|
||||
|
||||
describe("PlaywrightBrowserScenarioProvider", () => {
|
||||
it("captures initial and action steps and closes its launched session", async () => {
|
||||
const session = new FakeSession("launch");
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(scenario());
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.steps.map(({ step_index }) => step_index)).toEqual([
|
||||
0, 1,
|
||||
]);
|
||||
expect(result.value.browser).toMatchObject({
|
||||
process_ownership: "provider-owned",
|
||||
cleanup: "terminated-owned-process",
|
||||
});
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "complete",
|
||||
equality_eligible: true,
|
||||
});
|
||||
expect(session.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("makes missing and truncated captures ineligible for equality", async () => {
|
||||
const session = new FakeSession("launch", true);
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(
|
||||
scenario({ captures: ["screenshot", "dom"] }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "truncated",
|
||||
equality_eligible: false,
|
||||
missing_sections: ["dom"],
|
||||
truncated_sections: ["screenshot"],
|
||||
});
|
||||
});
|
||||
|
||||
it("makes event limit truncation ineligible for equality", async () => {
|
||||
const session = new FakeSession("launch");
|
||||
session.eventTruncations = ["events", "websockets"];
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(
|
||||
scenario({ events: ["websockets"] }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "truncated",
|
||||
equality_eligible: false,
|
||||
truncated_sections: ["events", "websockets"],
|
||||
});
|
||||
});
|
||||
|
||||
it("records one failed step, cancels later actions, and still cleans up", async () => {
|
||||
const session = new FakeSession("launch", false, true);
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(scenario({ actions: 2 }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.steps.map(({ status }) => status)).toEqual([
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
expect(result.value.steps[1]?.error).toBe(
|
||||
"fixture [REDACTED] action failure",
|
||||
);
|
||||
expect(session.performCalls).toBe(1);
|
||||
expect(session.closeCalls).toBe(1);
|
||||
expect(result.value.completeness.equality_eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("disconnects external CDP sessions and marks pre-attach events missing", async () => {
|
||||
const session = new FakeSession("connect");
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(
|
||||
scenario({ mode: "connect" }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.browser).toMatchObject({
|
||||
process_ownership: "external",
|
||||
cleanup: "disconnected-external",
|
||||
});
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
status: "incomplete",
|
||||
equality_eligible: false,
|
||||
missing_sections: ["events"],
|
||||
});
|
||||
expect(session.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("closes the session when initial capture fails", async () => {
|
||||
const session = new FakeSession("launch", false, false, true);
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(scenario());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(session.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("does not open a session for an already-cancelled request", async () => {
|
||||
let opens = 0;
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => {
|
||||
opens += 1;
|
||||
return Promise.resolve(new FakeSession("launch"));
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await provider.captureScenario(scenario(), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(opens).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels an active action and still closes the session", async () => {
|
||||
const controller = new AbortController();
|
||||
const session = new FakeSession("launch");
|
||||
session.onPerform = () => controller.abort();
|
||||
const provider = new PlaywrightBrowserScenarioProvider({
|
||||
open: () => Promise.resolve(session),
|
||||
});
|
||||
const result = await provider.captureScenario(scenario(), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.steps[1]?.status).toBe("cancelled");
|
||||
expect(session.closeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowserScenarioCaptureBudget", () => {
|
||||
it("enforces cumulative screenshot and metadata limits", () => {
|
||||
const budget = new BrowserScenarioCaptureBudget(1, 10);
|
||||
expect(budget.claimScreenshot()).toBe(true);
|
||||
expect(budget.claimScreenshot()).toBe(false);
|
||||
expect(budget.claimMetadata(6)).toBe(true);
|
||||
expect(budget.claimMetadata(5)).toBe(false);
|
||||
expect(budget.claimMetadata(4)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withPlaywrightExecutionBoundary } from "../src/browser/PlaywrightExecutionBoundary.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../src/domain/errors.js";
|
||||
|
||||
describe("withPlaywrightExecutionBoundary", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("rejects work that exceeds its absolute remaining duration", async () => {
|
||||
vi.useFakeTimers();
|
||||
const result = withPlaywrightExecutionBoundary(
|
||||
() => new Promise<never>(() => undefined),
|
||||
25,
|
||||
);
|
||||
const rejection =
|
||||
expect(result).rejects.toBeInstanceOf(AnalysisTimeoutError);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("rejects when the caller cancels active work", async () => {
|
||||
const controller = new AbortController();
|
||||
const result = withPlaywrightExecutionBoundary(
|
||||
() => new Promise<never>(() => undefined),
|
||||
1_000,
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user