feat(browser): add qa web runtime support

This commit is contained in:
Peter Steinberger
2026-04-12 19:40:24 -07:00
parent c848ebc8ce
commit 1a47660518
9 changed files with 820 additions and 16 deletions

View File

@@ -20,22 +20,33 @@ type ChromeMcpSessionFactory = Exclude<
type ChromeMcpSession = Awaited<ReturnType<ChromeMcpSessionFactory>>;
function createFakeSession(): ChromeMcpSession {
const callTool = vi.fn(async ({ name }: ToolCall) => {
let currentUrl =
"https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session";
let createdPageOpen = false;
const readUrlArg = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
const callTool = vi.fn(async ({ name, arguments: args }: ToolCall) => {
if (name === "list_pages") {
const pageLines = [
"## Pages",
`1: ${currentUrl} [selected]`,
"2: https://github.com/openclaw/openclaw/pull/45318",
];
if (createdPageOpen) {
pageLines.push(`3: ${currentUrl}`);
}
return {
content: [
{
type: "text",
text: [
"## Pages",
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session [selected]",
"2: https://github.com/openclaw/openclaw/pull/45318",
].join("\n"),
text: pageLines.join("\n"),
},
],
};
}
if (name === "new_page") {
currentUrl = readUrlArg(args?.url, "about:blank");
createdPageOpen = true;
return {
content: [
{
@@ -44,12 +55,16 @@ function createFakeSession(): ChromeMcpSession {
"## Pages",
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session",
"2: https://github.com/openclaw/openclaw/pull/45318",
"3: https://example.com/ [selected]",
`3: ${currentUrl} [selected]`,
].join("\n"),
},
],
};
}
if (name === "navigate_page") {
currentUrl = readUrlArg(args?.url, currentUrl);
return { content: [{ type: "text", text: "navigated" }] };
}
if (name === "evaluate_script") {
return {
content: [
@@ -130,6 +145,28 @@ describe("chrome MCP page parsing", () => {
});
});
it("opens about:blank directly without an extra navigate", async () => {
const session = createFakeSession();
const factory: ChromeMcpSessionFactory = async () => session;
setChromeMcpSessionFactoryForTest(factory);
const tab = await openChromeMcpTab("chrome-live", "about:blank");
expect(tab).toEqual({
targetId: "3",
title: "",
url: "about:blank",
type: "page",
});
expect(session.client.callTool).toHaveBeenCalledWith({
name: "new_page",
arguments: { url: "about:blank", timeout: 5000 },
});
expect(session.client.callTool).not.toHaveBeenCalledWith(
expect.objectContaining({ name: "navigate_page" }),
);
});
it("parses evaluate_script text responses when structuredContent is missing", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);

View File

@@ -42,6 +42,8 @@ const DEFAULT_CHROME_MCP_ARGS = [
"--experimentalStructuredContent",
"--experimental-page-id-routing",
];
const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
@@ -401,16 +403,33 @@ export async function openChromeMcpTab(
url: string,
userDataDir?: string,
): Promise<BrowserTab> {
const result = await callTool(profileName, userDataDir, "new_page", { url });
const targetUrl = url.trim() || "about:blank";
const result = await callTool(profileName, userDataDir, "new_page", {
url: "about:blank",
timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS,
});
const pages = extractStructuredPages(result);
const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
if (!chosen) {
throw new Error("Chrome MCP did not return the created page.");
}
const targetId = String(chosen.id);
const finalUrl =
targetUrl === "about:blank"
? (chosen.url ?? targetUrl)
: (
await navigateChromeMcpPage({
profileName,
userDataDir,
targetId,
url: targetUrl,
timeoutMs: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
})
).url;
return {
targetId: String(chosen.id),
targetId,
title: "",
url: chosen.url ?? url,
url: finalUrl,
type: "page",
};
}

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createBrowserControlContextMock,
createBrowserRouteDispatcherMock,
loadConfigMock,
startBrowserControlServiceFromConfigMock,
withTimeoutMock,
} = vi.hoisted(() => ({
createBrowserControlContextMock: vi.fn(() => ({ ok: true })),
createBrowserRouteDispatcherMock: vi.fn(),
loadConfigMock: vi.fn(),
startBrowserControlServiceFromConfigMock: vi.fn(),
withTimeoutMock: vi.fn(),
}));
vi.mock("../core-api.js", async () => {
const actual = await vi.importActual<typeof import("../core-api.js")>("../core-api.js");
return {
...actual,
createBrowserControlContext: createBrowserControlContextMock,
createBrowserRouteDispatcher: createBrowserRouteDispatcherMock,
loadConfig: loadConfigMock,
startBrowserControlServiceFromConfig: startBrowserControlServiceFromConfigMock,
withTimeout: withTimeoutMock,
};
});
import { browserHandlers } from "./browser-request.js";
describe("browser.request local timeout", () => {
beforeEach(() => {
loadConfigMock.mockReturnValue({
gateway: { nodes: { browser: { mode: "off" } } },
});
startBrowserControlServiceFromConfigMock.mockResolvedValue(true);
createBrowserRouteDispatcherMock.mockReturnValue({
dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })),
});
withTimeoutMock.mockImplementation(async () => {
throw new Error("browser request timed out");
});
});
it("applies timeoutMs to local browser dispatches", async () => {
const respond = vi.fn();
await browserHandlers["browser.request"]({
params: {
method: "POST",
path: "/tabs/open",
body: { url: "https://example.com" },
timeoutMs: 4321,
},
respond: respond as never,
context: {
nodeRegistry: { listConnected: () => [] },
} as never,
client: null,
req: { type: "req", id: "req-1", method: "browser.request" },
isWebchatConnect: () => false,
});
expect(withTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 4321, "browser request");
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "Error: browser request timed out",
}),
);
});
});

View File

@@ -18,6 +18,7 @@ import {
respondUnavailableOnNodeInvokeError,
safeParseJson,
startBrowserControlServiceFromConfig,
withTimeout,
type GatewayRequestHandlers,
type NodeSession,
} from "../core-api.js";
@@ -246,12 +247,31 @@ export async function handleBrowserGatewayRequest({
return;
}
const result = await dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
});
let result;
try {
result = timeoutMs
? await withTimeout(
(signal) =>
dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
signal,
}),
timeoutMs,
"browser request",
)
: await dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
return;
}
if (result.status >= 400) {
const message =

View File

@@ -4,6 +4,9 @@
"private": true,
"description": "OpenClaw QA lab plugin with private debugger UI and scenario runner",
"type": "module",
"dependencies": {
"playwright-core": "1.59.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"

View File

@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import {
callQaBrowserRequest,
qaBrowserAct,
qaBrowserOpenTab,
qaBrowserSnapshot,
waitForQaBrowserReady,
} from "./browser-runtime.js";
function createEnv() {
return {
gateway: {
call: vi.fn(async () => ({ ok: true })),
},
};
}
describe("browser-runtime", () => {
it("sends normalized browser.request payloads through the gateway", async () => {
const env = createEnv();
const result = await callQaBrowserRequest(env, {
method: "GET",
path: "/snapshot",
query: {
format: "ai",
targetId: "tab-1",
skip: undefined,
limit: 50,
},
timeoutMs: 12_345,
});
expect(result).toEqual({ ok: true });
expect(env.gateway.call).toHaveBeenCalledWith(
"browser.request",
{
method: "GET",
path: "/snapshot",
query: {
format: "ai",
targetId: "tab-1",
limit: "50",
},
body: undefined,
timeoutMs: 12_345,
},
{ timeoutMs: 12_345 },
);
});
it("opens tabs through the browser proxy", async () => {
const env = createEnv();
await qaBrowserOpenTab(env, {
url: "http://127.0.0.1:43124/control-ui/chat?session=test",
profile: "openclaw",
});
expect(env.gateway.call).toHaveBeenCalledWith(
"browser.request",
{
method: "POST",
path: "/tabs/open",
query: {
profile: "openclaw",
},
body: {
url: "http://127.0.0.1:43124/control-ui/chat?session=test",
},
timeoutMs: 20_000,
},
{ timeoutMs: 20_000 },
);
});
it("captures snapshots with query options", async () => {
const env = createEnv();
await qaBrowserSnapshot(env, {
targetId: "tab-1",
interactive: true,
labels: true,
maxChars: 4_000,
});
expect(env.gateway.call).toHaveBeenCalledWith(
"browser.request",
{
method: "GET",
path: "/snapshot",
query: {
targetId: "tab-1",
format: "ai",
interactive: "true",
labels: "true",
maxChars: "4000",
},
body: undefined,
timeoutMs: 20_000,
},
{ timeoutMs: 20_000 },
);
});
it("runs browser act requests through /act", async () => {
const env = createEnv();
await qaBrowserAct(env, {
profile: "openclaw",
request: {
kind: "type",
ref: "12",
text: "hello",
submit: true,
},
timeoutMs: 9_000,
});
expect(env.gateway.call).toHaveBeenCalledWith(
"browser.request",
{
method: "POST",
path: "/act",
query: {
profile: "openclaw",
},
body: {
kind: "type",
ref: "12",
text: "hello",
submit: true,
},
timeoutMs: 9_000,
},
{ timeoutMs: 9_000 },
);
});
it("waits until browser control reports a ready profile", async () => {
const env = createEnv();
env.gateway.call = vi
.fn()
.mockResolvedValueOnce({ enabled: true, running: false, cdpReady: false })
.mockResolvedValueOnce({ enabled: true, running: true, cdpReady: true });
const status = await waitForQaBrowserReady(env, {
profile: "user",
timeoutMs: 5_000,
intervalMs: 1,
});
expect(status).toEqual({ enabled: true, running: true, cdpReady: true });
expect(env.gateway.call).toHaveBeenNthCalledWith(
1,
"browser.request",
{
method: "GET",
path: "/",
query: {
profile: "user",
},
body: undefined,
timeoutMs: 5_000,
},
{ timeoutMs: 5_000 },
);
});
});

View File

@@ -0,0 +1,212 @@
type QaBrowserGateway = {
call: (
method: string,
params: Record<string, unknown>,
opts?: { timeoutMs?: number },
) => Promise<unknown>;
};
type QaBrowserEnv = {
gateway: QaBrowserGateway;
};
type QaBrowserRequestParams = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeoutMs?: number;
};
type QaBrowserOpenTabParams = {
url: string;
profile?: string;
timeoutMs?: number;
};
type QaBrowserSnapshotParams = {
profile?: string;
targetId?: string;
format?: "ai" | "aria";
limit?: number;
interactive?: boolean;
compact?: boolean;
depth?: number;
selector?: string;
frame?: string;
labels?: boolean;
mode?: "efficient";
maxChars?: number;
timeoutMs?: number;
};
type QaBrowserActRequest = {
kind: string;
targetId?: string;
ref?: string;
doubleClick?: boolean;
button?: string;
modifiers?: string[];
text?: string;
submit?: boolean;
slowly?: boolean;
key?: string;
delayMs?: number;
startRef?: string;
endRef?: string;
values?: string[];
fields?: Array<Record<string, unknown>>;
width?: number;
height?: number;
timeMs?: number;
selector?: string;
url?: string;
loadState?: string;
textGone?: string;
timeoutMs?: number;
fn?: string;
};
type QaBrowserActParams = {
profile?: string;
request: QaBrowserActRequest;
timeoutMs?: number;
};
type QaBrowserStatus = {
enabled?: boolean;
running?: boolean;
cdpReady?: boolean;
};
type QaBrowserReadyParams = {
profile?: string;
timeoutMs?: number;
intervalMs?: number;
};
function normalizeBrowserQuery(
query: QaBrowserRequestParams["query"],
): Record<string, string> | undefined {
if (!query) {
return undefined;
}
const normalized = Object.fromEntries(
Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [key, String(value)]),
);
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function resolveBrowserTimeoutMs(timeoutMs: number | undefined, fallbackMs: number) {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return fallbackMs;
}
return Math.max(1, Math.floor(timeoutMs));
}
export async function callQaBrowserRequest<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserRequestParams,
): Promise<T> {
const timeoutMs = resolveBrowserTimeoutMs(params.timeoutMs, 20_000);
const payload = await env.gateway.call(
"browser.request",
{
method: params.method,
path: params.path,
query: normalizeBrowserQuery(params.query),
body: params.body,
timeoutMs,
},
{ timeoutMs },
);
return payload as T;
}
export async function qaBrowserOpenTab<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserOpenTabParams,
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "POST",
path: "/tabs/open",
query: params.profile ? { profile: params.profile } : undefined,
body: { url: params.url },
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
export async function qaBrowserSnapshot<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserSnapshotParams = {},
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "GET",
path: "/snapshot",
query: {
profile: params.profile,
targetId: params.targetId,
format: params.format ?? "ai",
limit: params.limit,
interactive: params.interactive,
compact: params.compact,
depth: params.depth,
selector: params.selector,
frame: params.frame,
labels: params.labels,
mode: params.mode,
maxChars: params.maxChars,
},
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
export async function qaBrowserAct<T = unknown>(
env: QaBrowserEnv,
params: QaBrowserActParams,
): Promise<T> {
return await callQaBrowserRequest<T>(env, {
method: "POST",
path: "/act",
query: params.profile ? { profile: params.profile } : undefined,
body: params.request,
timeoutMs: resolveBrowserTimeoutMs(params.timeoutMs, 20_000),
});
}
function isQaBrowserReady(status: QaBrowserStatus | null | undefined) {
return status?.enabled === true && status?.running === true && status?.cdpReady === true;
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export async function waitForQaBrowserReady<T extends QaBrowserStatus = QaBrowserStatus>(
env: QaBrowserEnv,
params: QaBrowserReadyParams = {},
): Promise<T> {
const timeoutMs = resolveBrowserTimeoutMs(params.timeoutMs, 20_000);
const intervalMs = resolveBrowserTimeoutMs(params.intervalMs, 250);
const startedAt = Date.now();
let lastStatus: QaBrowserStatus | null = null;
while (Date.now() - startedAt < timeoutMs) {
lastStatus = await callQaBrowserRequest<QaBrowserStatus>(env, {
method: "GET",
path: "/",
query: params.profile ? { profile: params.profile } : undefined,
timeoutMs: Math.min(timeoutMs, 5_000),
});
if (isQaBrowserReady(lastStatus)) {
return lastStatus as T;
}
await sleep(intervalMs);
}
throw new Error(
`browser control not ready after ${timeoutMs}ms${
lastStatus ? ` (${JSON.stringify(lastStatus)})` : ""
}`,
);
}

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
bodyLocator,
browserClose,
contextClose,
contextNewPage,
goto,
launch,
locatorFill,
locatorPress,
locatorWaitFor,
pageEvaluate,
pageTitle,
pageUrl,
pageWaitForFunction,
pageWaitForSelector,
} = vi.hoisted(() => ({
bodyLocator: {
waitFor: vi.fn(async () => undefined),
innerText: vi.fn(async () => "hello from body"),
},
browserClose: vi.fn(async () => undefined),
contextClose: vi.fn(async () => undefined),
contextNewPage: vi.fn(),
goto: vi.fn(async () => undefined),
innerText: vi.fn(async () => "hello from body"),
launch: vi.fn(),
locatorFill: vi.fn(async () => undefined),
locatorPress: vi.fn(async () => undefined),
locatorWaitFor: vi.fn(async () => undefined),
pageEvaluate: vi.fn(async () => "ok"),
pageTitle: vi.fn(async () => "QA"),
pageUrl: vi.fn(() => "http://127.0.0.1:3000/chat"),
pageWaitForFunction: vi.fn(async () => undefined),
pageWaitForSelector: vi.fn(async () => undefined),
}));
vi.mock("playwright-core", () => ({
chromium: {
launch,
},
}));
import {
closeAllQaWebSessions,
qaWebEvaluate,
qaWebOpenPage,
qaWebSnapshot,
qaWebType,
qaWebWait,
} from "./web-runtime.js";
beforeEach(async () => {
const page = {
goto,
title: pageTitle,
url: pageUrl,
waitForSelector: pageWaitForSelector,
waitForFunction: pageWaitForFunction,
locator: vi.fn((selector: string) => {
if (selector === "body") {
return bodyLocator;
}
return {
first: () => ({
waitFor: locatorWaitFor,
fill: locatorFill,
press: locatorPress,
}),
};
}),
evaluate: pageEvaluate,
};
const context = {
newPage: vi.fn(async () => page),
close: contextClose,
};
const browser = {
newContext: vi.fn(async () => context),
close: browserClose,
};
contextNewPage.mockResolvedValue(page);
launch.mockResolvedValue(browser);
vi.clearAllMocks();
});
describe("qa web runtime", () => {
it("opens, interacts with, snapshots, and closes a page", async () => {
const opened = await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat" });
await qaWebWait({ pageId: opened.pageId, selector: "textarea" });
await qaWebWait({ pageId: opened.pageId, text: "bridge armed" });
await qaWebType({
pageId: opened.pageId,
selector: "textarea",
text: "hello",
submit: true,
});
const snapshot = await qaWebSnapshot({ pageId: opened.pageId, maxChars: 5 });
const evaluated = await qaWebEvaluate({ pageId: opened.pageId, expression: "'ok'" });
await closeAllQaWebSessions();
expect(launch).toHaveBeenCalledWith(
expect.objectContaining({ channel: "chrome", headless: true }),
);
expect(goto).toHaveBeenCalledWith("http://127.0.0.1:3000/chat", expect.any(Object));
expect(pageWaitForSelector).toHaveBeenCalledWith("textarea", expect.any(Object));
expect(pageWaitForFunction).toHaveBeenCalled();
expect(locatorFill).toHaveBeenCalledWith("hello", expect.any(Object));
expect(locatorPress).toHaveBeenCalledWith("Enter", expect.any(Object));
expect(snapshot.text).toBe("hello");
expect(evaluated).toBe("ok");
expect(contextClose).toHaveBeenCalledTimes(1);
expect(browserClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,154 @@
import { randomUUID } from "node:crypto";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright-core";
type QaWebSession = {
browser: Browser;
context: BrowserContext;
page: Page;
};
type QaWebOpenPageParams = {
url: string;
headless?: boolean;
channel?: "chrome";
timeoutMs?: number;
viewport?: { width: number; height: number };
};
type QaWebWaitParams = {
pageId: string;
selector?: string;
text?: string;
timeoutMs?: number;
};
type QaWebTypeParams = {
pageId: string;
selector: string;
text: string;
submit?: boolean;
timeoutMs?: number;
};
type QaWebSnapshotParams = {
pageId: string;
timeoutMs?: number;
maxChars?: number;
};
type QaWebEvaluateParams = {
pageId: string;
expression: string;
timeoutMs?: number;
};
const sessions = new Map<string, QaWebSession>();
const DEFAULT_WEB_TIMEOUT_MS = 20_000;
function resolveTimeoutMs(timeoutMs: number | undefined, fallbackMs = DEFAULT_WEB_TIMEOUT_MS) {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return fallbackMs;
}
return Math.max(1, Math.floor(timeoutMs));
}
function resolveSession(pageId: string): QaWebSession {
const session = sessions.get(pageId);
if (!session) {
throw new Error(`unknown web session: ${pageId}`);
}
return session;
}
export async function qaWebOpenPage(params: QaWebOpenPageParams) {
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
const browser = await chromium.launch({
channel: params.channel ?? "chrome",
headless: params.headless ?? true,
});
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: params.viewport ?? { width: 1440, height: 1080 },
});
const page = await context.newPage();
await page.goto(params.url, {
waitUntil: "domcontentloaded",
timeout: timeoutMs,
});
const pageId = randomUUID();
sessions.set(pageId, { browser, context, page });
return {
pageId,
url: page.url(),
title: await page.title().catch(() => ""),
};
}
export async function qaWebWait(params: QaWebWaitParams) {
const session = resolveSession(params.pageId);
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
if (params.selector) {
await session.page.waitForSelector(params.selector, { timeout: timeoutMs });
return { ok: true };
}
if (params.text) {
await session.page.waitForFunction(
(expected) => document.body?.innerText?.toLowerCase().includes(expected.toLowerCase()),
params.text,
{ timeout: timeoutMs },
);
return { ok: true };
}
throw new Error("web wait requires selector or text");
}
export async function qaWebType(params: QaWebTypeParams) {
const session = resolveSession(params.pageId);
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
const locator = session.page.locator(params.selector).first();
await locator.waitFor({ timeout: timeoutMs });
await locator.fill(params.text, { timeout: timeoutMs });
if (params.submit) {
await locator.press("Enter", { timeout: timeoutMs });
}
return { ok: true };
}
export async function qaWebSnapshot(params: QaWebSnapshotParams) {
const session = resolveSession(params.pageId);
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
const body = session.page.locator("body");
await body.waitFor({ timeout: timeoutMs });
const text = await body.innerText({ timeout: timeoutMs });
const maxChars =
typeof params.maxChars === "number" && Number.isFinite(params.maxChars)
? Math.max(1, Math.floor(params.maxChars))
: undefined;
return {
url: session.page.url(),
title: await session.page.title().catch(() => ""),
text: maxChars ? text.slice(0, maxChars) : text,
};
}
export async function qaWebEvaluate<T = unknown>(params: QaWebEvaluateParams): Promise<T> {
const session = resolveSession(params.pageId);
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
return (await Promise.race([
session.page.evaluate(({ expression }) => (0, eval)(expression) as unknown, {
expression: params.expression,
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`web evaluate timed out after ${timeoutMs}ms`)), timeoutMs),
),
])) as T;
}
export async function closeAllQaWebSessions(): Promise<void> {
const active = [...sessions.values()];
sessions.clear();
for (const session of active) {
await session.context.close().catch(() => {});
await session.browser.close().catch(() => {});
}
}