From 1a4766051847d38e3f9ef077ebb1c0762aba229a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 19:40:24 -0700 Subject: [PATCH] feat(browser): add qa web runtime support --- .../browser/src/browser/chrome-mcp.test.ts | 51 ++++- extensions/browser/src/browser/chrome-mcp.ts | 25 ++- .../gateway/browser-request.timeout.test.ts | 73 ++++++ .../browser/src/gateway/browser-request.ts | 32 ++- extensions/qa-lab/package.json | 3 + extensions/qa-lab/src/browser-runtime.test.ts | 169 ++++++++++++++ extensions/qa-lab/src/browser-runtime.ts | 212 ++++++++++++++++++ extensions/qa-lab/src/web-runtime.test.ts | 117 ++++++++++ extensions/qa-lab/src/web-runtime.ts | 154 +++++++++++++ 9 files changed, 820 insertions(+), 16 deletions(-) create mode 100644 extensions/browser/src/gateway/browser-request.timeout.test.ts create mode 100644 extensions/qa-lab/src/browser-runtime.test.ts create mode 100644 extensions/qa-lab/src/browser-runtime.ts create mode 100644 extensions/qa-lab/src/web-runtime.test.ts create mode 100644 extensions/qa-lab/src/web-runtime.ts diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index 03204cf3b87..9c8d84f55c6 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -20,22 +20,33 @@ type ChromeMcpSessionFactory = Exclude< type ChromeMcpSession = Awaited>; 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); diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index 38943aa23bb..b310dac21a1 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -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(); const pendingSessions = new Map>(); @@ -401,16 +403,33 @@ export async function openChromeMcpTab( url: string, userDataDir?: string, ): Promise { - 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", }; } diff --git a/extensions/browser/src/gateway/browser-request.timeout.test.ts b/extensions/browser/src/gateway/browser-request.timeout.test.ts new file mode 100644 index 00000000000..1fd6db931d1 --- /dev/null +++ b/extensions/browser/src/gateway/browser-request.timeout.test.ts @@ -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("../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", + }), + ); + }); +}); diff --git a/extensions/browser/src/gateway/browser-request.ts b/extensions/browser/src/gateway/browser-request.ts index 724ced98e73..82e2b5b8ba4 100644 --- a/extensions/browser/src/gateway/browser-request.ts +++ b/extensions/browser/src/gateway/browser-request.ts @@ -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 = diff --git a/extensions/qa-lab/package.json b/extensions/qa-lab/package.json index 4d5bc6c3bcc..cb2b5d41a9e 100644 --- a/extensions/qa-lab/package.json +++ b/extensions/qa-lab/package.json @@ -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:*" diff --git a/extensions/qa-lab/src/browser-runtime.test.ts b/extensions/qa-lab/src/browser-runtime.test.ts new file mode 100644 index 00000000000..1654703fb51 --- /dev/null +++ b/extensions/qa-lab/src/browser-runtime.test.ts @@ -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 }, + ); + }); +}); diff --git a/extensions/qa-lab/src/browser-runtime.ts b/extensions/qa-lab/src/browser-runtime.ts new file mode 100644 index 00000000000..5cc91897871 --- /dev/null +++ b/extensions/qa-lab/src/browser-runtime.ts @@ -0,0 +1,212 @@ +type QaBrowserGateway = { + call: ( + method: string, + params: Record, + opts?: { timeoutMs?: number }, + ) => Promise; +}; + +type QaBrowserEnv = { + gateway: QaBrowserGateway; +}; + +type QaBrowserRequestParams = { + method: "GET" | "POST" | "DELETE"; + path: string; + query?: Record; + 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>; + 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 | 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( + env: QaBrowserEnv, + params: QaBrowserRequestParams, +): Promise { + 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( + env: QaBrowserEnv, + params: QaBrowserOpenTabParams, +): Promise { + return await callQaBrowserRequest(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( + env: QaBrowserEnv, + params: QaBrowserSnapshotParams = {}, +): Promise { + return await callQaBrowserRequest(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( + env: QaBrowserEnv, + params: QaBrowserActParams, +): Promise { + return await callQaBrowserRequest(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((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForQaBrowserReady( + env: QaBrowserEnv, + params: QaBrowserReadyParams = {}, +): Promise { + 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(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)})` : "" + }`, + ); +} diff --git a/extensions/qa-lab/src/web-runtime.test.ts b/extensions/qa-lab/src/web-runtime.test.ts new file mode 100644 index 00000000000..8b7fef8fca8 --- /dev/null +++ b/extensions/qa-lab/src/web-runtime.test.ts @@ -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); + }); +}); diff --git a/extensions/qa-lab/src/web-runtime.ts b/extensions/qa-lab/src/web-runtime.ts new file mode 100644 index 00000000000..b894760e9ae --- /dev/null +++ b/extensions/qa-lab/src/web-runtime.ts @@ -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(); +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(params: QaWebEvaluateParams): Promise { + 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((_, reject) => + setTimeout(() => reject(new Error(`web evaluate timed out after ${timeoutMs}ms`)), timeoutMs), + ), + ])) as T; +} + +export async function closeAllQaWebSessions(): Promise { + const active = [...sessions.values()]; + sessions.clear(); + for (const session of active) { + await session.context.close().catch(() => {}); + await session.browser.close().catch(() => {}); + } +}