mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix(browser): ignore Playwright dialog race rejections
Carries forward #40067 from @randyjtw. Validated: - OPENCLAW_TESTBOX=1 pnpm check:changed (tbx_01kqc44esqmt15ygzvfxd1pqng) - CI: https://github.com/openclaw/openclaw/actions/runs/25097879442
This commit is contained in:
committed by
GitHub
parent
72cf700fbf
commit
4d43daa7bb
@@ -4,6 +4,7 @@ import { isPwAiLoaded } from "./pw-ai-state.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
|
||||
import { registerBrowserUnhandledRejectionHandler } from "./unhandled-rejections.js";
|
||||
|
||||
export async function createBrowserRuntimeState(params: {
|
||||
resolved: BrowserServerState["resolved"];
|
||||
@@ -25,6 +26,7 @@ export async function createBrowserRuntimeState(params: {
|
||||
resolved: params.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
state.stopUnhandledRejectionHandler = registerBrowserUnhandledRejectionHandler();
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -39,28 +41,32 @@ export async function stopBrowserRuntime(params: {
|
||||
if (!params.current) {
|
||||
return;
|
||||
}
|
||||
params.current.stopTrackedTabCleanup?.();
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
getState: params.getState,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
if (params.closeServer && params.current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
params.current?.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
params.clearState();
|
||||
|
||||
if (!isPwAiLoaded()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = await getPwAiModule({ mode: "soft" });
|
||||
await mod?.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
params.current.stopTrackedTabCleanup?.();
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
getState: params.getState,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
|
||||
if (params.closeServer && params.current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
params.current?.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
params.clearState();
|
||||
|
||||
if (!isPwAiLoaded()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mod = await getPwAiModule({ mode: "soft" });
|
||||
await mod?.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} finally {
|
||||
params.current.stopUnhandledRejectionHandler?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getUnhandledRejectionHandlers, registerUnhandledRejectionHandlerMock, resetHandlers } =
|
||||
vi.hoisted(() => {
|
||||
let handlers: Array<(reason: unknown) => boolean> = [];
|
||||
return {
|
||||
getUnhandledRejectionHandlers: () => handlers,
|
||||
registerUnhandledRejectionHandlerMock: vi.fn((handler: (reason: unknown) => boolean) => {
|
||||
handlers.push(handler);
|
||||
return () => {
|
||||
handlers = handlers.filter((candidate) => candidate !== handler);
|
||||
};
|
||||
}),
|
||||
resetHandlers: () => {
|
||||
handlers = [];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
ensureExtensionRelayForProfilesMock,
|
||||
getPwAiModuleMock,
|
||||
isPwAiLoadedMock,
|
||||
startTrackedBrowserTabCleanupTimerMock,
|
||||
stopKnownBrowserProfilesMock,
|
||||
trackedTabCleanupMock,
|
||||
} = vi.hoisted(() => {
|
||||
const trackedTabCleanupMock = vi.fn();
|
||||
return {
|
||||
ensureExtensionRelayForProfilesMock: vi.fn(async () => {}),
|
||||
getPwAiModuleMock: vi.fn(),
|
||||
isPwAiLoadedMock: vi.fn(() => false),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMock),
|
||||
stopKnownBrowserProfilesMock: vi.fn(async () => {}),
|
||||
trackedTabCleanupMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
registerUnhandledRejectionHandler: registerUnhandledRejectionHandlerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./server-lifecycle.js", () => ({
|
||||
ensureExtensionRelayForProfiles: ensureExtensionRelayForProfilesMock,
|
||||
stopKnownBrowserProfiles: stopKnownBrowserProfilesMock,
|
||||
}));
|
||||
|
||||
vi.mock("./session-tab-cleanup.js", () => ({
|
||||
startTrackedBrowserTabCleanupTimer: startTrackedBrowserTabCleanupTimerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai-state.js", () => ({
|
||||
isPwAiLoaded: isPwAiLoadedMock,
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai-module.js", () => ({
|
||||
getPwAiModule: getPwAiModuleMock,
|
||||
}));
|
||||
|
||||
const { createBrowserRuntimeState, stopBrowserRuntime } = await import("./runtime-lifecycle.js");
|
||||
const { isPlaywrightDialogRaceUnhandledRejection } = await import("./unhandled-rejections.js");
|
||||
|
||||
beforeEach(() => {
|
||||
resetHandlers();
|
||||
registerUnhandledRejectionHandlerMock.mockClear();
|
||||
ensureExtensionRelayForProfilesMock.mockClear();
|
||||
getPwAiModuleMock.mockClear();
|
||||
isPwAiLoadedMock.mockReset().mockReturnValue(false);
|
||||
startTrackedBrowserTabCleanupTimerMock.mockClear();
|
||||
stopKnownBrowserProfilesMock.mockClear();
|
||||
trackedTabCleanupMock.mockClear();
|
||||
});
|
||||
|
||||
describe("browser unhandled rejection lifecycle", () => {
|
||||
it("matches direct and nested Playwright dialog-race protocol errors", () => {
|
||||
const direct = Object.assign(
|
||||
new Error("Protocol error (Page.handleJavaScriptDialog): No dialog is showing"),
|
||||
{ method: "Page.handleJavaScriptDialog" },
|
||||
);
|
||||
const nested = new Error("browser action failed", {
|
||||
cause: Object.assign(new Error("No dialog is showing"), {
|
||||
method: "Page.handleJavaScriptDialog",
|
||||
}),
|
||||
});
|
||||
const wrapped = {
|
||||
error: new Error("Protocol error (Dialog.handleJavaScriptDialog): No dialog is showing"),
|
||||
};
|
||||
|
||||
expect(isPlaywrightDialogRaceUnhandledRejection(direct)).toBe(true);
|
||||
expect(isPlaywrightDialogRaceUnhandledRejection(nested)).toBe(true);
|
||||
expect(isPlaywrightDialogRaceUnhandledRejection(wrapped)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-dialog and non-race Playwright errors unhandled", () => {
|
||||
expect(
|
||||
isPlaywrightDialogRaceUnhandledRejection(
|
||||
Object.assign(new Error("No dialog is showing"), { method: "Page.navigate" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPlaywrightDialogRaceUnhandledRejection(
|
||||
new Error("Protocol error (Page.handleJavaScriptDialog): Target closed"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isPlaywrightDialogRaceUnhandledRejection(new Error("No dialog is showing"))).toBe(false);
|
||||
});
|
||||
|
||||
it("registers during startup and unregisters during shutdown", async () => {
|
||||
stopKnownBrowserProfilesMock.mockImplementationOnce(async () => {
|
||||
expect(getUnhandledRejectionHandlers()).toHaveLength(1);
|
||||
});
|
||||
const state = await createBrowserRuntimeState({
|
||||
resolved: { profiles: {} } as never,
|
||||
port: 18791,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(registerUnhandledRejectionHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(getUnhandledRejectionHandlers()).toHaveLength(1);
|
||||
expect(
|
||||
getUnhandledRejectionHandlers()[0]?.(
|
||||
new Error("Protocol error (Page.handleJavaScriptDialog): No dialog is showing"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const clearState = vi.fn();
|
||||
await stopBrowserRuntime({
|
||||
current: state,
|
||||
getState: () => state,
|
||||
clearState,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(trackedTabCleanupMock).toHaveBeenCalledTimes(1);
|
||||
expect(stopKnownBrowserProfilesMock).toHaveBeenCalledTimes(1);
|
||||
expect(clearState).toHaveBeenCalledTimes(1);
|
||||
expect(getUnhandledRejectionHandlers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ export type BrowserServerState = {
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
stopTrackedTabCleanup?: () => void;
|
||||
stopUnhandledRejectionHandler?: () => void;
|
||||
};
|
||||
|
||||
type BrowserProfileActions = {
|
||||
|
||||
94
extensions/browser/src/browser/unhandled-rejections.ts
Normal file
94
extensions/browser/src/browser/unhandled-rejections.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const PLAYWRIGHT_DIALOG_METHODS = new Set([
|
||||
"Page.handleJavaScriptDialog",
|
||||
"Dialog.handleJavaScriptDialog",
|
||||
]);
|
||||
|
||||
const NO_DIALOG_MESSAGE = "no dialog is showing";
|
||||
|
||||
function collectNestedErrorCandidates(err: unknown): unknown[] {
|
||||
const queue: unknown[] = [err];
|
||||
const seen = new Set<unknown>();
|
||||
const candidates: unknown[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current == null || seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
candidates.push(current);
|
||||
|
||||
if (!current || typeof current !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = current as Record<string, unknown>;
|
||||
for (const nested of [
|
||||
record.cause,
|
||||
record.reason,
|
||||
record.original,
|
||||
record.error,
|
||||
record.data,
|
||||
]) {
|
||||
if (nested != null && !seen.has(nested)) {
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(record.errors)) {
|
||||
for (const nested of record.errors) {
|
||||
if (nested != null && !seen.has(nested)) {
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function readMessage(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
}
|
||||
const message = (err as { message?: unknown }).message;
|
||||
return typeof message === "string" ? message : "";
|
||||
}
|
||||
|
||||
function readPlaywrightMethod(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const method = (err as { method?: unknown }).method;
|
||||
return typeof method === "string" ? method : undefined;
|
||||
}
|
||||
|
||||
export function isPlaywrightDialogRaceUnhandledRejection(reason: unknown): boolean {
|
||||
for (const candidate of collectNestedErrorCandidates(reason)) {
|
||||
const message = readMessage(candidate);
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
if (!normalizedMessage.includes(NO_DIALOG_MESSAGE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const method = readPlaywrightMethod(candidate);
|
||||
if (method && PLAYWRIGHT_DIALOG_METHODS.has(method)) {
|
||||
return true;
|
||||
}
|
||||
for (const playwrightMethod of PLAYWRIGHT_DIALOG_METHODS) {
|
||||
if (message.includes(playwrightMethod)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerBrowserUnhandledRejectionHandler(): () => void {
|
||||
return registerUnhandledRejectionHandler(isPlaywrightDialogRaceUnhandledRejection);
|
||||
}
|
||||
Reference in New Issue
Block a user