fix(browser): serialize attached scenarios through cleanup settlement

This commit is contained in:
morluto 2026-10-10 01:44:52 +08:00
parent 4940a867e2
commit cfce91d418
6 changed files with 668 additions and 80 deletions

View File

@ -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(`<!doctype html><html><body><pre id="environment"></pre>
response.end(`<!doctype html><html><body data-run="${run}"><pre id="environment"></pre>
<button id="refresh-environment">Refresh environment</button><script>
const refresh = () => document.querySelector('#environment').textContent = JSON.stringify({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@ -21,6 +26,11 @@ export async function startBrowserVerifierSite() {
dpr: devicePixelRatio, width: innerWidth, height: innerHeight
});
document.querySelector('#refresh-environment').onclick = refresh;
window.releaseScenario = () => {
const release = document.createElement('span');
release.id = 'release-' + document.body.dataset.run;
document.body.append(release);
};
refresh();
</script></body></html>`);
return;

View File

@ -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 id="environment">([^<]+)<\/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,

View File

@ -529,9 +529,11 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
const finishEvents = vi
.fn<() => Promise<void>>()
.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 () => {

View File

@ -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 = <Value>() => {
let resolve: (value: Value | PromiseLike<Value>) => void = () => undefined;
const promise = new Promise<Value>((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<void>((resolve) => {
resolveCloseStarted = resolve;
});
const closeStarted = deferred<void>();
const closeResult = deferred<void>();
const settled: boolean[] = [];
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser: () => {
resolveCloseStarted();
return new Promise<void>(() => 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<void>();
const closeResult = deferred<void>();
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<Pick<Browser, "contexts" | "close" | "version">>();
const connectionStarted = deferred<void>();
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<never>((_resolve, reject) => {
rejectFirst = reject;
});
let resolveFirstStarted: (() => void) | undefined;
const firstStarted = new Promise<void>((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);
});

View File

@ -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<Browser, "contexts" | "close" | "version">;
readonly profilePath: string | undefined;
readonly cleanup: PlaywrightScenarioBrowserCleanupOwner;
}
@ -28,6 +33,7 @@ export interface OpenedScenarioBrowser {
interface PlaywrightScenarioCleanupResources {
readonly closeBrowser: () => Promise<void>;
readonly removeProfile: (() => Promise<void>) | 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<string, AttachedScenarioAdmission>();
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<BrowserScenario["browser"], { readonly mode: "connect" }>,
signal: AbortSignal | undefined,
): Promise<AttachedScenarioLease> => {
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<string, string | undefined>>,
): Record<string, string | undefined> => {
@ -142,14 +262,17 @@ const allowedBrowserEnvironment = (
);
};
const findConnectedPage = async (
browser: Browser,
browser: Pick<Browser, "contexts">,
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<void> => {
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<Pick<Browser, "contexts" | "close" | "version">>;
};
type AttachedBrowserRequest = Extract<
BrowserScenario["browser"],
{ readonly mode: "connect" }
>;
const openAttachedScenarioBrowser = async (
browserRequest: AttachedBrowserRequest,
environment: BrowserScenario["environment"],
launcher: ScenarioBrowserLauncher,
signal: AbortSignal | undefined,
): Promise<OpenedScenarioBrowser> => {
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<Record<string, string | undefined>>,
launcher: Pick<
typeof chromium,
"connectOverCDP" | "launchPersistentContext"
> = chromium,
options: {
readonly launcher?: ScenarioBrowserLauncher;
readonly signal?: AbortSignal | undefined;
} = {},
): Promise<OpenedScenarioBrowser> => {
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;

View File

@ -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 {