mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
1407 lines
43 KiB
TypeScript
1407 lines
43 KiB
TypeScript
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { mkdir, writeFile, rm, access, symlink } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import type {
|
|
Browser,
|
|
BrowserContext,
|
|
CDPSession,
|
|
Page,
|
|
} from "playwright-core";
|
|
|
|
import { DEFAULT_CDP_COMMAND_TIMEOUT_MS } from "../domain/browserDeadline.js";
|
|
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
|
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
|
import {
|
|
attachedScenarioLeaseKey,
|
|
failBrowserScenarioOperation,
|
|
openPlaywrightScenarioBrowser,
|
|
PlaywrightScenarioBrowserCleanupOwner,
|
|
} from "./PlaywrightScenarioBrowser.js";
|
|
|
|
const hostPlatform = process.platform;
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
Object.defineProperty(process, "platform", { value: hostPlatform });
|
|
});
|
|
|
|
it("locks one attached browser for spellings that discover the same endpoint", () => {
|
|
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe(
|
|
attachedScenarioLeaseKey("http://127.0.0.1:9222/", "page-1"),
|
|
);
|
|
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).not.toBe(
|
|
attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-2"),
|
|
);
|
|
});
|
|
|
|
it("locks one attached browser across loopback spellings of one endpoint", () => {
|
|
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe(
|
|
attachedScenarioLeaseKey("http://[::1]:9222", "page-1"),
|
|
);
|
|
expect(attachedScenarioLeaseKey("http://[::1]:9222", "page-1")).not.toBe(
|
|
attachedScenarioLeaseKey("http://[::1]:9222", "page-2"),
|
|
);
|
|
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).not.toBe(
|
|
attachedScenarioLeaseKey("http://127.0.0.1:9223", "page-1"),
|
|
);
|
|
});
|
|
|
|
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 = {
|
|
LANG: "selected-language",
|
|
PATH: "/selected/bin",
|
|
REA_PRIVATE_VALUE: "not-a-launch-setting",
|
|
};
|
|
let launchedEnvironment: unknown;
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://example.test" },
|
|
actions: [
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
|
],
|
|
});
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(scenario, selected, {
|
|
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");
|
|
expect(launchedEnvironment).toEqual({
|
|
LANG: "selected-language",
|
|
PATH: "/selected/bin",
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"passes the caller-selected symlink-parent executable to the launch boundary",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-browser-caller-path-");
|
|
try {
|
|
await mkdir(join(root, "real/inner"), { recursive: true });
|
|
await symlink(join(root, "real/inner"), join(root, "link"));
|
|
const selected = absoluteCallerPath("link/../browser", { cwd: root });
|
|
let launchedExecutable: string | undefined;
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: selected },
|
|
start_url: { url: "https://example.test" },
|
|
actions: [
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (_profile, options) => {
|
|
launchedExecutable = options?.executablePath;
|
|
throw new Error("fake launch boundary reached");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow("fake launch boundary reached");
|
|
|
|
expect(launchedExecutable).toBe(selected);
|
|
expect(selected).toBe(`${root}/link/../browser`);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps Termux execution support when launching Chromium on Android", async () => {
|
|
Object.defineProperty(process, "platform", { value: "android" });
|
|
vi.stubEnv("PREFIX", "/data/data/com.termux/files/usr");
|
|
const selected = { LANG: "selected-language", PREFIX: process.env.PREFIX };
|
|
let launchOptions: Parameters<
|
|
typeof import("playwright-core").chromium.launchPersistentContext
|
|
>[1];
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://example.test" },
|
|
actions: [
|
|
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 },
|
|
],
|
|
});
|
|
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(scenario, selected, {
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (_profile, options) => {
|
|
launchOptions = options;
|
|
throw new Error("launch boundary reached");
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow("launch boundary reached");
|
|
|
|
expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]);
|
|
expect(launchOptions?.env).toEqual({
|
|
LANG: "selected-language",
|
|
LD_PRELOAD: "/data/data/com.termux/files/usr/lib/libtermux-exec.so",
|
|
TERMUX_EXEC__PROC_SELF_EXE: process.execPath,
|
|
});
|
|
});
|
|
|
|
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: async (disconnectAtBoundary) => {
|
|
closeStarted.resolve(undefined);
|
|
await disconnectAtBoundary(() => closeResult.promise);
|
|
},
|
|
removeProfile: undefined,
|
|
onSettled: (browserClosed) => settled.push(browserClosed),
|
|
});
|
|
const timedOutClose = cleanup.close();
|
|
let timedOutFailure: unknown;
|
|
void timedOutClose.catch((cause: unknown) => {
|
|
timedOutFailure = cause;
|
|
});
|
|
const timeoutExpectation = expect(timedOutClose).rejects.toMatchObject({
|
|
reason: "cleanup_failed",
|
|
cleanup: { reason: expect.stringContaining("timed out") },
|
|
});
|
|
await closeStarted.promise;
|
|
let responseSettled = false;
|
|
void timedOutClose
|
|
.finally(() => {
|
|
responseSettled = true;
|
|
})
|
|
.catch(() => {});
|
|
await vi.advanceTimersByTimeAsync(29_999);
|
|
expect(responseSettled).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await timeoutExpectation;
|
|
expect(settled).toEqual([]);
|
|
|
|
closeResult.resolve(undefined);
|
|
await cleanup.close();
|
|
expect(settled).toEqual([true]);
|
|
expect(timedOutFailure).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not configure a late attached connection after cancellation", async () => {
|
|
const connection =
|
|
deferred<Pick<Browser, "contexts" | "close" | "isConnected" | "version">>();
|
|
const connectionStarted = deferred<void>();
|
|
const connectedBrowser = {
|
|
close: vi.fn(async () => undefined),
|
|
contexts: vi.fn(() => []),
|
|
isConnected: vi.fn(() => true),
|
|
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 [];
|
|
}),
|
|
isConnected: vi.fn(() => true),
|
|
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();
|
|
});
|
|
|
|
const attachedBrowserFixture = (
|
|
callbacks: {
|
|
readonly send?: (method: string, parameters?: unknown) => Promise<unknown>;
|
|
readonly emulateMedia?: (
|
|
options: Parameters<Page["emulateMedia"]>[0],
|
|
) => Promise<void>;
|
|
readonly detach?: () => Promise<void>;
|
|
readonly closeBrowser?: () => Promise<void>;
|
|
readonly isTargetClosed?: () => boolean;
|
|
readonly isBrowserConnected?: () => boolean;
|
|
} = {},
|
|
) => {
|
|
const discoverySession = {
|
|
send: vi.fn(async () => ({ targetInfo: { targetId: "page-1" } })),
|
|
detach: vi.fn(async () => undefined),
|
|
} as unknown as CDPSession;
|
|
const environmentSession = {
|
|
send: vi.fn(callbacks.send ?? (async () => undefined)),
|
|
detach: vi.fn(callbacks.detach ?? (async () => undefined)),
|
|
} as unknown as CDPSession;
|
|
const page = {
|
|
emulateMedia: vi.fn(callbacks.emulateMedia ?? (async () => undefined)),
|
|
isClosed: vi.fn(callbacks.isTargetClosed ?? (() => false)),
|
|
} as unknown as Page;
|
|
const context = {
|
|
pages: () => [page],
|
|
newCDPSession: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(discoverySession)
|
|
.mockResolvedValueOnce(environmentSession),
|
|
} as unknown as BrowserContext;
|
|
const browser = {
|
|
close: vi.fn(callbacks.closeBrowser ?? (async () => undefined)),
|
|
contexts: () => [context],
|
|
isConnected: vi.fn(callbacks.isBrowserConnected ?? (() => true)),
|
|
version: () => "fixture",
|
|
} as unknown as Pick<
|
|
Browser,
|
|
"contexts" | "close" | "isConnected" | "version"
|
|
>;
|
|
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 }],
|
|
});
|
|
return {
|
|
browser,
|
|
environmentSession,
|
|
page,
|
|
open: (signal?: AbortSignal) =>
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => browser,
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
},
|
|
signal,
|
|
},
|
|
),
|
|
};
|
|
};
|
|
|
|
it("restores its environment overrides before releasing an attached browser", async () => {
|
|
const cleanupOrder: string[] = [];
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
method === "Emulation.setTimezoneOverride" &&
|
|
(parameters as { timezoneId?: string }).timezoneId === ""
|
|
)
|
|
cleanupOrder.push("timezone");
|
|
if (
|
|
method === "Emulation.setLocaleOverride" &&
|
|
(parameters as { locale?: string }).locale === ""
|
|
)
|
|
cleanupOrder.push("locale");
|
|
if (method === "Emulation.clearDeviceMetricsOverride")
|
|
cleanupOrder.push("device-metrics");
|
|
},
|
|
emulateMedia: async (options) => {
|
|
if (options?.colorScheme === null) cleanupOrder.push("media");
|
|
},
|
|
detach: async () => {
|
|
cleanupOrder.push("session");
|
|
},
|
|
closeBrowser: async () => {
|
|
cleanupOrder.push("browser");
|
|
},
|
|
});
|
|
const opened = await fixture.open();
|
|
await opened.cleanup.close();
|
|
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.setLocaleOverride",
|
|
{ locale: "en-US" },
|
|
);
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.setLocaleOverride",
|
|
{ locale: "" },
|
|
);
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.setTimezoneOverride",
|
|
{ timezoneId: "" },
|
|
);
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.clearDeviceMetricsOverride",
|
|
);
|
|
expect(fixture.page.emulateMedia).toHaveBeenLastCalledWith({
|
|
colorScheme: null,
|
|
reducedMotion: null,
|
|
});
|
|
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
|
|
expect(fixture.browser.close).toHaveBeenCalledOnce();
|
|
expect(cleanupOrder).toEqual([
|
|
"timezone",
|
|
"locale",
|
|
"device-metrics",
|
|
"media",
|
|
"session",
|
|
"browser",
|
|
]);
|
|
});
|
|
|
|
it("checks a late stop after attached restoration and before transport disconnect", async () => {
|
|
const restorationStarted = deferred<void>();
|
|
const finishRestoration = deferred<void>();
|
|
const order: string[] = [];
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
method === "Emulation.setTimezoneOverride" &&
|
|
(parameters as { timezoneId?: string }).timezoneId === ""
|
|
) {
|
|
order.push("restore-started");
|
|
restorationStarted.resolve(undefined);
|
|
await finishRestoration.promise;
|
|
order.push("restore-finished");
|
|
}
|
|
},
|
|
closeBrowser: async () => {
|
|
order.push("browser-disconnected");
|
|
},
|
|
});
|
|
const opened = await fixture.open();
|
|
let revision = 0;
|
|
const release = opened.cleanup.release(undefined, {
|
|
get revision() {
|
|
return revision;
|
|
},
|
|
async run() {
|
|
order.push(`gate:${revision}`);
|
|
},
|
|
});
|
|
|
|
await restorationStarted.promise;
|
|
revision += 1;
|
|
finishRestoration.resolve(undefined);
|
|
await release;
|
|
|
|
expect(order).toEqual([
|
|
"restore-started",
|
|
"restore-finished",
|
|
"gate:1",
|
|
"browser-disconnected",
|
|
]);
|
|
});
|
|
|
|
it("retains attached environment ownership until a failed reset can be retried", async () => {
|
|
let localeResetAttempts = 0;
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
method === "Emulation.setLocaleOverride" &&
|
|
(parameters as { locale?: string }).locale === ""
|
|
) {
|
|
localeResetAttempts += 1;
|
|
if (localeResetAttempts === 1) throw new Error("reset interrupted");
|
|
}
|
|
},
|
|
});
|
|
const opened = await fixture.open();
|
|
|
|
await expect(opened.cleanup.close()).rejects.toMatchObject({
|
|
reason: "cleanup_failed",
|
|
cleanup: { reason: "reset interrupted" },
|
|
});
|
|
expect(fixture.browser.close).not.toHaveBeenCalled();
|
|
expect(fixture.environmentSession.detach).not.toHaveBeenCalled();
|
|
|
|
await opened.cleanup.close();
|
|
expect(localeResetAttempts).toBe(2);
|
|
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
|
|
expect(fixture.browser.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not clear an attached environment override it failed to acquire", async () => {
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
method === "Emulation.setLocaleOverride" &&
|
|
(parameters as { locale?: string }).locale === "en-US"
|
|
)
|
|
throw new Error("caller already owns locale");
|
|
},
|
|
});
|
|
|
|
await expect(fixture.open()).rejects.toThrow("caller already owns locale");
|
|
expect(fixture.environmentSession.send).not.toHaveBeenCalledWith(
|
|
"Emulation.setLocaleOverride",
|
|
{ locale: "" },
|
|
);
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.clearDeviceMetricsOverride",
|
|
);
|
|
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
|
|
expect(fixture.browser.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("waits for a cancelled environment mutation before restoring it", async () => {
|
|
const localeStarted = deferred<void>();
|
|
const localeResult = deferred<void>();
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
method === "Emulation.setLocaleOverride" &&
|
|
(parameters as { locale?: string }).locale === "en-US"
|
|
) {
|
|
localeStarted.resolve(undefined);
|
|
await localeResult.promise;
|
|
}
|
|
},
|
|
});
|
|
const controller = new AbortController();
|
|
const opening = fixture.open(controller.signal);
|
|
await localeStarted.promise;
|
|
const cancelled = expect(opening).rejects.toMatchObject({
|
|
_tag: "AnalysisCancelledError",
|
|
});
|
|
controller.abort();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(fixture.environmentSession.send).not.toHaveBeenCalledWith(
|
|
"Emulation.setLocaleOverride",
|
|
{ locale: "" },
|
|
);
|
|
|
|
localeResult.resolve(undefined);
|
|
await cancelled;
|
|
expect(fixture.environmentSession.send).toHaveBeenCalledWith(
|
|
"Emulation.setLocaleOverride",
|
|
{ locale: "" },
|
|
);
|
|
expect(fixture.environmentSession.detach).toHaveBeenCalledOnce();
|
|
expect(fixture.browser.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("releases cleanup ownership when the attached target is destroyed", async () => {
|
|
let targetClosed = false;
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
targetClosed &&
|
|
method === "Emulation.setTimezoneOverride" &&
|
|
(parameters as { timezoneId?: string }).timezoneId === ""
|
|
)
|
|
throw new Error("Target closed");
|
|
},
|
|
isTargetClosed: () => targetClosed,
|
|
});
|
|
const opened = await fixture.open();
|
|
targetClosed = true;
|
|
|
|
await opened.cleanup.close();
|
|
expect(fixture.browser.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("releases cleanup ownership when the attached browser disconnects", async () => {
|
|
let connected = true;
|
|
const fixture = attachedBrowserFixture({
|
|
send: async (method, parameters) => {
|
|
if (
|
|
!connected &&
|
|
method === "Emulation.setTimezoneOverride" &&
|
|
(parameters as { timezoneId?: string }).timezoneId === ""
|
|
)
|
|
throw new Error("Browser disconnected");
|
|
},
|
|
isBrowserConnected: () => connected,
|
|
});
|
|
const opened = await fixture.open();
|
|
connected = false;
|
|
|
|
await opened.cleanup.close();
|
|
expect(fixture.browser.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("serializes concurrent attach across loopback spellings of one browser", async () => {
|
|
const makeScenario = (endpoint: string) =>
|
|
browserScenarioSchema.parse({
|
|
browser: { mode: "connect", cdp_endpoint: endpoint, target_id: "page-1" },
|
|
start_url: { url: "https://example.test" },
|
|
actions: [
|
|
{
|
|
step_id: "wait",
|
|
action: "wait_for_timeout",
|
|
duration_ms: 1,
|
|
},
|
|
],
|
|
});
|
|
const firstConnection =
|
|
deferred<Pick<Browser, "contexts" | "close" | "isConnected" | "version">>();
|
|
let rejectFirstConnection: ((cause: Error) => void) | undefined;
|
|
const failingConnection = new Promise<never>((_resolve, reject) => {
|
|
rejectFirstConnection = reject;
|
|
});
|
|
const firstStarted = deferred<void>();
|
|
const endpoints: string[] = [];
|
|
const connectOverCDP = vi.fn((endpoint: string) => {
|
|
endpoints.push(endpoint);
|
|
if (endpoints.length === 1) {
|
|
firstStarted.resolve(undefined);
|
|
return firstConnection.promise;
|
|
}
|
|
return failingConnection;
|
|
});
|
|
const launcher = {
|
|
connectOverCDP,
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
};
|
|
|
|
const first = openPlaywrightScenarioBrowser(
|
|
makeScenario("http://127.0.0.1:9222"),
|
|
{},
|
|
{ launcher },
|
|
);
|
|
await firstStarted.promise;
|
|
|
|
const second = openPlaywrightScenarioBrowser(
|
|
makeScenario("http://[::1]:9222"),
|
|
{},
|
|
{ launcher },
|
|
);
|
|
const race = await Promise.race([
|
|
second.then(
|
|
() => "settled" as const,
|
|
() => "settled" as const,
|
|
),
|
|
new Promise((resolve) => setTimeout(() => resolve("queued"), 100)),
|
|
]);
|
|
// The loopback spelling is the same browser, so the second attach must wait
|
|
// for the lease instead of dialing in parallel and being rejected.
|
|
expect(race).toBe("queued");
|
|
expect(connectOverCDP).toHaveBeenCalledTimes(1);
|
|
|
|
firstConnection.resolve({
|
|
close: vi.fn(async () => undefined),
|
|
contexts: vi.fn(() => []),
|
|
isConnected: vi.fn(() => true),
|
|
version: () => "fixture",
|
|
});
|
|
await expect(first).rejects.toThrow();
|
|
if (rejectFirstConnection === undefined)
|
|
throw new Error("second connection did not begin");
|
|
rejectFirstConnection(new Error("second connection failed"));
|
|
await expect(second).rejects.toThrow("second connection failed");
|
|
expect(endpoints).toEqual(["http://127.0.0.1:9222", "http://[::1]:9222"]);
|
|
});
|
|
|
|
it("tracks only pending browser cleanup resources and resolves prior errors after exact recovery", async () => {
|
|
const root = await createTestTempDirectory("rea-browser-cleanup-recovery-");
|
|
const profile = join(root, "profile");
|
|
await mkdir(profile);
|
|
await writeFile(join(profile, "authored-data"), "profile control");
|
|
let browserDenied = true;
|
|
let profileDenied = true;
|
|
let browserCloses = 0;
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: async (disconnectAtBoundary) => {
|
|
browserCloses++;
|
|
await disconnectAtBoundary(async () => {
|
|
if (browserDenied)
|
|
throw new Error("controlled transport close failure");
|
|
});
|
|
},
|
|
removeProfile: async () => {
|
|
if (profileDenied) throw new Error("controlled profile removal failure");
|
|
await rm(profile, { recursive: true });
|
|
},
|
|
});
|
|
try {
|
|
const first: unknown = await owner.close().catch((cause: unknown) => cause);
|
|
expect(first).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
cleanupResources: ["browser_transport", "browser_profile"],
|
|
});
|
|
browserDenied = false;
|
|
const second: unknown = await owner
|
|
.close()
|
|
.catch((cause: unknown) => cause);
|
|
expect(owner.browserClosed).toBe(true);
|
|
for (const error of [first, second])
|
|
expect(error).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
cleanupResources: ["browser_profile"],
|
|
});
|
|
await access(profile);
|
|
profileDenied = false;
|
|
await owner.close();
|
|
await expect(access(profile)).rejects.toMatchObject({ code: "ENOENT" });
|
|
for (const error of [first, second])
|
|
expect(error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
expect(browserCloses).toBe(2);
|
|
} finally {
|
|
browserDenied = false;
|
|
profileDenied = false;
|
|
await owner.close();
|
|
}
|
|
});
|
|
|
|
it("rechecks the browser release gate after finalization changes its revision", async () => {
|
|
const eventsStarted = deferred<void>();
|
|
const finishEvents = deferred<void>();
|
|
const gateStarted = deferred<void>();
|
|
const finishGate = deferred<void>();
|
|
const closeBrowser = vi.fn(
|
|
async (
|
|
disconnectAtBoundary: (disconnect: () => Promise<void>) => Promise<void>,
|
|
) => disconnectAtBoundary(async () => undefined),
|
|
);
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser,
|
|
removeProfile: undefined,
|
|
});
|
|
let revision = 0;
|
|
const gateRuns: number[] = [];
|
|
const release = owner.release(
|
|
async () => {
|
|
eventsStarted.resolve(undefined);
|
|
await finishEvents.promise;
|
|
},
|
|
{
|
|
get revision() {
|
|
return revision;
|
|
},
|
|
async run() {
|
|
gateRuns.push(revision);
|
|
if (gateRuns.length === 1) {
|
|
gateStarted.resolve(undefined);
|
|
await finishGate.promise;
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
await eventsStarted.promise;
|
|
finishEvents.resolve(undefined);
|
|
await gateStarted.promise;
|
|
revision += 1;
|
|
finishGate.resolve(undefined);
|
|
await release;
|
|
|
|
expect(gateRuns).toEqual([0, 1]);
|
|
expect(closeBrowser).toHaveBeenCalledTimes(1);
|
|
expect(owner.browserClosed).toBe(true);
|
|
});
|
|
|
|
it("runs the latest stop gate after attached restoration and before disconnect", async () => {
|
|
const restorationStarted = deferred<void>();
|
|
const finishRestoration = deferred<void>();
|
|
const gateRevisions: number[] = [];
|
|
let revision = 0;
|
|
let disconnected = false;
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser: async (disconnectAtBoundary) => {
|
|
restorationStarted.resolve(undefined);
|
|
await finishRestoration.promise;
|
|
await disconnectAtBoundary(async () => {
|
|
disconnected = true;
|
|
});
|
|
},
|
|
removeProfile: undefined,
|
|
});
|
|
const release = owner.release(undefined, {
|
|
get revision() {
|
|
return revision;
|
|
},
|
|
async run() {
|
|
gateRevisions.push(revision);
|
|
},
|
|
});
|
|
|
|
await restorationStarted.promise;
|
|
revision += 1;
|
|
finishRestoration.resolve(undefined);
|
|
await release;
|
|
|
|
expect(gateRevisions).toEqual([1]);
|
|
expect(disconnected).toBe(true);
|
|
expect(owner.browserClosed).toBe(true);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"retains a failed release gate when its revision changes: %s",
|
|
async (changeRevision) => {
|
|
let transportClosed = false;
|
|
const closeBrowser = vi.fn(
|
|
async (
|
|
disconnectAtBoundary: (
|
|
disconnect: () => Promise<void>,
|
|
) => Promise<void>,
|
|
) =>
|
|
disconnectAtBoundary(async () => {
|
|
transportClosed = true;
|
|
}),
|
|
);
|
|
const owner = new PlaywrightScenarioBrowserCleanupOwner({
|
|
closeBrowser,
|
|
removeProfile: undefined,
|
|
});
|
|
const failure = new Error("navigation stop was not confirmed");
|
|
const gateStarted = deferred<void>();
|
|
const finishGate = deferred<void>();
|
|
let revision = 0;
|
|
let failGate = true;
|
|
const gate = {
|
|
get revision() {
|
|
return revision;
|
|
},
|
|
async run() {
|
|
if (failGate) {
|
|
failGate = false;
|
|
gateStarted.resolve(undefined);
|
|
await finishGate.promise;
|
|
throw failure;
|
|
}
|
|
},
|
|
};
|
|
|
|
const release = owner.release(undefined, gate).then(
|
|
() => ({ ok: true as const }),
|
|
(cause: unknown) => ({ ok: false as const, cause }),
|
|
);
|
|
await gateStarted.promise;
|
|
if (changeRevision) revision += 1;
|
|
finishGate.resolve(undefined);
|
|
await expect(release).resolves.toEqual({ ok: false, cause: failure });
|
|
expect(closeBrowser).toHaveBeenCalledTimes(1);
|
|
expect(transportClosed).toBe(false);
|
|
expect(owner.browserClosed).toBe(false);
|
|
expect(owner.cleanupResources).toContain("browser_transport");
|
|
|
|
await owner.release(undefined, gate);
|
|
expect(closeBrowser).toHaveBeenCalledTimes(2);
|
|
expect(transportClosed).toBe(true);
|
|
expect(owner.browserClosed).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("bounds an opening failure response while retaining the same late cleanup lease", async () => {
|
|
vi.useFakeTimers();
|
|
let acknowledge: () => void = () => {};
|
|
const late = new Promise<void>((resolve) => {
|
|
acknowledge = resolve;
|
|
});
|
|
const lease = new AnalysisCleanupLease(() => late, ["browser_profile"]);
|
|
const reason = { caller: "cancelled opening" };
|
|
const primary = new AnalysisCancelledError("capture_browser_scenario", {
|
|
cause: reason,
|
|
});
|
|
try {
|
|
const opening = failBrowserScenarioOperation(lease, primary).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
const failure = await opening;
|
|
expect(failure).toMatchObject({
|
|
cleanupIncomplete: true,
|
|
cleanupResources: ["browser_profile"],
|
|
});
|
|
expect(lease.released).toBe(false);
|
|
acknowledge();
|
|
await lease.release();
|
|
expect(failure).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
if (!(failure instanceof AnalysisError))
|
|
throw new Error("Expected typed cleanup failure");
|
|
expect(projectAnalysisError(failure)).toMatchObject({ code: "cancelled" });
|
|
expect(primary.cause).toBe(reason);
|
|
} finally {
|
|
acknowledge();
|
|
await lease.release();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
const scenarioActions = [
|
|
{ step_id: "settle", action: "wait_for_timeout" as const, duration_ms: 1 },
|
|
];
|
|
|
|
it("applies command_timeout_ms to launch startup", async () => {
|
|
let timeout: number | undefined;
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://example.test" },
|
|
command_timeout_ms: 4_321,
|
|
actions: scenarioActions,
|
|
});
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (_profile, options) => {
|
|
timeout = options?.timeout;
|
|
throw new Error("launch boundary reached");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow("launch boundary reached");
|
|
expect(timeout).toBe(4_321);
|
|
});
|
|
|
|
it("uses the default command deadline for an omitted launch timeout", async () => {
|
|
let timeout: number | undefined;
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://example.test" },
|
|
actions: scenarioActions,
|
|
});
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (_profile, options) => {
|
|
timeout = options?.timeout;
|
|
throw new Error("launch boundary reached");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toThrow("launch boundary reached");
|
|
expect(timeout).toBe(DEFAULT_CDP_COMMAND_TIMEOUT_MS);
|
|
});
|
|
|
|
it("applies command_timeout_ms to the primary connect handshake when service workers are allowed", async () => {
|
|
let timeout: number | undefined;
|
|
const started = performance.now();
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "primary-handshake",
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
environment: { service_workers: "allow" },
|
|
command_timeout_ms: 50,
|
|
actions: scenarioActions,
|
|
});
|
|
const opening = openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async (_endpoint, options) => {
|
|
timeout = options?.timeout;
|
|
return new Promise(() => undefined);
|
|
},
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
},
|
|
},
|
|
);
|
|
const failure = await opening.then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toMatchObject({
|
|
reason: "timeout",
|
|
userMessage: expect.stringContaining("50 ms command_timeout_ms"),
|
|
remediationAction: expect.stringContaining("capture-browser-scenario JSON"),
|
|
});
|
|
expect(
|
|
(failure as { remediationAction?: string }).remediationAction,
|
|
).not.toContain("--command-timeout-ms");
|
|
expect(timeout).toBe(50);
|
|
expect(performance.now() - started).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("applies command_timeout_ms to primary target lookup when service workers are allowed", async () => {
|
|
const started = performance.now();
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "primary-target-info",
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
environment: { service_workers: "allow" },
|
|
command_timeout_ms: 50,
|
|
actions: scenarioActions,
|
|
});
|
|
const browser = {
|
|
close: async () => undefined,
|
|
contexts: () => [
|
|
{
|
|
pages: () => [{} as Page],
|
|
newCDPSession: async () =>
|
|
({
|
|
send: () => new Promise(() => undefined),
|
|
detach: async () => undefined,
|
|
}) as unknown as CDPSession,
|
|
} as unknown as BrowserContext,
|
|
],
|
|
isConnected: () => true,
|
|
version: () => "fixture",
|
|
};
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async (_endpoint, options) => {
|
|
expect(options?.timeout).toBe(50);
|
|
return browser;
|
|
},
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
reason: "timeout",
|
|
userMessage: expect.stringContaining("Target.getTargetInfo"),
|
|
});
|
|
expect(performance.now() - started).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("closes a context that launches after its deadline before removing the profile", async () => {
|
|
const order: string[] = [];
|
|
let profilePath = "";
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: { mode: "launch", executable_path: process.execPath },
|
|
start_url: { url: "https://example.test" },
|
|
command_timeout_ms: 40,
|
|
actions: scenarioActions,
|
|
});
|
|
const failure = await openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (profile) => {
|
|
profilePath = profile;
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
order.push("launched");
|
|
return {
|
|
close: async () => {
|
|
order.push("close");
|
|
},
|
|
} as unknown as BrowserContext;
|
|
},
|
|
},
|
|
},
|
|
).then(
|
|
() => undefined,
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(failure).toMatchObject({ reason: "timeout" });
|
|
expect(
|
|
(failure as { remediationAction?: string }).remediationAction,
|
|
).not.toContain("--command-timeout-ms");
|
|
expect(order).toEqual(["launched", "close"]);
|
|
await expect(access(profilePath)).rejects.toThrow();
|
|
});
|
|
|
|
it("closes a browser that connects after its deadline without waiting to report the timeout", async () => {
|
|
let closed = false;
|
|
const started = performance.now();
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "late-connect-target",
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
environment: { service_workers: "allow" },
|
|
command_timeout_ms: 40,
|
|
actions: scenarioActions,
|
|
});
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
return {
|
|
close: async () => {
|
|
closed = true;
|
|
},
|
|
contexts: () => [],
|
|
isConnected: () => true,
|
|
version: () => "late-connect",
|
|
};
|
|
},
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toMatchObject({ reason: "timeout" });
|
|
expect(performance.now() - started).toBeLessThan(2_000);
|
|
for (let attempt = 0; attempt < 50 && !closed; attempt += 1)
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(closed).toBe(true);
|
|
});
|
|
|
|
it("restores an environment mutation that completes after its deadline", async () => {
|
|
let restored = false;
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "connect",
|
|
cdp_endpoint: "http://127.0.0.1:9222",
|
|
target_id: "late-environment-target",
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
environment: { service_workers: "allow" },
|
|
command_timeout_ms: 40,
|
|
actions: scenarioActions,
|
|
});
|
|
const discoverySession = {
|
|
send: vi.fn(async () => ({
|
|
targetInfo: { targetId: "late-environment-target" },
|
|
})),
|
|
detach: vi.fn(async () => undefined),
|
|
} as unknown as CDPSession;
|
|
const environmentSession = {
|
|
send: vi.fn(async () => undefined),
|
|
detach: vi.fn(async () => undefined),
|
|
} as unknown as CDPSession;
|
|
const page = {
|
|
emulateMedia: vi.fn(async (options: { colorScheme?: string | null }) => {
|
|
if (options.colorScheme === null) {
|
|
restored = true;
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
}),
|
|
isClosed: vi.fn(() => false),
|
|
} as unknown as Page;
|
|
const context = {
|
|
pages: () => [page],
|
|
newCDPSession: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(discoverySession)
|
|
.mockResolvedValueOnce(environmentSession),
|
|
} as unknown as BrowserContext;
|
|
const browser = {
|
|
close: vi.fn(async () => undefined),
|
|
contexts: () => [context],
|
|
isConnected: vi.fn(() => true),
|
|
version: () => "late-environment",
|
|
};
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => browser,
|
|
launchPersistentContext: async () => {
|
|
throw new Error("Unexpected launch");
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
reason: "timeout",
|
|
userMessage: expect.stringContaining("Emulation.setEmulatedMedia"),
|
|
});
|
|
expect(restored).toBe(true);
|
|
expect(environmentSession.send).not.toHaveBeenCalledWith(
|
|
"Emulation.clearDeviceMetricsOverride",
|
|
);
|
|
expect(browser.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([
|
|
new Error("Timeout unrelated launch diagnostic"),
|
|
Object.assign(new Error("unrelated launch failure"), {
|
|
name: "TimeoutError",
|
|
}),
|
|
])(
|
|
"preserves a launch failure without inferring a Playwright deadline: %s",
|
|
async (failure) => {
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "launch",
|
|
executable_path: process.execPath,
|
|
headless: true,
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
command_timeout_ms: 4_321,
|
|
actions: scenarioActions,
|
|
});
|
|
let profilePath: string | undefined;
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (profile) => {
|
|
profilePath = profile;
|
|
throw failure;
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toBe(failure);
|
|
expect(profilePath).toBeDefined();
|
|
await expect(access(profilePath!)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("normalizes a genuine Playwright launch timeout and releases its profile", async () => {
|
|
const { errors } = await import("playwright-core");
|
|
const failure = new errors.TimeoutError("native launch deadline");
|
|
const scenario = browserScenarioSchema.parse({
|
|
browser: {
|
|
mode: "launch",
|
|
executable_path: process.execPath,
|
|
headless: true,
|
|
},
|
|
start_url: { url: "https://example.test" },
|
|
command_timeout_ms: 4_321,
|
|
actions: scenarioActions,
|
|
});
|
|
let profilePath: string | undefined;
|
|
await expect(
|
|
openPlaywrightScenarioBrowser(
|
|
scenario,
|
|
{},
|
|
{
|
|
launcher: {
|
|
connectOverCDP: async () => {
|
|
throw new Error("Unexpected connection");
|
|
},
|
|
launchPersistentContext: async (profile) => {
|
|
profilePath = profile;
|
|
throw failure;
|
|
},
|
|
},
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
reason: "timeout",
|
|
userMessage: expect.stringContaining("4321 ms command_timeout_ms"),
|
|
cause: failure,
|
|
});
|
|
expect(profilePath).toBeDefined();
|
|
await expect(access(profilePath!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|