diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 208a314a2..059a63599 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -11,9 +11,14 @@ export async function startBrowserVerifierSite() { response.destroy(); return; } - if (request.url === "/scenario-environment") { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + if (requestUrl.pathname === "/scenario-environment") { + const requestedRun = requestUrl.searchParams.get("run"); + const run = ["A", "B", "C"].includes(requestedRun) + ? requestedRun + : "default"; response.setHeader("content-type", "text/html"); - response.end(`
+ response.end(` `); return; diff --git a/scripts/lib/browser-scenario-environment-e2e.mjs b/scripts/lib/browser-scenario-environment-e2e.mjs index bc460192d..1804402f3 100644 --- a/scripts/lib/browser-scenario-environment-e2e.mjs +++ b/scripts/lib/browser-scenario-environment-e2e.mjs @@ -17,43 +17,68 @@ const expected = { width: 900, height: 600, }; +const alternate = { + timezone: "UTC", + locale: "en-US", + dpr: 1, + width: 700, + height: 500, +}; const expression = `JSON.stringify({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, locale: Intl.DateTimeFormat().resolvedOptions().locale, dpr: devicePixelRatio, width: innerWidth, height: innerHeight })`; -const environmentScenario = (endpoint, targetId, origin) => +const environmentScenario = ({ endpoint, targetId, origin, values, run }) => browserScenarioSchema.parse({ browser: { mode: "connect", cdp_endpoint: endpoint, target_id: targetId }, - start_url: { url: `${origin}/scenario-environment` }, + start_url: { + url: `${origin}/scenario-environment${run === undefined ? "" : `?run=${run}`}`, + }, environment: { - locale: expected.locale, - timezone: expected.timezone, + locale: values.locale, + timezone: values.timezone, viewport: { - width: expected.width, - height: expected.height, - device_scale_factor: expected.dpr, + width: values.width, + height: values.height, + device_scale_factor: values.dpr, }, }, - actions: [ - { - step_id: "refresh", - action: "click", - locator: { kind: "css", selector: "#refresh-environment" }, - }, - ], + actions: + run === undefined + ? [ + { + step_id: "refresh", + action: "click", + locator: { kind: "css", selector: "#refresh-environment" }, + }, + ] + : [ + { + step_id: `gate_${run}`, + action: "wait_for", + locator: { kind: "css", selector: `#release-${run}` }, + state: "attached", + timeout_ms: 30_000, + }, + { + step_id: `refresh_${run}`, + action: "click", + locator: { kind: "css", selector: "#refresh-environment" }, + }, + ], capture: { after_each_step: ["dom"], at_end: ["dom"] }, }); -const assertCapture = (capture) => { +const assertCapture = (capture, values = expected) => { assert.equal(capture.browser.cleanup, "disconnected-external"); for (const step of capture.steps) { assert.equal(step.artifacts.dom.state, "captured"); const text = step.artifacts.dom.value.text; const observed = /([^<]+)<\/pre>/u.exec(text);
assert.ok(observed, "DOM omitted the page's actual environment values");
- assert.deepEqual(JSON.parse(observed[1]), expected);
+ assert.deepEqual(JSON.parse(observed[1]), values);
}
};
@@ -75,7 +100,12 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
return JSON.parse(value.result.value);
};
const baseline = await readEnvironment();
- const scenario = environmentScenario(endpoint, targetId, origin);
+ const scenario = environmentScenario({
+ endpoint,
+ targetId,
+ origin,
+ values: expected,
+ });
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
@@ -105,6 +135,15 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
"capture_browser_scenario",
),
);
+ await verifyConcurrentMcpCapture({
+ client,
+ connection,
+ endpoint,
+ targetId,
+ origin,
+ readEnvironment,
+ assertRestored,
+ });
} finally {
await client.close();
await transport.close();
@@ -157,6 +196,125 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
}
}
+const verifyConcurrentMcpCapture = async ({
+ client,
+ connection,
+ endpoint,
+ targetId,
+ origin,
+ readEnvironment,
+ assertRestored,
+}) => {
+ const firstScenario = environmentScenario({
+ endpoint,
+ targetId,
+ origin,
+ values: expected,
+ run: "A",
+ });
+ const secondScenario = environmentScenario({
+ endpoint,
+ targetId,
+ origin,
+ values: alternate,
+ run: "B",
+ });
+ const firstCancellation = new AbortController();
+ const secondCancellation = new AbortController();
+ const cancellation = new AbortController();
+ const pendingCalls = [];
+ const callTool = (arguments_, signal) => {
+ const pending = client.callTool(
+ { name: "capture_browser_scenario", arguments: arguments_ },
+ { signal, timeout: 60_000 },
+ );
+ void pending.catch(() => {});
+ pendingCalls.push(pending);
+ return pending;
+ };
+
+ try {
+ const first = callTool(firstScenario, firstCancellation.signal);
+ await waitForEnvironment(readEnvironment, connection, expected, "A");
+
+ const second = callTool(secondScenario, secondCancellation.signal);
+ await client.ping();
+ assert.deepEqual(await readEnvironment(), expected);
+ assert.equal(await readScenarioRun(connection), "A");
+
+ const cancelled = callTool(
+ environmentScenario({
+ endpoint,
+ targetId,
+ origin,
+ values: alternate,
+ run: "C",
+ }),
+ cancellation.signal,
+ );
+ await client.ping();
+ cancellation.abort(new Error("cancel queued browser scenario"));
+ await assert.rejects(cancelled, /cancel queued browser scenario/u);
+ assert.deepEqual(await readEnvironment(), expected);
+ assert.equal(await readScenarioRun(connection), "A");
+
+ await releaseScenario(connection);
+ assertCapture(
+ requireMcpEvidenceResult(await first, "capture_browser_scenario"),
+ expected,
+ );
+
+ await waitForEnvironment(readEnvironment, connection, alternate, "B");
+ await releaseScenario(connection);
+ assertCapture(
+ requireMcpEvidenceResult(await second, "capture_browser_scenario"),
+ alternate,
+ );
+ await client.ping();
+ await assertRestored();
+ } finally {
+ firstCancellation.abort();
+ secondCancellation.abort();
+ cancellation.abort();
+ await releaseScenario(connection).catch(() => undefined);
+ await Promise.allSettled(pendingCalls);
+ }
+};
+
+const waitForEnvironment = async (
+ readEnvironment,
+ connection,
+ expectedValues,
+ run,
+) => {
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ if (
+ JSON.stringify(await readEnvironment()) ===
+ JSON.stringify(expectedValues) &&
+ (await readScenarioRun(connection)) === run
+ )
+ return;
+ await delay(25);
+ }
+ throw new Error(`Scenario ${run} never reached its page-controlled gate`);
+};
+
+const readScenarioRun = async (connection) => {
+ const value = await connection.send("Runtime.evaluate", {
+ expression: "document.body.dataset.run ?? null",
+ returnByValue: true,
+ });
+ return value.result.value;
+};
+
+const releaseScenario = async (connection) => {
+ await connection.send("Runtime.evaluate", {
+ expression: "window.releaseScenario()",
+ returnByValue: true,
+ });
+};
+
async function verifyCancelledEnvironment(
provider,
connection,
diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
index b66cecb20..af94b7f90 100644
--- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts
+++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
@@ -529,9 +529,11 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
const finishEvents = vi
.fn<() => Promise>()
.mockResolvedValue(undefined);
+ const cleanupSettlements: boolean[] = [];
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: browserClose,
removeProfile,
+ onSettled: (browserClosed) => cleanupSettlements.push(browserClosed),
});
const first = cleanup.close(finishEvents);
@@ -541,12 +543,14 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
expect(browserClose).toHaveBeenCalledTimes(1);
expect(removeProfile).not.toHaveBeenCalled();
expect(finishEvents).toHaveBeenCalledTimes(1);
+ expect(cleanupSettlements).toEqual([false]);
await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
expect(browserClose).toHaveBeenCalledTimes(2);
expect(removeProfile).toHaveBeenCalledTimes(1);
expect(finishEvents).toHaveBeenCalledTimes(1);
+ expect(cleanupSettlements).toEqual([false, true]);
});
it("keeps failed event finalization failed across cleanup retries", async () => {
diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts
index 970decfd9..4ee0a3f1f 100644
--- a/src/browser/PlaywrightScenarioBrowser.test.ts
+++ b/src/browser/PlaywrightScenarioBrowser.test.ts
@@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from "vitest";
+import type { Browser } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import {
@@ -8,6 +9,14 @@ import {
afterEach(() => vi.unstubAllEnvs());
+const deferred = () => {
+ let resolve: (value: Value | PromiseLike) => void = () => undefined;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+};
+
it("passes the selected environment to the actual browser launch boundary", async () => {
vi.stubEnv("LANG", "ambient-language");
const selected = {
@@ -25,12 +34,14 @@ it("passes the selected environment to the actual browser launch boundary", asyn
});
await expect(
openPlaywrightScenarioBrowser(scenario, selected, {
- connectOverCDP: async () => {
- throw new Error("Unexpected connection");
- },
- launchPersistentContext: async (_profile, options) => {
- launchedEnvironment = options?.env;
- throw new Error("launch boundary reached");
+ launcher: {
+ connectOverCDP: async () => {
+ throw new Error("Unexpected connection");
+ },
+ launchPersistentContext: async (_profile, options) => {
+ launchedEnvironment = options?.env;
+ throw new Error("launch boundary reached");
+ },
},
}),
).rejects.toThrow("launch boundary reached");
@@ -40,27 +51,244 @@ it("passes the selected environment to the actual browser launch boundary", asyn
});
});
-it("returns cancellation instead of success when an attached browser hangs on close", async () => {
+it("keeps attached-target ownership until cancelled cleanup actually settles", async () => {
const controller = new AbortController();
- let resolveCloseStarted: () => void = () => undefined;
- const closeStarted = new Promise((resolve) => {
- resolveCloseStarted = resolve;
- });
+ const closeStarted = deferred();
+ const closeResult = deferred();
+ const settled: boolean[] = [];
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: () => {
- resolveCloseStarted();
- return new Promise(() => undefined);
+ closeStarted.resolve(undefined);
+ return closeResult.promise;
},
removeProfile: undefined,
+ onSettled: (browserClosed) => settled.push(browserClosed),
});
- const closing = cleanup.close(undefined, controller.signal);
-
- await closeStarted;
+ const cancelledClose = cleanup.close(undefined, controller.signal);
+ await closeStarted.promise;
controller.abort();
- await expect(closing).rejects.toMatchObject({
+ await expect(cancelledClose).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
cleanupIncomplete: true,
userCategory: "cancelled",
});
+ expect(settled).toEqual([]);
+
+ closeResult.resolve(undefined);
+ await cleanup.close();
+ expect(settled).toEqual([true]);
+});
+
+it("keeps attached-target ownership after the cleanup response timeout", async () => {
+ vi.useFakeTimers();
+ try {
+ const closeStarted = deferred();
+ const closeResult = deferred();
+ const settled: boolean[] = [];
+ const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
+ closeBrowser: () => {
+ closeStarted.resolve(undefined);
+ return closeResult.promise;
+ },
+ removeProfile: undefined,
+ onSettled: (browserClosed) => settled.push(browserClosed),
+ });
+ const timedOutClose = cleanup.close();
+ const timeoutExpectation = expect(timedOutClose).rejects.toMatchObject({
+ reason: "cleanup_failed",
+ cleanup: { reason: expect.stringContaining("timed out") },
+ });
+ await closeStarted.promise;
+ await vi.advanceTimersByTimeAsync(1_000);
+ await timeoutExpectation;
+ expect(settled).toEqual([]);
+
+ closeResult.resolve(undefined);
+ await cleanup.close();
+ expect(settled).toEqual([true]);
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+it("does not configure a late attached connection after cancellation", async () => {
+ const connection =
+ deferred>();
+ const connectionStarted = deferred();
+ const connectedBrowser = {
+ close: vi.fn(async () => undefined),
+ contexts: vi.fn(() => []),
+ version: () => "fixture",
+ };
+ const controller = new AbortController();
+ const launcher = {
+ connectOverCDP: () => {
+ connectionStarted.resolve(undefined);
+ return connection.promise;
+ },
+ launchPersistentContext: async () => {
+ throw new Error("Unexpected launch");
+ },
+ };
+ const scenario = browserScenarioSchema.parse({
+ browser: {
+ mode: "connect",
+ cdp_endpoint: "http://127.0.0.1:9222",
+ target_id: "page-1",
+ },
+ start_url: { url: "https://example.test" },
+ actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+ });
+ const opening = openPlaywrightScenarioBrowser(
+ scenario,
+ {},
+ {
+ launcher,
+ signal: controller.signal,
+ },
+ );
+ await connectionStarted.promise;
+ controller.abort();
+ connection.resolve(connectedBrowser);
+
+ await expect(opening).rejects.toMatchObject({
+ _tag: "BrowserObservationError",
+ reason: "cancelled",
+ });
+ expect(connectedBrowser.contexts).not.toHaveBeenCalled();
+ expect(connectedBrowser.close).toHaveBeenCalledOnce();
+});
+
+it("closes an attached connection when cancellation starts target discovery", async () => {
+ const controller = new AbortController();
+ const connectedBrowser = {
+ close: vi.fn(async () => undefined),
+ contexts: vi.fn(() => {
+ controller.abort();
+ return [];
+ }),
+ version: () => "fixture",
+ };
+ const launcher = {
+ connectOverCDP: async () => connectedBrowser,
+ launchPersistentContext: async () => {
+ throw new Error("Unexpected launch");
+ },
+ };
+ const scenario = browserScenarioSchema.parse({
+ browser: {
+ mode: "connect",
+ cdp_endpoint: "http://127.0.0.1:9222",
+ target_id: "page-1",
+ },
+ start_url: { url: "https://example.test" },
+ actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+ });
+
+ await expect(
+ openPlaywrightScenarioBrowser(
+ scenario,
+ {},
+ {
+ launcher,
+ signal: controller.signal,
+ },
+ ),
+ ).rejects.toMatchObject({
+ _tag: "AnalysisCancelledError",
+ });
+ expect(connectedBrowser.contexts).toHaveBeenCalledOnce();
+ expect(connectedBrowser.close).toHaveBeenCalledOnce();
+});
+
+it("cancels same-target queued connection before acquiring a browser", async () => {
+ const makeScenario = (targetId: string) =>
+ browserScenarioSchema.parse({
+ browser: {
+ mode: "connect",
+ cdp_endpoint: "http://127.0.0.1:9222",
+ target_id: targetId,
+ },
+ start_url: { url: "https://example.test" },
+ actions: [
+ {
+ step_id: "wait",
+ action: "wait_for_timeout",
+ duration_ms: 1,
+ },
+ ],
+ });
+ let rejectFirst: ((cause: Error) => void) | undefined;
+ const firstConnection = new Promise((_resolve, reject) => {
+ rejectFirst = reject;
+ });
+ let resolveFirstStarted: (() => void) | undefined;
+ const firstStarted = new Promise((resolve) => {
+ resolveFirstStarted = resolve;
+ });
+ const endpoints: string[] = [];
+ const connectOverCDP = vi.fn((endpoint: string) => {
+ endpoints.push(endpoint);
+ if (endpoints.length === 1) {
+ resolveFirstStarted?.();
+ return firstConnection;
+ }
+ return Promise.reject(new Error("connection failed"));
+ });
+ const launchPersistentContext = vi.fn(async () => {
+ throw new Error("launch boundary reached");
+ });
+ const launcher = { connectOverCDP, launchPersistentContext };
+ const environment = {};
+ const first = openPlaywrightScenarioBrowser(
+ makeScenario("page-1"),
+ environment,
+ { launcher },
+ );
+ await firstStarted;
+ expect(connectOverCDP).toHaveBeenCalledTimes(1);
+
+ const controller = new AbortController();
+ const removeAbort = vi.spyOn(controller.signal, "removeEventListener");
+ const queued = openPlaywrightScenarioBrowser(
+ makeScenario("page-1"),
+ environment,
+ { launcher, signal: controller.signal },
+ );
+ controller.abort();
+ await expect(queued).rejects.toMatchObject({
+ _tag: "BrowserObservationError",
+ reason: "cancelled",
+ });
+ expect(connectOverCDP).toHaveBeenCalledTimes(1);
+ expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function));
+
+ await expect(
+ openPlaywrightScenarioBrowser(makeScenario("page-2"), environment, {
+ launcher,
+ }),
+ ).rejects.toThrow("connection failed");
+ expect(connectOverCDP).toHaveBeenCalledTimes(2);
+
+ const launch = browserScenarioSchema.parse({
+ browser: { mode: "launch", executable_path: process.execPath },
+ start_url: { url: "https://example.test" },
+ actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+ });
+ await expect(
+ openPlaywrightScenarioBrowser(launch, environment, { launcher }),
+ ).rejects.toThrow("launch boundary reached");
+ expect(launchPersistentContext).toHaveBeenCalledTimes(1);
+
+ if (rejectFirst === undefined)
+ throw new Error("first connection did not begin");
+ rejectFirst(new Error("first connection failed"));
+ await expect(first).rejects.toThrow("first connection failed");
+ await expect(
+ openPlaywrightScenarioBrowser(makeScenario("page-1"), environment, {
+ launcher,
+ }),
+ ).rejects.toThrow("connection failed");
+ expect(connectOverCDP).toHaveBeenCalledTimes(3);
});
diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts
index 392c96278..88fdda0f8 100644
--- a/src/browser/PlaywrightScenarioBrowser.ts
+++ b/src/browser/PlaywrightScenarioBrowser.ts
@@ -16,10 +16,15 @@ import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.j
const OPERATION = "capture_browser_scenario" as const;
+const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => {
+ if (signal?.aborted === true)
+ throw new BrowserObservationError(OPERATION, "cancelled");
+};
+
export interface OpenedScenarioBrowser {
readonly context: BrowserContext;
readonly page: Page;
- readonly browser: Browser;
+ readonly browser: Pick;
readonly profilePath: string | undefined;
readonly cleanup: PlaywrightScenarioBrowserCleanupOwner;
}
@@ -28,6 +33,7 @@ export interface OpenedScenarioBrowser {
interface PlaywrightScenarioCleanupResources {
readonly closeBrowser: () => Promise;
readonly removeProfile: (() => Promise) | undefined;
+ readonly onSettled?: (browserClosed: boolean) => void;
}
/** Owns one browser connection and optional private profile through cleanup retries. */
@@ -49,9 +55,13 @@ export class PlaywrightScenarioBrowserCleanupOwner {
const cleanup = this.#closePromise ?? this.#closeResources(finishEvents);
if (this.#closePromise === undefined) {
this.#closePromise = cleanup;
- void cleanup.catch(() => {
- if (this.#closePromise === cleanup) this.#closePromise = undefined;
- });
+ void cleanup.then(
+ () => this.resources.onSettled?.(this.#browserClosed),
+ () => {
+ if (this.#closePromise === cleanup) this.#closePromise = undefined;
+ this.resources.onSettled?.(this.#browserClosed);
+ },
+ );
}
return this.#awaitCleanup(cleanup, signal);
}
@@ -115,6 +125,116 @@ export class PlaywrightScenarioBrowserCleanupOwner {
}
}
+interface AttachedScenarioWaiter {
+ readonly signal: AbortSignal | undefined;
+ readonly resolve: (release: () => void) => void;
+ readonly reject: (error: BrowserObservationError) => void;
+ onAbort: (() => void) | undefined;
+}
+
+interface AttachedScenarioAdmission {
+ active: boolean;
+ readonly waiters: AttachedScenarioWaiter[];
+ cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
+}
+
+interface AttachedScenarioLease {
+ readonly admission: AttachedScenarioAdmission;
+ readonly release: () => void;
+}
+
+const attachedScenarioAdmissions = new Map();
+
+const releaseAttachedScenario = (
+ key: string,
+ admission: AttachedScenarioAdmission,
+): void => {
+ const waiter = admission.waiters.shift();
+ if (waiter === undefined) {
+ admission.active = false;
+ if (
+ admission.cleanupOwner === undefined &&
+ attachedScenarioAdmissions.get(key) === admission
+ )
+ attachedScenarioAdmissions.delete(key);
+ return;
+ }
+
+ if (waiter.onAbort !== undefined)
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
+ if (waiter.signal?.aborted === true) {
+ waiter.reject(new BrowserObservationError(OPERATION, "cancelled"));
+ releaseAttachedScenario(key, admission);
+ return;
+ }
+ waiter.resolve(createAttachedScenarioRelease(key, admission));
+};
+
+const createAttachedScenarioRelease = (
+ key: string,
+ admission: AttachedScenarioAdmission,
+): (() => void) => {
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ releaseAttachedScenario(key, admission);
+ };
+};
+
+const acquireAttachedScenario = (
+ scenario: Extract,
+ signal: AbortSignal | undefined,
+): Promise => {
+ if (signal?.aborted === true)
+ return Promise.reject(new BrowserObservationError(OPERATION, "cancelled"));
+
+ const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]);
+ const admission =
+ attachedScenarioAdmissions.get(key) ??
+ ({
+ active: false,
+ waiters: [],
+ cleanupOwner: undefined,
+ } satisfies AttachedScenarioAdmission);
+ attachedScenarioAdmissions.set(key, admission);
+
+ return new Promise((resolve, reject) => {
+ const grant = (release: () => void): void =>
+ resolve({ admission, release });
+ if (!admission.active) {
+ admission.active = true;
+ grant(createAttachedScenarioRelease(key, admission));
+ return;
+ }
+
+ const waiter: AttachedScenarioWaiter = {
+ signal,
+ resolve: (release) => grant(release),
+ reject,
+ onAbort: undefined,
+ };
+ admission.waiters.push(waiter);
+ if (signal !== undefined) {
+ const onAbort = (): void => {
+ const index = admission.waiters.indexOf(waiter);
+ if (index < 0) return;
+ admission.waiters.splice(index, 1);
+ signal.removeEventListener("abort", onAbort);
+ reject(new BrowserObservationError(OPERATION, "cancelled"));
+ if (!admission.active && admission.waiters.length === 0)
+ releaseAttachedScenario(key, admission);
+ };
+ waiter.onAbort = onAbort;
+ signal.addEventListener("abort", onAbort, { once: true });
+ if (signal.aborted) {
+ onAbort();
+ return;
+ }
+ }
+ });
+};
+
const allowedBrowserEnvironment = (
environment: Readonly>,
): Record => {
@@ -142,14 +262,17 @@ const allowedBrowserEnvironment = (
);
};
const findConnectedPage = async (
- browser: Browser,
+ browser: Pick,
targetId: string,
+ signal: AbortSignal | undefined,
): 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 {
+ throwIfScenarioCancelled(signal);
const { targetInfo } = await session.send("Target.getTargetInfo");
+ throwIfScenarioCancelled(signal);
if (targetInfo.targetId !== targetId) continue;
return { context, page };
} finally {
@@ -162,30 +285,36 @@ const findConnectedPage = async (
const configureAttachedEnvironment = async (
context: BrowserContext,
page: Page,
- scenario: BrowserScenario,
+ environment: BrowserScenario["environment"],
+ signal: AbortSignal | undefined,
): Promise => {
await page.setViewportSize({
- width: scenario.environment.viewport.width,
- height: scenario.environment.viewport.height,
+ width: environment.viewport.width,
+ height: environment.viewport.height,
});
+ throwIfScenarioCancelled(signal);
await page.emulateMedia({
- colorScheme: scenario.environment.color_scheme,
- reducedMotion: scenario.environment.reduced_motion,
+ colorScheme: environment.color_scheme,
+ reducedMotion: environment.reduced_motion,
});
+ throwIfScenarioCancelled(signal);
const session = await context.newCDPSession(page);
+ throwIfScenarioCancelled(signal);
// Chromium resets these overrides when the CDP session detaches. Keep it on
// the scenario transport until cleanup disconnects the attached browser.
await session.send("Emulation.setDeviceMetricsOverride", {
- width: scenario.environment.viewport.width,
- height: scenario.environment.viewport.height,
- deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
+ width: environment.viewport.width,
+ height: environment.viewport.height,
+ deviceScaleFactor: environment.viewport.device_scale_factor,
mobile: false,
});
+ throwIfScenarioCancelled(signal);
await session.send("Emulation.setLocaleOverride", {
- locale: scenario.environment.locale,
+ locale: environment.locale,
});
+ throwIfScenarioCancelled(signal);
await session.send("Emulation.setTimezoneOverride", {
- timezoneId: scenario.environment.timezone,
+ timezoneId: environment.timezone,
});
};
@@ -224,40 +353,97 @@ export const failBrowserScenarioOperation = async (
throw primaryFailure;
};
+type ScenarioBrowserLauncher = Pick<
+ typeof chromium,
+ "launchPersistentContext"
+> & {
+ readonly connectOverCDP: (
+ endpoint: string,
+ ) => Promise>;
+};
+
+type AttachedBrowserRequest = Extract<
+ BrowserScenario["browser"],
+ { readonly mode: "connect" }
+>;
+
+const openAttachedScenarioBrowser = async (
+ browserRequest: AttachedBrowserRequest,
+ environment: BrowserScenario["environment"],
+ launcher: ScenarioBrowserLauncher,
+ signal: AbortSignal | undefined,
+): Promise => {
+ const lease = await acquireAttachedScenario(browserRequest, signal);
+ let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
+ try {
+ const priorOwner = lease.admission.cleanupOwner;
+ if (priorOwner !== undefined) await priorOwner.close();
+ throwIfScenarioCancelled(signal);
+
+ const browser = await launcher.connectOverCDP(browserRequest.cdp_endpoint);
+ const cleanup: PlaywrightScenarioBrowserCleanupOwner =
+ new PlaywrightScenarioBrowserCleanupOwner({
+ closeBrowser: () => browser.close(),
+ onSettled: (browserClosed) => {
+ if (browserClosed && lease.admission.cleanupOwner === cleanup)
+ lease.admission.cleanupOwner = undefined;
+ lease.release();
+ },
+ removeProfile: undefined,
+ });
+ cleanupOwner = cleanup;
+ lease.admission.cleanupOwner = cleanup;
+ throwIfScenarioCancelled(signal);
+ const target = await withPlaywrightExecutionBoundary(
+ () => findConnectedPage(browser, browserRequest.target_id, signal),
+ undefined,
+ signal,
+ );
+ throwIfScenarioCancelled(signal);
+ await withPlaywrightExecutionBoundary(
+ () =>
+ configureAttachedEnvironment(
+ target.context,
+ target.page,
+ environment,
+ signal,
+ ),
+ undefined,
+ signal,
+ );
+ return {
+ ...target,
+ browser,
+ profilePath: undefined,
+ cleanup,
+ };
+ } catch (cause: unknown) {
+ if (cleanupOwner === undefined) {
+ lease.release();
+ throw cause;
+ }
+ const cleanup = cleanupOwner;
+ return failBrowserScenarioOperation(() => cleanup.close(), cause);
+ }
+};
+
/** Launch or attach using the caller-selected environment and browser engine. */
export const openPlaywrightScenarioBrowser = async (
scenario: BrowserScenario,
environment: Readonly>,
- launcher: Pick<
- typeof chromium,
- "connectOverCDP" | "launchPersistentContext"
- > = chromium,
+ options: {
+ readonly launcher?: ScenarioBrowserLauncher;
+ readonly signal?: AbortSignal | undefined;
+ } = {},
): Promise => {
- if (scenario.browser.mode === "connect") {
- const browser = await launcher.connectOverCDP(
- scenario.browser.cdp_endpoint,
- { timeout: 0 },
+ const launcher = options.launcher ?? chromium;
+ if (scenario.browser.mode === "connect")
+ return openAttachedScenarioBrowser(
+ scenario.browser,
+ scenario.environment,
+ launcher,
+ options.signal,
);
- const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
- closeBrowser: () => browser.close(),
- removeProfile: undefined,
- });
- try {
- const target = await findConnectedPage(
- browser,
- scenario.browser.target_id,
- );
- await configureAttachedEnvironment(target.context, target.page, scenario);
- return {
- ...target,
- browser,
- profilePath: undefined,
- cleanup,
- };
- } catch (cause: unknown) {
- return failBrowserScenarioOperation(() => cleanup.close(), cause);
- }
- }
const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
let context: BrowserContext;
diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts
index 26aa65fcd..c4ac54459 100644
--- a/src/browser/PlaywrightScenarioSession.ts
+++ b/src/browser/PlaywrightScenarioSession.ts
@@ -135,7 +135,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
if (secrets === undefined)
throw new BrowserObservationError(OPERATION, "secret_unavailable");
const startedAt = Date.now();
- const opening = openPlaywrightScenarioBrowser(scenario, environment);
+ const opening = openPlaywrightScenarioBrowser(scenario, environment, {
+ signal: options.signal,
+ });
let opened: OpenedScenarioBrowser;
let events: PlaywrightScenarioEvents | undefined;
try {