fix(browser): serialize attached scenarios through cleanup settlement
This commit is contained in:
parent
4940a867e2
commit
cfce91d418
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue