From 235f3ce0ba3b27f92f8e8643db50386e304725fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 03:27:12 +0000 Subject: [PATCH] refactor(browser): simplify control API --- src/browser/client-actions-core.ts | 260 ++++--------- src/browser/client-actions-observe.ts | 66 +--- src/browser/client-actions-types.ts | 1 - src/browser/client.test.ts | 135 ++++--- src/browser/client.ts | 133 +------ src/browser/pw-ai.test.ts | 4 +- src/browser/pw-ai.ts | 10 +- src/browser/pw-tools-core.ts | 51 ++- src/browser/pw-tools-observe.ts | 99 ----- src/browser/routes/actions-core.test.ts | 273 -------------- src/browser/routes/actions-core.ts | 307 --------------- src/browser/routes/actions-extra.test.ts | 174 --------- src/browser/routes/actions-extra.ts | 144 ------- src/browser/routes/actions.ts | 198 ---------- src/browser/routes/agent.ts | 456 +++++++++++++++++++++++ src/browser/routes/index.ts | 6 +- src/browser/routes/inspect.ts | 244 ------------ src/browser/server.test.ts | 113 +++--- src/cli/browser-cli-actions-input.ts | 69 ++-- src/cli/browser-cli-actions-observe.ts | 118 ------ src/cli/browser-cli-examples.ts | 6 - src/cli/browser-cli-inspect.ts | 119 +----- src/cli/browser-cli-manage.ts | 4 +- 23 files changed, 776 insertions(+), 2214 deletions(-) delete mode 100644 src/browser/pw-tools-observe.ts delete mode 100644 src/browser/routes/actions-core.test.ts delete mode 100644 src/browser/routes/actions-core.ts delete mode 100644 src/browser/routes/actions-extra.test.ts delete mode 100644 src/browser/routes/actions-extra.ts delete mode 100644 src/browser/routes/actions.ts create mode 100644 src/browser/routes/agent.ts delete mode 100644 src/browser/routes/inspect.ts diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index 35701f3d9..36bc51ea8 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -1,10 +1,54 @@ -import type { ScreenshotResult } from "./client.js"; import type { BrowserActionOk, + BrowserActionPathResult, BrowserActionTabResult, } from "./client-actions-types.js"; import { fetchBrowserJson } from "./client-fetch.js"; +export type BrowserActRequest = + | { + kind: "click"; + ref: string; + targetId?: string; + doubleClick?: boolean; + button?: string; + modifiers?: string[]; + } + | { + kind: "type"; + ref: string; + text: string; + targetId?: string; + submit?: boolean; + slowly?: boolean; + } + | { kind: "press"; key: string; targetId?: string } + | { kind: "hover"; ref: string; targetId?: string } + | { kind: "drag"; startRef: string; endRef: string; targetId?: string } + | { kind: "select"; ref: string; values: string[]; targetId?: string } + | { + kind: "fill"; + fields: Array>; + targetId?: string; + } + | { kind: "resize"; width: number; height: number; targetId?: string } + | { + kind: "wait"; + timeMs?: number; + text?: string; + textGone?: string; + targetId?: string; + } + | { kind: "evaluate"; fn: string; ref?: string; targetId?: string } + | { kind: "close"; targetId?: string }; + +export type BrowserActResponse = { + ok: true; + targetId: string; + url?: string; + result?: unknown; +}; + export async function browserNavigate( baseUrl: string, opts: { url: string; targetId?: string }, @@ -17,219 +61,63 @@ export async function browserNavigate( }); } -export async function browserResize( - baseUrl: string, - opts: { width: number; height: number; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/resize`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - width: opts.width, - height: opts.height, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserClosePage( - baseUrl: string, - opts: { targetId?: string } = {}, -): Promise { - return await fetchBrowserJson(`${baseUrl}/close`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserClick( +export async function browserArmDialog( baseUrl: string, opts: { - ref: string; + accept: boolean; + promptText?: string; targetId?: string; - doubleClick?: boolean; - button?: string; - modifiers?: string[]; + timeoutMs?: number; }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/click`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - targetId: opts.targetId, - doubleClick: opts.doubleClick, - button: opts.button, - modifiers: opts.modifiers, - }), - timeoutMs: 20000, - }); -} - -export async function browserType( - baseUrl: string, - opts: { - ref: string; - text: string; - targetId?: string; - submit?: boolean; - slowly?: boolean; - }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/type`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - text: opts.text, - targetId: opts.targetId, - submit: opts.submit, - slowly: opts.slowly, - }), - timeoutMs: 20000, - }); -} - -export async function browserPressKey( - baseUrl: string, - opts: { key: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/press`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key: opts.key, targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserHover( - baseUrl: string, - opts: { ref: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/hover`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ref: opts.ref, targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserDrag( - baseUrl: string, - opts: { startRef: string; endRef: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/drag`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - startRef: opts.startRef, - endRef: opts.endRef, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserSelectOption( - baseUrl: string, - opts: { ref: string; values: string[]; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/select`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - values: opts.values, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserUpload( - baseUrl: string, - opts: { paths?: string[]; targetId?: string } = {}, -): Promise { - return await fetchBrowserJson(`${baseUrl}/upload`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ paths: opts.paths, targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserFillForm( - baseUrl: string, - opts: { fields: Array>; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/fill`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fields: opts.fields, targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserHandleDialog( - baseUrl: string, - opts: { accept: boolean; promptText?: string; targetId?: string }, ): Promise { - return await fetchBrowserJson(`${baseUrl}/dialog`, { + return await fetchBrowserJson(`${baseUrl}/hooks/dialog`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accept: opts.accept, promptText: opts.promptText, targetId: opts.targetId, + timeoutMs: opts.timeoutMs, }), timeoutMs: 20000, }); } -export async function browserWaitFor( +export async function browserArmFileChooser( baseUrl: string, opts: { - time?: number; - text?: string; - textGone?: string; + paths: string[]; targetId?: string; + timeoutMs?: number; }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/wait`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - time: opts.time, - text: opts.text, - textGone: opts.textGone, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserEvaluate( - baseUrl: string, - opts: { fn: string; ref?: string; targetId?: string }, -): Promise<{ ok: true; result: unknown }> { - return await fetchBrowserJson<{ ok: true; result: unknown }>( - `${baseUrl}/evaluate`, +): Promise { + return await fetchBrowserJson( + `${baseUrl}/hooks/file-chooser`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - function: opts.fn, - ref: opts.ref, + paths: opts.paths, targetId: opts.targetId, + timeoutMs: opts.timeoutMs, }), timeoutMs: 20000, }, ); } +export async function browserAct( + baseUrl: string, + req: BrowserActRequest, +): Promise { + return await fetchBrowserJson(`${baseUrl}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + timeoutMs: 20000, + }); +} + export async function browserScreenshotAction( baseUrl: string, opts: { @@ -238,10 +126,9 @@ export async function browserScreenshotAction( ref?: string; element?: string; type?: "png" | "jpeg"; - filename?: string; }, -): Promise { - return await fetchBrowserJson( +): Promise { + return await fetchBrowserJson( `${baseUrl}/screenshot`, { method: "POST", @@ -252,7 +139,6 @@ export async function browserScreenshotAction( ref: opts.ref, element: opts.element, type: opts.type, - filename: opts.filename, }), timeoutMs: 20000, }, diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts index 4b648d3f6..f5dbdbdaa 100644 --- a/src/browser/client-actions-observe.ts +++ b/src/browser/client-actions-observe.ts @@ -1,7 +1,4 @@ -import type { - BrowserActionOk, - BrowserActionPathResult, -} from "./client-actions-types.js"; +import type { BrowserActionPathResult } from "./client-actions-types.js"; import { fetchBrowserJson } from "./client-fetch.js"; import type { BrowserConsoleMessage } from "./pw-session.js"; @@ -31,64 +28,3 @@ export async function browserPdfSave( timeoutMs: 20000, }); } - -export async function browserVerifyElementVisible( - baseUrl: string, - opts: { role: string; accessibleName: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/verify/element`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - role: opts.role, - accessibleName: opts.accessibleName, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserVerifyTextVisible( - baseUrl: string, - opts: { text: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/verify/text`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: opts.text, targetId: opts.targetId }), - timeoutMs: 20000, - }); -} - -export async function browserVerifyListVisible( - baseUrl: string, - opts: { ref: string; items: string[]; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/verify/list`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - items: opts.items, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} - -export async function browserVerifyValue( - baseUrl: string, - opts: { ref: string; type: string; value?: string; targetId?: string }, -): Promise { - return await fetchBrowserJson(`${baseUrl}/verify/value`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - type: opts.type, - value: opts.value, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }); -} diff --git a/src/browser/client-actions-types.ts b/src/browser/client-actions-types.ts index 648faff17..c1c765ff7 100644 --- a/src/browser/client-actions-types.ts +++ b/src/browser/client-actions-types.ts @@ -11,5 +11,4 @@ export type BrowserActionPathResult = { path: string; targetId: string; url?: string; - filename?: string; }; diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index 544c80129..cb4c4bfdd 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -1,15 +1,20 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - browserClickRef, - browserDom, browserOpenTab, - browserQuery, - browserScreenshot, browserSnapshot, browserStatus, browserTabs, } from "./client.js"; +import { + browserAct, + browserArmDialog, + browserArmFileChooser, + browserConsoleMessages, + browserNavigate, + browserPdfSave, + browserScreenshotAction, +} from "./client-actions.js"; describe("browser client", () => { afterEach(() => { @@ -49,7 +54,7 @@ describe("browser client", () => { ); await expect( - browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 1 }), + browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }), ).rejects.toThrow(/409: conflict/i); }); @@ -79,7 +84,61 @@ describe("browser client", () => { }), } as unknown as Response; } - if (url.includes("/screenshot")) { + if (url.endsWith("/navigate")) { + return { + ok: true, + json: async () => ({ + ok: true, + targetId: "t1", + url: "https://y", + }), + } as unknown as Response; + } + if (url.endsWith("/act")) { + return { + ok: true, + json: async () => ({ + ok: true, + targetId: "t1", + url: "https://x", + result: 1, + }), + } as unknown as Response; + } + if (url.endsWith("/hooks/file-chooser")) { + return { + ok: true, + json: async () => ({ ok: true }), + } as unknown as Response; + } + if (url.endsWith("/hooks/dialog")) { + return { + ok: true, + json: async () => ({ ok: true }), + } as unknown as Response; + } + if (url.includes("/console?")) { + return { + ok: true, + json: async () => ({ + ok: true, + targetId: "t1", + messages: [], + }), + } as unknown as Response; + } + if (url.endsWith("/pdf")) { + return { + ok: true, + json: async () => ({ + ok: true, + path: "/tmp/a.pdf", + targetId: "t1", + url: "https://x", + }), + } as unknown as Response; + } + if (url.endsWith("/screenshot")) { return { ok: true, json: async () => ({ @@ -90,29 +149,6 @@ describe("browser client", () => { }), } as unknown as Response; } - if (url.includes("/query?")) { - return { - ok: true, - json: async () => ({ - ok: true, - targetId: "t1", - url: "https://x", - matches: [{ index: 0, tag: "a" }], - }), - } as unknown as Response; - } - if (url.includes("/dom?")) { - return { - ok: true, - json: async () => ({ - ok: true, - targetId: "t1", - url: "https://x", - format: "text", - text: "hi", - }), - } as unknown as Response; - } if (url.includes("/snapshot?")) { return { ok: true, @@ -125,12 +161,6 @@ describe("browser client", () => { }), } as unknown as Response; } - if (url.endsWith("/click")) { - return { - ok: true, - json: async () => ({ ok: true, targetId: "t1", url: "https://x" }), - } as unknown as Response; - } return { ok: true, json: async () => ({ @@ -163,24 +193,39 @@ describe("browser client", () => { browserOpenTab("http://127.0.0.1:18791", "https://example.com"), ).resolves.toMatchObject({ targetId: "t2" }); - await expect( - browserScreenshot("http://127.0.0.1:18791", { fullPage: true }), - ).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" }); - await expect( - browserQuery("http://127.0.0.1:18791", { selector: "a", limit: 1 }), - ).resolves.toMatchObject({ ok: true }); - await expect( - browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 10 }), - ).resolves.toMatchObject({ ok: true }); await expect( browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }), ).resolves.toMatchObject({ ok: true, format: "aria" }); + await expect( - browserClickRef("http://127.0.0.1:18791", { ref: "1" }), + browserNavigate("http://127.0.0.1:18791", { url: "https://example.com" }), + ).resolves.toMatchObject({ ok: true, targetId: "t1" }); + await expect( + browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }), + ).resolves.toMatchObject({ ok: true, targetId: "t1" }); + await expect( + browserArmFileChooser("http://127.0.0.1:18791", { + paths: ["/tmp/a.txt"], + }), ).resolves.toMatchObject({ ok: true }); + await expect( + browserArmDialog("http://127.0.0.1:18791", { accept: true }), + ).resolves.toMatchObject({ ok: true }); + await expect( + browserConsoleMessages("http://127.0.0.1:18791", { level: "error" }), + ).resolves.toMatchObject({ ok: true, targetId: "t1" }); + await expect( + browserPdfSave("http://127.0.0.1:18791"), + ).resolves.toMatchObject({ ok: true, path: "/tmp/a.pdf" }); + await expect( + browserScreenshotAction("http://127.0.0.1:18791", { fullPage: true }), + ).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" }); expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true); const open = calls.find((c) => c.url.endsWith("/tabs/open")); expect(open?.init?.method).toBe("POST"); + + const screenshot = calls.find((c) => c.url.endsWith("/screenshot")); + expect(screenshot?.init?.method).toBe("POST"); }); }); diff --git a/src/browser/client.ts b/src/browser/client.ts index 59d921f80..66f6f8d41 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -22,37 +22,6 @@ export type BrowserTab = { type?: string; }; -export type ScreenshotResult = { - ok: true; - path: string; - targetId: string; - url: string; -}; - -export type QueryResult = { - ok: true; - targetId: string; - url: string; - matches: Array<{ - index: number; - tag: string; - id?: string; - className?: string; - text?: string; - value?: string; - href?: string; - outerHTML?: string; - }>; -}; - -export type DomResult = { - ok: true; - targetId: string; - url: string; - format: "html" | "text"; - text: string; -}; - export type SnapshotAriaNode = { ref: string; role: string; @@ -71,26 +40,6 @@ export type SnapshotResult = url: string; nodes: SnapshotAriaNode[]; } - | { - ok: true; - format: "domSnapshot"; - targetId: string; - url: string; - nodes: Array<{ - ref: string; - parentRef: string | null; - depth: number; - tag: string; - id?: string; - className?: string; - role?: string; - name?: string; - text?: string; - href?: string; - type?: string; - value?: string; - }>; - } | { ok: true; format: "ai"; @@ -168,69 +117,10 @@ export async function browserCloseTab( }); } -export async function browserScreenshot( - baseUrl: string, - opts: { - targetId?: string; - fullPage?: boolean; - }, -): Promise { - const q = new URLSearchParams(); - if (opts.targetId) q.set("targetId", opts.targetId); - if (opts.fullPage) q.set("fullPage", "true"); - const suffix = q.toString() ? `?${q.toString()}` : ""; - return await fetchBrowserJson( - `${baseUrl}/screenshot${suffix}`, - { - timeoutMs: 20000, - }, - ); -} - -export async function browserQuery( - baseUrl: string, - opts: { - selector: string; - targetId?: string; - limit?: number; - }, -): Promise { - const q = new URLSearchParams(); - q.set("selector", opts.selector); - if (opts.targetId) q.set("targetId", opts.targetId); - if (typeof opts.limit === "number") q.set("limit", String(opts.limit)); - return await fetchBrowserJson( - `${baseUrl}/query?${q.toString()}`, - { - timeoutMs: 15000, - }, - ); -} - -export async function browserDom( - baseUrl: string, - opts: { - format: "html" | "text"; - targetId?: string; - maxChars?: number; - selector?: string; - }, -): Promise { - const q = new URLSearchParams(); - q.set("format", opts.format); - if (opts.targetId) q.set("targetId", opts.targetId); - if (typeof opts.maxChars === "number") - q.set("maxChars", String(opts.maxChars)); - if (opts.selector) q.set("selector", opts.selector); - return await fetchBrowserJson(`${baseUrl}/dom?${q.toString()}`, { - timeoutMs: 20000, - }); -} - export async function browserSnapshot( baseUrl: string, opts: { - format: "aria" | "domSnapshot" | "ai"; + format: "aria" | "ai"; targetId?: string; limit?: number; }, @@ -247,25 +137,4 @@ export async function browserSnapshot( ); } -export async function browserClickRef( - baseUrl: string, - opts: { - ref: string; - targetId?: string; - }, -): Promise<{ ok: true; targetId: string; url: string }> { - return await fetchBrowserJson<{ ok: true; targetId: string; url: string }>( - `${baseUrl}/click`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ref: opts.ref, - targetId: opts.targetId, - }), - timeoutMs: 20000, - }, - ); -} - // Actions beyond the basic read-only commands live in client-actions.ts. diff --git a/src/browser/pw-ai.test.ts b/src/browser/pw-ai.test.ts index a3c3eac75..f096f8c25 100644 --- a/src/browser/pw-ai.test.ts +++ b/src/browser/pw-ai.test.ts @@ -100,7 +100,7 @@ describe("pw-ai", () => { ).mockResolvedValue(browser); const mod = await importModule(); - await mod.clickRefViaPlaywright({ + await mod.clickViaPlaywright({ cdpPort: 18792, targetId: "T1", ref: "76", @@ -135,7 +135,7 @@ describe("pw-ai", () => { const mod = await importModule(); await mod.snapshotAiViaPlaywright({ cdpPort: 18792, targetId: "T1" }); - await mod.clickRefViaPlaywright({ + await mod.clickViaPlaywright({ cdpPort: 18792, targetId: "T1", ref: "1", diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index 0a1a296b2..b984b9e5e 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -10,12 +10,12 @@ export { export { armDialogViaPlaywright, armFileUploadViaPlaywright, - clickRefViaPlaywright, clickViaPlaywright, closePageViaPlaywright, dragViaPlaywright, evaluateViaPlaywright, fillFormViaPlaywright, + getConsoleMessagesViaPlaywright, hoverViaPlaywright, navigateViaPlaywright, pdfViaPlaywright, @@ -27,11 +27,3 @@ export { typeViaPlaywright, waitForViaPlaywright, } from "./pw-tools-core.js"; - -export { - getConsoleMessagesViaPlaywright, - verifyElementVisibleViaPlaywright, - verifyListVisibleViaPlaywright, - verifyTextVisibleViaPlaywright, - verifyValueViaPlaywright, -} from "./pw-tools-observe.js"; diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index 15fb2085b..e69a87e7b 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -1,4 +1,5 @@ import { + type BrowserConsoleMessage, ensurePageState, getPageForTargetId, refLocator, @@ -36,15 +37,6 @@ export async function snapshotAiViaPlaywright(opts: { return { snapshot: String(result?.full ?? "") }; } -export async function clickRefViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - ref: string; - timeoutMs?: number; -}): Promise { - await clickViaPlaywright(opts); -} - export async function clickViaPlaywright(opts: { cdpPort: number; targetId?: string; @@ -300,15 +292,15 @@ export async function navigateViaPlaywright(opts: { export async function waitForViaPlaywright(opts: { cdpPort: number; targetId?: string; - time?: number; + timeMs?: number; text?: string; textGone?: string; timeoutMs?: number; }): Promise { const page = await getPageForTargetId(opts); ensurePageState(page); - if (typeof opts.time === "number" && Number.isFinite(opts.time)) { - await page.waitForTimeout(Math.max(0, opts.time) * 1000); + if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) { + await page.waitForTimeout(Math.max(0, opts.timeMs)); } if (opts.text) { await page @@ -348,6 +340,13 @@ export async function takeScreenshotViaPlaywright(opts: { const buffer = await locator.screenshot({ type }); return { buffer }; } + if (opts.element) { + if (opts.fullPage) + throw new Error("fullPage is not supported for element screenshots"); + const locator = page.locator(opts.element).first(); + const buffer = await locator.screenshot({ type }); + return { buffer }; + } const buffer = await page.screenshot({ type, fullPage: Boolean(opts.fullPage), @@ -387,3 +386,31 @@ export async function pdfViaPlaywright(opts: { const buffer = await page.pdf({ printBackground: true }); return { buffer }; } + +function consolePriority(level: string) { + switch (level) { + case "error": + return 3; + case "warning": + return 2; + case "info": + case "log": + return 1; + case "debug": + return 0; + default: + return 1; + } +} + +export async function getConsoleMessagesViaPlaywright(opts: { + cdpPort: number; + targetId?: string; + level?: string; +}): Promise { + const page = await getPageForTargetId(opts); + const state = ensurePageState(page); + if (!opts.level) return [...state.console]; + const min = consolePriority(opts.level); + return state.console.filter((msg) => consolePriority(msg.type) >= min); +} diff --git a/src/browser/pw-tools-observe.ts b/src/browser/pw-tools-observe.ts deleted file mode 100644 index 0301cfdb8..000000000 --- a/src/browser/pw-tools-observe.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - type BrowserConsoleMessage, - ensurePageState, - getPageForTargetId, - refLocator, -} from "./pw-session.js"; - -function consolePriority(level: string) { - switch (level) { - case "error": - return 3; - case "warning": - return 2; - case "info": - case "log": - return 1; - case "debug": - return 0; - default: - return 1; - } -} - -export async function getConsoleMessagesViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - level?: string; -}): Promise { - const page = await getPageForTargetId(opts); - const state = ensurePageState(page); - if (!opts.level) return [...state.console]; - const min = consolePriority(opts.level); - return state.console.filter((msg) => consolePriority(msg.type) >= min); -} - -export async function verifyElementVisibleViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - role: string; - accessibleName: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locator = page.getByRole(opts.role as never, { - name: opts.accessibleName, - }); - if ((await locator.count()) === 0) throw new Error("element not found"); - if (!(await locator.first().isVisible())) - throw new Error("element not visible"); -} - -export async function verifyTextVisibleViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - text: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locator = page.getByText(opts.text).filter({ visible: true }); - if ((await locator.count()) === 0) throw new Error("text not found"); -} - -export async function verifyListVisibleViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - ref: string; - items: string[]; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locator = refLocator(page, opts.ref); - for (const item of opts.items) { - const itemLocator = locator.getByText(item); - if ((await itemLocator.count()) === 0) - throw new Error(`item "${item}" not found`); - } -} - -export async function verifyValueViaPlaywright(opts: { - cdpPort: number; - targetId?: string; - ref: string; - type: string; - value: string; -}): Promise { - const page = await getPageForTargetId(opts); - ensurePageState(page); - const locator = refLocator(page, opts.ref); - if (opts.type === "checkbox" || opts.type === "radio") { - const checked = await locator.isChecked(); - const expected = opts.value === "true"; - if (checked !== expected) - throw new Error(`expected ${opts.value}, got ${String(checked)}`); - return; - } - const value = await locator.inputValue(); - if (value !== opts.value) - throw new Error(`expected ${opts.value}, got ${value}`); -} diff --git a/src/browser/routes/actions-core.test.ts b/src/browser/routes/actions-core.test.ts deleted file mode 100644 index dc77d7f70..000000000 --- a/src/browser/routes/actions-core.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { BrowserRouteContext } from "../server-context.js"; - -const pw = vi.hoisted(() => ({ - armDialogViaPlaywright: vi.fn().mockResolvedValue(undefined), - armFileUploadViaPlaywright: vi.fn().mockResolvedValue(undefined), - clickViaPlaywright: vi.fn().mockResolvedValue(undefined), - closePageViaPlaywright: vi.fn().mockResolvedValue(undefined), - dragViaPlaywright: vi.fn().mockResolvedValue(undefined), - evaluateViaPlaywright: vi.fn().mockResolvedValue("result"), - fillFormViaPlaywright: vi.fn().mockResolvedValue(undefined), - hoverViaPlaywright: vi.fn().mockResolvedValue(undefined), - navigateViaPlaywright: vi - .fn() - .mockResolvedValue({ url: "https://example.com" }), - pressKeyViaPlaywright: vi.fn().mockResolvedValue(undefined), - resizeViewportViaPlaywright: vi.fn().mockResolvedValue(undefined), - selectOptionViaPlaywright: vi.fn().mockResolvedValue(undefined), - typeViaPlaywright: vi.fn().mockResolvedValue(undefined), - waitForViaPlaywright: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../pw-ai.js", () => pw); - -import { handleBrowserActionCore } from "./actions-core.js"; - -const baseTab = { - targetId: "tab1", - title: "One", - url: "https://example.com", -}; - -function createRes() { - return { - statusCode: 200, - body: undefined as unknown, - status(code: number) { - this.statusCode = code; - return this; - }, - json(payload: unknown) { - this.body = payload; - return this; - }, - }; -} - -function createCtx( - overrides: Partial = {}, -): BrowserRouteContext { - return { - state: () => { - throw new Error("unused"); - }, - ensureBrowserAvailable: vi.fn().mockResolvedValue(undefined), - ensureTabAvailable: vi.fn().mockResolvedValue(baseTab), - isReachable: vi.fn().mockResolvedValue(true), - listTabs: vi - .fn() - .mockResolvedValue([ - baseTab, - { targetId: "tab2", title: "Two", url: "https://example.com/2" }, - ]), - openTab: vi.fn().mockResolvedValue({ - targetId: "newtab", - title: "", - url: "about:blank", - type: "page", - }), - focusTab: vi.fn().mockResolvedValue(undefined), - closeTab: vi.fn().mockResolvedValue(undefined), - stopRunningBrowser: vi.fn().mockResolvedValue({ stopped: true }), - mapTabError: vi.fn().mockReturnValue(null), - ...overrides, - }; -} - -async function callAction( - action: Parameters[0]["action"], - args: Record = {}, - ctxOverride?: Partial, -) { - const res = createRes(); - const ctx = createCtx(ctxOverride); - const handled = await handleBrowserActionCore({ - action, - args, - targetId: "", - cdpPort: 18792, - ctx, - res, - }); - return { res, ctx, handled }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("handleBrowserActionCore", () => { - it("dispatches core browser actions", async () => { - const cases = [ - { - action: "close" as const, - args: {}, - fn: pw.closePageViaPlaywright, - expectArgs: { cdpPort: 18792, targetId: "tab1" }, - expectBody: { ok: true, targetId: "tab1", url: baseTab.url }, - }, - { - action: "resize" as const, - args: { width: 800, height: 600 }, - fn: pw.resizeViewportViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - width: 800, - height: 600, - }, - expectBody: { ok: true, targetId: "tab1", url: baseTab.url }, - }, - { - action: "dialog" as const, - args: { accept: true, promptText: "ok" }, - fn: pw.armDialogViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - accept: true, - promptText: "ok", - }, - expectBody: { ok: true }, - }, - { - action: "evaluate" as const, - args: { function: "() => 1", ref: "1" }, - fn: pw.evaluateViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - fn: "() => 1", - ref: "1", - }, - expectBody: { ok: true, result: "result" }, - }, - { - action: "upload" as const, - args: { paths: ["/tmp/file.txt"] }, - fn: pw.armFileUploadViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - paths: ["/tmp/file.txt"], - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "fill" as const, - args: { fields: [{ ref: "1", value: "x" }] }, - fn: pw.fillFormViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - fields: [{ ref: "1", value: "x" }], - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "press" as const, - args: { key: "Enter" }, - fn: pw.pressKeyViaPlaywright, - expectArgs: { cdpPort: 18792, targetId: "tab1", key: "Enter" }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "type" as const, - args: { ref: "2", text: "hi", submit: true, slowly: true }, - fn: pw.typeViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - ref: "2", - text: "hi", - submit: true, - slowly: true, - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "navigate" as const, - args: { url: "https://example.com" }, - fn: pw.navigateViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - url: "https://example.com", - }, - expectBody: { ok: true, targetId: "tab1", url: baseTab.url }, - }, - { - action: "click" as const, - args: { - ref: "1", - doubleClick: true, - button: "right", - modifiers: ["Shift"], - }, - fn: pw.clickViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - ref: "1", - doubleClick: true, - button: "right", - modifiers: ["Shift"], - }, - expectBody: { ok: true, targetId: "tab1", url: baseTab.url }, - }, - { - action: "drag" as const, - args: { startRef: "1", endRef: "2" }, - fn: pw.dragViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - startRef: "1", - endRef: "2", - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "hover" as const, - args: { ref: "3" }, - fn: pw.hoverViaPlaywright, - expectArgs: { cdpPort: 18792, targetId: "tab1", ref: "3" }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "select" as const, - args: { ref: "4", values: ["A"] }, - fn: pw.selectOptionViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - ref: "4", - values: ["A"], - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - { - action: "wait" as const, - args: { time: 500, text: "ok", textGone: "bye" }, - fn: pw.waitForViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - time: 500, - text: "ok", - textGone: "bye", - }, - expectBody: { ok: true, targetId: "tab1" }, - }, - ]; - - for (const item of cases) { - const { res, handled } = await callAction(item.action, item.args); - expect(handled).toBe(true); - expect(item.fn).toHaveBeenCalledWith(item.expectArgs); - expect(res.body).toEqual(item.expectBody); - } - }); -}); diff --git a/src/browser/routes/actions-core.ts b/src/browser/routes/actions-core.ts deleted file mode 100644 index dfbab56b4..000000000 --- a/src/browser/routes/actions-core.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type express from "express"; - -import { - armDialogViaPlaywright, - armFileUploadViaPlaywright, - clickViaPlaywright, - closePageViaPlaywright, - dragViaPlaywright, - evaluateViaPlaywright, - fillFormViaPlaywright, - hoverViaPlaywright, - navigateViaPlaywright, - pressKeyViaPlaywright, - resizeViewportViaPlaywright, - selectOptionViaPlaywright, - typeViaPlaywright, - waitForViaPlaywright, -} from "../pw-ai.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { - jsonError, - toBoolean, - toNumber, - toStringArray, - toStringOrEmpty, -} from "./utils.js"; - -type MouseButton = "left" | "right" | "middle"; -type KeyboardModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; - -function normalizeMouseButton(value: unknown): MouseButton | undefined { - const raw = toStringOrEmpty(value); - if (raw === "left" || raw === "right" || raw === "middle") return raw; - return undefined; -} - -function normalizeModifiers(value: unknown): KeyboardModifier[] | undefined { - const raw = toStringArray(value); - if (!raw?.length) return undefined; - const normalized = raw.filter( - (m): m is KeyboardModifier => - m === "Alt" || - m === "Control" || - m === "ControlOrMeta" || - m === "Meta" || - m === "Shift", - ); - return normalized.length ? normalized : undefined; -} - -export type BrowserActionCore = - | "click" - | "close" - | "dialog" - | "drag" - | "evaluate" - | "fill" - | "hover" - | "navigate" - | "press" - | "resize" - | "select" - | "type" - | "upload" - | "wait"; - -type ActionCoreParams = { - action: BrowserActionCore; - args: Record; - targetId: string; - cdpPort: number; - ctx: BrowserRouteContext; - res: express.Response; -}; - -export async function handleBrowserActionCore( - params: ActionCoreParams, -): Promise { - const { action, args, targetId, cdpPort, ctx, res } = params; - const target = targetId || undefined; - - switch (action) { - case "close": { - const tab = await ctx.ensureTabAvailable(target); - await closePageViaPlaywright({ cdpPort, targetId: tab.targetId }); - res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - return true; - } - case "resize": { - const width = toNumber(args.width); - const height = toNumber(args.height); - if (!width || !height) { - jsonError(res, 400, "width and height are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await resizeViewportViaPlaywright({ - cdpPort, - targetId: tab.targetId, - width, - height, - }); - res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - return true; - } - case "dialog": { - const accept = toBoolean(args.accept); - if (accept === undefined) { - jsonError(res, 400, "accept is required"); - return true; - } - const promptText = toStringOrEmpty(args.promptText) || undefined; - const tab = await ctx.ensureTabAvailable(target); - await armDialogViaPlaywright({ - cdpPort, - targetId: tab.targetId, - accept, - promptText, - }); - res.json({ ok: true }); - return true; - } - case "evaluate": { - const fn = toStringOrEmpty(args.function); - if (!fn) { - jsonError(res, 400, "function is required"); - return true; - } - const ref = toStringOrEmpty(args.ref) || undefined; - const tab = await ctx.ensureTabAvailable(target); - const result = await evaluateViaPlaywright({ - cdpPort, - targetId: tab.targetId, - fn, - ref, - }); - res.json({ ok: true, result }); - return true; - } - case "upload": { - const paths = toStringArray(args.paths) ?? []; - const tab = await ctx.ensureTabAvailable(target); - await armFileUploadViaPlaywright({ - cdpPort, - targetId: tab.targetId, - paths: paths.length ? paths : undefined, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "fill": { - const fields = Array.isArray(args.fields) - ? (args.fields as Array>) - : null; - if (!fields?.length) { - jsonError(res, 400, "fields are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await fillFormViaPlaywright({ - cdpPort, - targetId: tab.targetId, - fields, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "press": { - const key = toStringOrEmpty(args.key); - if (!key) { - jsonError(res, 400, "key is required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await pressKeyViaPlaywright({ - cdpPort, - targetId: tab.targetId, - key, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "type": { - const ref = toStringOrEmpty(args.ref); - const text = toStringOrEmpty(args.text); - if (!ref || !text) { - jsonError(res, 400, "ref and text are required"); - return true; - } - const submit = toBoolean(args.submit) ?? false; - const slowly = toBoolean(args.slowly) ?? false; - const tab = await ctx.ensureTabAvailable(target); - await typeViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - text, - submit, - slowly, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "navigate": { - const url = toStringOrEmpty(args.url); - if (!url) { - jsonError(res, 400, "url is required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - const result = await navigateViaPlaywright({ - cdpPort, - targetId: tab.targetId, - url, - }); - res.json({ ok: true, targetId: tab.targetId, ...result }); - return true; - } - case "click": { - const ref = toStringOrEmpty(args.ref); - if (!ref) { - jsonError(res, 400, "ref is required"); - return true; - } - const doubleClick = toBoolean(args.doubleClick) ?? false; - const button = normalizeMouseButton(args.button); - const modifiers = normalizeModifiers(args.modifiers); - const tab = await ctx.ensureTabAvailable(target); - await clickViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - doubleClick, - button, - modifiers, - }); - res.json({ ok: true, targetId: tab.targetId, url: tab.url }); - return true; - } - case "drag": { - const startRef = toStringOrEmpty(args.startRef); - const endRef = toStringOrEmpty(args.endRef); - if (!startRef || !endRef) { - jsonError(res, 400, "startRef and endRef are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await dragViaPlaywright({ - cdpPort, - targetId: tab.targetId, - startRef, - endRef, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "hover": { - const ref = toStringOrEmpty(args.ref); - if (!ref) { - jsonError(res, 400, "ref is required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await hoverViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "select": { - const ref = toStringOrEmpty(args.ref); - const values = toStringArray(args.values); - if (!ref || !values?.length) { - jsonError(res, 400, "ref and values are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await selectOptionViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - values, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - case "wait": { - const time = toNumber(args.time); - const text = toStringOrEmpty(args.text) || undefined; - const textGone = toStringOrEmpty(args.textGone) || undefined; - const tab = await ctx.ensureTabAvailable(target); - await waitForViaPlaywright({ - cdpPort, - targetId: tab.targetId, - time, - text, - textGone, - }); - res.json({ ok: true, targetId: tab.targetId }); - return true; - } - default: - return false; - } -} diff --git a/src/browser/routes/actions-extra.test.ts b/src/browser/routes/actions-extra.test.ts deleted file mode 100644 index 896f610cf..000000000 --- a/src/browser/routes/actions-extra.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { BrowserRouteContext } from "../server-context.js"; - -const pw = vi.hoisted(() => ({ - getConsoleMessagesViaPlaywright: vi.fn().mockResolvedValue([]), - pdfViaPlaywright: vi.fn().mockResolvedValue({ buffer: Buffer.from("pdf") }), - verifyElementVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined), - verifyListVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined), - verifyTextVisibleViaPlaywright: vi.fn().mockResolvedValue(undefined), - verifyValueViaPlaywright: vi.fn().mockResolvedValue(undefined), -})); - -const media = vi.hoisted(() => ({ - ensureMediaDir: vi.fn().mockResolvedValue(undefined), - saveMediaBuffer: vi.fn().mockResolvedValue({ path: "/tmp/fake.pdf" }), -})); - -vi.mock("../pw-ai.js", () => pw); -vi.mock("../../media/store.js", () => media); - -import { handleBrowserActionExtra } from "./actions-extra.js"; - -const baseTab = { - targetId: "tab1", - title: "One", - url: "https://example.com", -}; - -function createRes() { - return { - statusCode: 200, - body: undefined as unknown, - status(code: number) { - this.statusCode = code; - return this; - }, - json(payload: unknown) { - this.body = payload; - return this; - }, - }; -} - -function createCtx( - overrides: Partial = {}, -): BrowserRouteContext { - return { - state: () => { - throw new Error("unused"); - }, - ensureBrowserAvailable: vi.fn().mockResolvedValue(undefined), - ensureTabAvailable: vi.fn().mockResolvedValue(baseTab), - isReachable: vi.fn().mockResolvedValue(true), - listTabs: vi.fn().mockResolvedValue([baseTab]), - openTab: vi.fn().mockResolvedValue({ - targetId: "newtab", - title: "", - url: "about:blank", - type: "page", - }), - focusTab: vi.fn().mockResolvedValue(undefined), - closeTab: vi.fn().mockResolvedValue(undefined), - stopRunningBrowser: vi.fn().mockResolvedValue({ stopped: true }), - mapTabError: vi.fn().mockReturnValue(null), - ...overrides, - }; -} - -async function callAction( - action: Parameters[0]["action"], - args: Record = {}, -) { - const res = createRes(); - const ctx = createCtx(); - const handled = await handleBrowserActionExtra({ - action, - args, - targetId: "", - cdpPort: 18792, - ctx, - res, - }); - return { res, ctx, handled }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("handleBrowserActionExtra", () => { - it("dispatches extra browser actions", async () => { - const cases = [ - { - action: "console" as const, - args: { level: "error" }, - fn: pw.getConsoleMessagesViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - level: "error", - }, - expectBody: { ok: true, messages: [], targetId: "tab1" }, - }, - { - action: "verifyElement" as const, - args: { role: "button", accessibleName: "Submit" }, - fn: pw.verifyElementVisibleViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - role: "button", - accessibleName: "Submit", - }, - expectBody: { ok: true }, - }, - { - action: "verifyText" as const, - args: { text: "Hello" }, - fn: pw.verifyTextVisibleViaPlaywright, - expectArgs: { cdpPort: 18792, targetId: "tab1", text: "Hello" }, - expectBody: { ok: true }, - }, - { - action: "verifyList" as const, - args: { ref: "1", items: ["a", "b"] }, - fn: pw.verifyListVisibleViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - ref: "1", - items: ["a", "b"], - }, - expectBody: { ok: true }, - }, - { - action: "verifyValue" as const, - args: { ref: "2", type: "textbox", value: "x" }, - fn: pw.verifyValueViaPlaywright, - expectArgs: { - cdpPort: 18792, - targetId: "tab1", - ref: "2", - type: "textbox", - value: "x", - }, - expectBody: { ok: true }, - }, - ]; - - for (const item of cases) { - const { res, handled } = await callAction(item.action, item.args); - expect(handled).toBe(true); - expect(item.fn).toHaveBeenCalledWith(item.expectArgs); - expect(res.body).toEqual(item.expectBody); - } - }); - - it("stores PDF output", async () => { - const { res: pdfRes } = await callAction("pdf"); - expect(pw.pdfViaPlaywright).toHaveBeenCalledWith({ - cdpPort: 18792, - targetId: "tab1", - }); - expect(media.ensureMediaDir).toHaveBeenCalled(); - expect(media.saveMediaBuffer).toHaveBeenCalled(); - expect(pdfRes.body).toMatchObject({ - ok: true, - path: "/tmp/fake.pdf", - targetId: "tab1", - url: baseTab.url, - }); - }); -}); diff --git a/src/browser/routes/actions-extra.ts b/src/browser/routes/actions-extra.ts deleted file mode 100644 index db581f880..000000000 --- a/src/browser/routes/actions-extra.ts +++ /dev/null @@ -1,144 +0,0 @@ -import path from "node:path"; - -import type express from "express"; - -import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; -import { - getConsoleMessagesViaPlaywright, - pdfViaPlaywright, - verifyElementVisibleViaPlaywright, - verifyListVisibleViaPlaywright, - verifyTextVisibleViaPlaywright, - verifyValueViaPlaywright, -} from "../pw-ai.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { jsonError, toStringArray, toStringOrEmpty } from "./utils.js"; - -export type BrowserActionExtra = - | "console" - | "pdf" - | "verifyElement" - | "verifyList" - | "verifyText" - | "verifyValue"; - -type ActionExtraParams = { - action: BrowserActionExtra; - args: Record; - targetId: string; - cdpPort: number; - ctx: BrowserRouteContext; - res: express.Response; -}; - -export async function handleBrowserActionExtra( - params: ActionExtraParams, -): Promise { - const { action, args, targetId, cdpPort, ctx, res } = params; - const target = targetId || undefined; - - switch (action) { - case "console": { - const level = toStringOrEmpty(args.level) || undefined; - const tab = await ctx.ensureTabAvailable(target); - const messages = await getConsoleMessagesViaPlaywright({ - cdpPort, - targetId: tab.targetId, - level, - }); - res.json({ ok: true, messages, targetId: tab.targetId }); - return true; - } - case "pdf": { - const tab = await ctx.ensureTabAvailable(target); - const pdf = await pdfViaPlaywright({ - cdpPort, - targetId: tab.targetId, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - pdf.buffer, - "application/pdf", - "browser", - pdf.buffer.byteLength, - ); - res.json({ - ok: true, - path: path.resolve(saved.path), - targetId: tab.targetId, - url: tab.url, - }); - return true; - } - case "verifyElement": { - const role = toStringOrEmpty(args.role); - const accessibleName = toStringOrEmpty(args.accessibleName); - if (!role || !accessibleName) { - jsonError(res, 400, "role and accessibleName are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await verifyElementVisibleViaPlaywright({ - cdpPort, - targetId: tab.targetId, - role, - accessibleName, - }); - res.json({ ok: true }); - return true; - } - case "verifyText": { - const text = toStringOrEmpty(args.text); - if (!text) { - jsonError(res, 400, "text is required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await verifyTextVisibleViaPlaywright({ - cdpPort, - targetId: tab.targetId, - text, - }); - res.json({ ok: true }); - return true; - } - case "verifyList": { - const ref = toStringOrEmpty(args.ref); - const items = toStringArray(args.items); - if (!ref || !items?.length) { - jsonError(res, 400, "ref and items are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await verifyListVisibleViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - items, - }); - res.json({ ok: true }); - return true; - } - case "verifyValue": { - const ref = toStringOrEmpty(args.ref); - const type = toStringOrEmpty(args.type); - const value = toStringOrEmpty(args.value); - if (!ref || !type) { - jsonError(res, 400, "ref and type are required"); - return true; - } - const tab = await ctx.ensureTabAvailable(target); - await verifyValueViaPlaywright({ - cdpPort, - targetId: tab.targetId, - ref, - type, - value, - }); - res.json({ ok: true }); - return true; - } - default: - return false; - } -} diff --git a/src/browser/routes/actions.ts b/src/browser/routes/actions.ts deleted file mode 100644 index 76633dd8c..000000000 --- a/src/browser/routes/actions.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type express from "express"; - -import type { BrowserRouteContext } from "../server-context.js"; -import { handleBrowserActionCore } from "./actions-core.js"; -import { handleBrowserActionExtra } from "./actions-extra.js"; -import { jsonError, toStringOrEmpty } from "./utils.js"; - -function readBody(req: express.Request): Record { - const body = req.body as Record | undefined; - if (!body || typeof body !== "object" || Array.isArray(body)) return {}; - return body; -} - -function readTargetId(value: unknown): string { - return toStringOrEmpty(value); -} - -function handleActionError( - ctx: BrowserRouteContext, - res: express.Response, - err: unknown, -) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); -} - -async function runCoreAction( - ctx: BrowserRouteContext, - res: express.Response, - action: Parameters[0]["action"], - args: Record, - targetId: string, -) { - try { - const cdpPort = ctx.state().cdpPort; - await handleBrowserActionCore({ - action, - args, - targetId, - cdpPort, - ctx, - res, - }); - } catch (err) { - handleActionError(ctx, res, err); - } -} - -async function runExtraAction( - ctx: BrowserRouteContext, - res: express.Response, - action: Parameters[0]["action"], - args: Record, - targetId: string, -) { - try { - const cdpPort = ctx.state().cdpPort; - await handleBrowserActionExtra({ - action, - args, - targetId, - cdpPort, - ctx, - res, - }); - } catch (err) { - handleActionError(ctx, res, err); - } -} - -export function registerBrowserActionRoutes( - app: express.Express, - ctx: BrowserRouteContext, -) { - app.post("/navigate", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "navigate", body, targetId); - }); - - app.post("/resize", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "resize", body, targetId); - }); - - app.post("/close", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "close", body, targetId); - }); - - app.post("/click", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "click", body, targetId); - }); - - app.post("/type", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "type", body, targetId); - }); - - app.post("/press", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "press", body, targetId); - }); - - app.post("/hover", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "hover", body, targetId); - }); - - app.post("/drag", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "drag", body, targetId); - }); - - app.post("/select", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "select", body, targetId); - }); - - app.post("/upload", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "upload", body, targetId); - }); - - app.post("/fill", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "fill", body, targetId); - }); - - app.post("/dialog", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "dialog", body, targetId); - }); - - app.post("/wait", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "wait", body, targetId); - }); - - app.post("/evaluate", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runCoreAction(ctx, res, "evaluate", body, targetId); - }); - - app.get("/console", async (req, res) => { - const targetId = readTargetId(req.query.targetId); - const level = toStringOrEmpty(req.query.level); - const args = level ? { level } : {}; - await runExtraAction(ctx, res, "console", args, targetId); - }); - - app.post("/pdf", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runExtraAction(ctx, res, "pdf", body, targetId); - }); - - app.post("/verify/element", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runExtraAction(ctx, res, "verifyElement", body, targetId); - }); - - app.post("/verify/text", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runExtraAction(ctx, res, "verifyText", body, targetId); - }); - - app.post("/verify/list", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runExtraAction(ctx, res, "verifyList", body, targetId); - }); - - app.post("/verify/value", async (req, res) => { - const body = readBody(req); - const targetId = readTargetId(body.targetId); - await runExtraAction(ctx, res, "verifyValue", body, targetId); - }); - - // Intentionally no coordinate-based mouse actions (move/click/drag). -} diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts new file mode 100644 index 000000000..2ce85e94f --- /dev/null +++ b/src/browser/routes/agent.ts @@ -0,0 +1,456 @@ +import path from "node:path"; + +import type express from "express"; + +import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; +import { snapshotAria } from "../cdp.js"; +import { + armDialogViaPlaywright, + armFileUploadViaPlaywright, + clickViaPlaywright, + closePageViaPlaywright, + dragViaPlaywright, + evaluateViaPlaywright, + fillFormViaPlaywright, + getConsoleMessagesViaPlaywright, + hoverViaPlaywright, + navigateViaPlaywright, + pdfViaPlaywright, + pressKeyViaPlaywright, + resizeViewportViaPlaywright, + selectOptionViaPlaywright, + snapshotAiViaPlaywright, + takeScreenshotViaPlaywright, + typeViaPlaywright, + waitForViaPlaywright, +} from "../pw-ai.js"; +import { + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + normalizeBrowserScreenshot, +} from "../screenshot.js"; +import type { BrowserRouteContext } from "../server-context.js"; +import { + jsonError, + toBoolean, + toNumber, + toStringArray, + toStringOrEmpty, +} from "./utils.js"; + +type ActKind = + | "click" + | "close" + | "drag" + | "evaluate" + | "fill" + | "hover" + | "press" + | "resize" + | "select" + | "type" + | "wait"; + +type ClickButton = "left" | "right" | "middle"; +type ClickModifier = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift"; + +function readBody(req: express.Request): Record { + const body = req.body as Record | undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) return {}; + return body; +} + +function handleRouteError( + ctx: BrowserRouteContext, + res: express.Response, + err: unknown, +) { + const mapped = ctx.mapTabError(err); + if (mapped) return jsonError(res, mapped.status, mapped.message); + jsonError(res, 500, String(err)); +} + +function parseClickButton(raw: string): ClickButton | undefined { + if (raw === "left" || raw === "right" || raw === "middle") return raw; + return undefined; +} + +export function registerBrowserAgentRoutes( + app: express.Express, + ctx: BrowserRouteContext, +) { + app.post("/navigate", async (req, res) => { + const body = readBody(req); + const url = toStringOrEmpty(body.url); + const targetId = toStringOrEmpty(body.targetId) || undefined; + if (!url) return jsonError(res, 400, "url is required"); + try { + const tab = await ctx.ensureTabAvailable(targetId); + const result = await navigateViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + url, + }); + res.json({ ok: true, targetId: tab.targetId, ...result }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/act", async (req, res) => { + const body = readBody(req); + const kind = toStringOrEmpty(body.kind) as ActKind; + const targetId = toStringOrEmpty(body.targetId) || undefined; + + if ( + kind !== "click" && + kind !== "close" && + kind !== "drag" && + kind !== "evaluate" && + kind !== "fill" && + kind !== "hover" && + kind !== "press" && + kind !== "resize" && + kind !== "select" && + kind !== "type" && + kind !== "wait" + ) { + return jsonError(res, 400, "kind is required"); + } + + try { + const tab = await ctx.ensureTabAvailable(targetId); + const cdpPort = ctx.state().cdpPort; + + switch (kind) { + case "click": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + const doubleClick = toBoolean(body.doubleClick) ?? false; + const buttonRaw = toStringOrEmpty(body.button) || ""; + const button = buttonRaw ? parseClickButton(buttonRaw) : undefined; + if (buttonRaw && !button) + return jsonError(res, 400, "button must be left|right|middle"); + + const modifiersRaw = toStringArray(body.modifiers) ?? []; + const allowedModifiers = new Set([ + "Alt", + "Control", + "ControlOrMeta", + "Meta", + "Shift", + ]); + const invalidModifiers = modifiersRaw.filter( + (m) => !allowedModifiers.has(m as ClickModifier), + ); + if (invalidModifiers.length) + return jsonError( + res, + 400, + "modifiers must be Alt|Control|ControlOrMeta|Meta|Shift", + ); + const modifiers = modifiersRaw.length + ? (modifiersRaw as ClickModifier[]) + : undefined; + await clickViaPlaywright({ + cdpPort, + targetId: tab.targetId, + ref, + doubleClick, + button, + modifiers, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + case "type": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + if (typeof body.text !== "string") + return jsonError(res, 400, "text is required"); + const text = body.text; + const submit = toBoolean(body.submit) ?? false; + const slowly = toBoolean(body.slowly) ?? false; + await typeViaPlaywright({ + cdpPort, + targetId: tab.targetId, + ref, + text, + submit, + slowly, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "press": { + const key = toStringOrEmpty(body.key); + if (!key) return jsonError(res, 400, "key is required"); + await pressKeyViaPlaywright({ cdpPort, targetId: tab.targetId, key }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "hover": { + const ref = toStringOrEmpty(body.ref); + if (!ref) return jsonError(res, 400, "ref is required"); + await hoverViaPlaywright({ cdpPort, targetId: tab.targetId, ref }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "drag": { + const startRef = toStringOrEmpty(body.startRef); + const endRef = toStringOrEmpty(body.endRef); + if (!startRef || !endRef) + return jsonError(res, 400, "startRef and endRef are required"); + await dragViaPlaywright({ + cdpPort, + targetId: tab.targetId, + startRef, + endRef, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "select": { + const ref = toStringOrEmpty(body.ref); + const values = toStringArray(body.values); + if (!ref || !values?.length) + return jsonError(res, 400, "ref and values are required"); + await selectOptionViaPlaywright({ + cdpPort, + targetId: tab.targetId, + ref, + values, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "fill": { + const fields = Array.isArray(body.fields) + ? (body.fields as Array>) + : null; + if (!fields?.length) + return jsonError(res, 400, "fields are required"); + await fillFormViaPlaywright({ + cdpPort, + targetId: tab.targetId, + fields, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "resize": { + const width = toNumber(body.width); + const height = toNumber(body.height); + if (!width || !height) + return jsonError(res, 400, "width and height are required"); + await resizeViewportViaPlaywright({ + cdpPort, + targetId: tab.targetId, + width, + height, + }); + return res.json({ ok: true, targetId: tab.targetId, url: tab.url }); + } + case "wait": { + const timeMs = toNumber(body.timeMs); + const text = toStringOrEmpty(body.text) || undefined; + const textGone = toStringOrEmpty(body.textGone) || undefined; + await waitForViaPlaywright({ + cdpPort, + targetId: tab.targetId, + timeMs, + text, + textGone, + }); + return res.json({ ok: true, targetId: tab.targetId }); + } + case "evaluate": { + const fn = toStringOrEmpty(body.fn); + if (!fn) return jsonError(res, 400, "fn is required"); + const ref = toStringOrEmpty(body.ref) || undefined; + const result = await evaluateViaPlaywright({ + cdpPort, + targetId: tab.targetId, + fn, + ref, + }); + return res.json({ + ok: true, + targetId: tab.targetId, + url: tab.url, + result, + }); + } + case "close": { + await closePageViaPlaywright({ cdpPort, targetId: tab.targetId }); + return res.json({ ok: true, targetId: tab.targetId }); + } + default: { + return jsonError(res, 400, "unsupported kind"); + } + } + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/hooks/file-chooser", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const paths = toStringArray(body.paths) ?? []; + const timeoutMs = toNumber(body.timeoutMs); + if (!paths.length) return jsonError(res, 400, "paths are required"); + try { + const tab = await ctx.ensureTabAvailable(targetId); + await armFileUploadViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + paths, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/hooks/dialog", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const accept = toBoolean(body.accept); + const promptText = toStringOrEmpty(body.promptText) || undefined; + const timeoutMs = toNumber(body.timeoutMs); + if (accept === undefined) return jsonError(res, 400, "accept is required"); + try { + const tab = await ctx.ensureTabAvailable(targetId); + await armDialogViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + accept, + promptText, + timeoutMs: timeoutMs ?? undefined, + }); + res.json({ ok: true }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/console", async (req, res) => { + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const level = typeof req.query.level === "string" ? req.query.level : ""; + + try { + const tab = await ctx.ensureTabAvailable(targetId || undefined); + const messages = await getConsoleMessagesViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + level: level.trim() || undefined, + }); + res.json({ ok: true, messages, targetId: tab.targetId }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/pdf", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + try { + const tab = await ctx.ensureTabAvailable(targetId); + const pdf = await pdfViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + pdf.buffer, + "application/pdf", + "browser", + pdf.buffer.byteLength, + ); + res.json({ + ok: true, + path: path.resolve(saved.path), + targetId: tab.targetId, + url: tab.url, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.post("/screenshot", async (req, res) => { + const body = readBody(req); + const targetId = toStringOrEmpty(body.targetId) || undefined; + const fullPage = toBoolean(body.fullPage) ?? false; + const ref = toStringOrEmpty(body.ref) || undefined; + const element = toStringOrEmpty(body.element) || undefined; + const type = body.type === "jpeg" ? "jpeg" : "png"; + + try { + const tab = await ctx.ensureTabAvailable(targetId); + const snap = await takeScreenshotViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + ref, + element, + fullPage, + type, + }); + + const normalized = await normalizeBrowserScreenshot(snap.buffer, { + maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, + maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + }); + await ensureMediaDir(); + const saved = await saveMediaBuffer( + normalized.buffer, + normalized.contentType ?? `image/${type}`, + "browser", + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, + ); + res.json({ + ok: true, + path: path.resolve(saved.path), + targetId: tab.targetId, + url: tab.url, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); + + app.get("/snapshot", async (req, res) => { + const targetId = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; + const format = req.query.format === "aria" ? "aria" : "ai"; + const limit = + typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; + + try { + const tab = await ctx.ensureTabAvailable(targetId || undefined); + if (format === "ai") { + const snap = await snapshotAiViaPlaywright({ + cdpPort: ctx.state().cdpPort, + targetId: tab.targetId, + }); + return res.json({ + ok: true, + format, + targetId: tab.targetId, + url: tab.url, + ...snap, + }); + } + + const snap = await snapshotAria({ + wsUrl: tab.wsUrl ?? "", + limit, + }); + return res.json({ + ok: true, + format, + targetId: tab.targetId, + url: tab.url, + ...snap, + }); + } catch (err) { + handleRouteError(ctx, res, err); + } + }); +} diff --git a/src/browser/routes/index.ts b/src/browser/routes/index.ts index 5dbc2dfca..3a3dc4623 100644 --- a/src/browser/routes/index.ts +++ b/src/browser/routes/index.ts @@ -1,9 +1,8 @@ import type express from "express"; import type { BrowserRouteContext } from "../server-context.js"; -import { registerBrowserActionRoutes } from "./actions.js"; +import { registerBrowserAgentRoutes } from "./agent.js"; import { registerBrowserBasicRoutes } from "./basic.js"; -import { registerBrowserInspectRoutes } from "./inspect.js"; import { registerBrowserTabRoutes } from "./tabs.js"; export function registerBrowserRoutes( @@ -12,6 +11,5 @@ export function registerBrowserRoutes( ) { registerBrowserBasicRoutes(app, ctx); registerBrowserTabRoutes(app, ctx); - registerBrowserInspectRoutes(app, ctx); - registerBrowserActionRoutes(app, ctx); + registerBrowserAgentRoutes(app, ctx); } diff --git a/src/browser/routes/inspect.ts b/src/browser/routes/inspect.ts deleted file mode 100644 index bedbc0309..000000000 --- a/src/browser/routes/inspect.ts +++ /dev/null @@ -1,244 +0,0 @@ -import path from "node:path"; - -import type express from "express"; -import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; -import { - captureScreenshot, - captureScreenshotPng, - getDomText, - querySelector, - snapshotAria, - snapshotDom, -} from "../cdp.js"; -import { - snapshotAiViaPlaywright, - takeScreenshotViaPlaywright, -} from "../pw-ai.js"; -import { - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - normalizeBrowserScreenshot, -} from "../screenshot.js"; -import type { BrowserRouteContext } from "../server-context.js"; -import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js"; - -export function registerBrowserInspectRoutes( - app: express.Express, - ctx: BrowserRouteContext, -) { - app.get("/screenshot", async (req, res) => { - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const fullPage = - req.query.fullPage === "true" || req.query.fullPage === "1"; - - try { - const tab = await ctx.ensureTabAvailable(targetId || undefined); - - let shot: Buffer = Buffer.alloc(0); - let contentTypeHint: "image/jpeg" | "image/png" = "image/jpeg"; - try { - shot = await captureScreenshot({ - wsUrl: tab.wsUrl ?? "", - fullPage, - format: "jpeg", - quality: 85, - }); - } catch { - contentTypeHint = "image/png"; - shot = await captureScreenshotPng({ - wsUrl: tab.wsUrl ?? "", - fullPage, - }); - } - - const normalized = await normalizeBrowserScreenshot(shot, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - normalized.buffer, - normalized.contentType ?? contentTypeHint, - "browser", - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - ); - const filePath = path.resolve(saved.path); - res.json({ - ok: true, - path: filePath, - targetId: tab.targetId, - url: tab.url, - }); - } catch (err) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); - } - }); - - app.post("/screenshot", async (req, res) => { - const body = req.body as Record; - const targetId = toStringOrEmpty(body?.targetId); - const fullPage = toBoolean(body?.fullPage) ?? false; - const ref = toStringOrEmpty(body?.ref); - const element = toStringOrEmpty(body?.element); - const type = body?.type === "jpeg" ? "jpeg" : "png"; - const filename = toStringOrEmpty(body?.filename); - - try { - const tab = await ctx.ensureTabAvailable(targetId || undefined); - const snap = await takeScreenshotViaPlaywright({ - cdpPort: ctx.state().cdpPort, - targetId: tab.targetId, - ref, - element, - fullPage, - type, - }); - const buffer = snap.buffer; - const normalized = await normalizeBrowserScreenshot(buffer, { - maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, - maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - }); - await ensureMediaDir(); - const saved = await saveMediaBuffer( - normalized.buffer, - normalized.contentType ?? `image/${type}`, - "browser", - DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, - ); - const filePath = path.resolve(saved.path); - res.json({ - ok: true, - path: filePath, - targetId: tab.targetId, - url: tab.url, - filename: filename || undefined, - }); - } catch (err) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); - } - }); - - app.get("/query", async (req, res) => { - const selector = - typeof req.query.selector === "string" ? req.query.selector.trim() : ""; - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const limit = - typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; - - if (!selector) return jsonError(res, 400, "selector is required"); - - try { - const tab = await ctx.ensureTabAvailable(targetId || undefined); - const result = await querySelector({ - wsUrl: tab.wsUrl ?? "", - selector, - limit, - }); - res.json({ ok: true, targetId: tab.targetId, url: tab.url, ...result }); - } catch (err) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); - } - }); - - app.get("/dom", async (req, res) => { - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const format = req.query.format === "text" ? "text" : "html"; - const selector = - typeof req.query.selector === "string" ? req.query.selector.trim() : ""; - const maxChars = - typeof req.query.maxChars === "string" - ? Number(req.query.maxChars) - : undefined; - - try { - const tab = await ctx.ensureTabAvailable(targetId || undefined); - const result = await getDomText({ - wsUrl: tab.wsUrl ?? "", - format, - maxChars, - selector: selector || undefined, - }); - res.json({ - ok: true, - targetId: tab.targetId, - url: tab.url, - format, - ...result, - }); - } catch (err) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); - } - }); - - app.get("/snapshot", async (req, res) => { - const targetId = - typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const format = - req.query.format === "domSnapshot" - ? "domSnapshot" - : req.query.format === "ai" - ? "ai" - : "aria"; - const limit = - typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; - - try { - const tab = await ctx.ensureTabAvailable(targetId || undefined); - - if (format === "ai") { - const snap = await snapshotAiViaPlaywright({ - cdpPort: ctx.state().cdpPort, - targetId: tab.targetId, - }); - return res.json({ - ok: true, - format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } - - if (format === "aria") { - const snap = await snapshotAria({ - wsUrl: tab.wsUrl ?? "", - limit, - }); - return res.json({ - ok: true, - format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } - - const snap = await snapshotDom({ - wsUrl: tab.wsUrl ?? "", - limit, - }); - return res.json({ - ok: true, - format, - targetId: tab.targetId, - url: tab.url, - ...snap, - }); - } catch (err) { - const mapped = ctx.mapTabError(err); - if (mapped) return jsonError(res, mapped.status, mapped.message); - jsonError(res, 500, String(err)); - } - }); -} diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index 037884eee..a027bd52f 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -7,7 +7,6 @@ let testPort = 0; let reachable = false; let cfgAttachOnly = false; let createTargetId: string | null = null; -let screenshotThrowsOnce = false; function makeProc(pid = 123) { const handlers = new Map void>>(); @@ -67,28 +66,14 @@ vi.mock("./cdp.js", () => ({ if (createTargetId) return { targetId: createTargetId }; throw new Error("cdp disabled"); }), - getDomText: vi.fn(async () => ({ text: "" })), - querySelector: vi.fn(async () => ({ matches: [{ index: 0, tag: "a" }] })), snapshotAria: vi.fn(async () => ({ nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], })), - snapshotDom: vi.fn(async () => ({ - nodes: [{ ref: "1", parentRef: null, depth: 0, tag: "html" }], - })), - captureScreenshot: vi.fn(async () => { - if (screenshotThrowsOnce) { - screenshotThrowsOnce = false; - throw new Error("jpeg failed"); - } - return Buffer.from("jpg"); - }), - captureScreenshotPng: vi.fn(async () => Buffer.from("png")), })); vi.mock("./pw-ai.js", () => ({ armDialogViaPlaywright: vi.fn(async () => {}), armFileUploadViaPlaywright: vi.fn(async () => {}), - clickRefViaPlaywright: vi.fn(async () => {}), clickViaPlaywright: vi.fn(async () => {}), closePageViaPlaywright: vi.fn(async () => {}), closePlaywrightBrowserConnection: vi.fn(async () => {}), @@ -106,10 +91,6 @@ vi.mock("./pw-ai.js", () => ({ buffer: Buffer.from("png"), })), typeViaPlaywright: vi.fn(async () => {}), - verifyElementVisibleViaPlaywright: vi.fn(async () => {}), - verifyListVisibleViaPlaywright: vi.fn(async () => {}), - verifyTextVisibleViaPlaywright: vi.fn(async () => {}), - verifyValueViaPlaywright: vi.fn(async () => {}), waitForViaPlaywright: vi.fn(async () => {}), dragViaPlaywright: vi.fn(async () => {}), })); @@ -159,7 +140,6 @@ describe("browser control server", () => { reachable = false; cfgAttachOnly = false; createTargetId = null; - screenshotThrowsOnce = false; testPort = await getFreePort(); @@ -270,24 +250,12 @@ describe("browser control server", () => { expect(focus.status).toBe(409); }); - it("supports query/dom/snapshot/click/screenshot and stop", async () => { + it("supports the agent contract and stop", async () => { const { startBrowserControlServerFromConfig } = await import("./server.js"); await startBrowserControlServerFromConfig(); const base = `http://127.0.0.1:${testPort}`; await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); - const query = (await realFetch(`${base}/query?selector=a&limit=1`).then( - (r) => r.json(), - )) as { ok: boolean; matches?: unknown[] }; - expect(query.ok).toBe(true); - expect(Array.isArray(query.matches)).toBe(true); - - const dom = (await realFetch(`${base}/dom?format=text&maxChars=10`).then( - (r) => r.json(), - )) as { ok: boolean; text?: string }; - expect(dom.ok).toBe(true); - expect(typeof dom.text).toBe("string"); - const snapAria = (await realFetch( `${base}/snapshot?format=aria&limit=1`, ).then((r) => r.json())) as { @@ -304,16 +272,61 @@ describe("browser control server", () => { expect(snapAi.ok).toBe(true); expect(snapAi.format).toBe("ai"); - const click = (await realFetch(`${base}/click`, { + const nav = (await realFetch(`${base}/navigate`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ref: "1" }), + body: JSON.stringify({ url: "https://example.com" }), + }).then((r) => r.json())) as { ok: boolean; targetId?: string }; + expect(nav.ok).toBe(true); + expect(typeof nav.targetId).toBe("string"); + + const click = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "click", ref: "1" }), }).then((r) => r.json())) as { ok: boolean }; expect(click.ok).toBe(true); - const shot = (await realFetch(`${base}/screenshot?fullPage=true`).then( - (r) => r.json(), - )) as { ok: boolean; path?: string }; + const evalRes = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "evaluate", fn: "() => 1" }), + }).then((r) => r.json())) as { ok: boolean; result?: unknown }; + expect(evalRes.ok).toBe(true); + + const upload = await realFetch(`${base}/hooks/file-chooser`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths: ["/tmp/a.txt"] }), + }).then((r) => r.json()); + expect(upload).toMatchObject({ ok: true }); + + const dialog = await realFetch(`${base}/hooks/dialog`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accept: true }), + }).then((r) => r.json()); + expect(dialog).toMatchObject({ ok: true }); + + const consoleRes = (await realFetch(`${base}/console`).then((r) => + r.json(), + )) as { ok: boolean; messages?: unknown[] }; + expect(consoleRes.ok).toBe(true); + expect(Array.isArray(consoleRes.messages)).toBe(true); + + const pdf = (await realFetch(`${base}/pdf`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }).then((r) => r.json())) as { ok: boolean; path?: string }; + expect(pdf.ok).toBe(true); + expect(typeof pdf.path).toBe("string"); + + const shot = (await realFetch(`${base}/screenshot`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullPage: true }), + }).then((r) => r.json())) as { ok: boolean; path?: string }; expect(shot.ok).toBe(true); expect(typeof shot.path).toBe("string"); @@ -344,7 +357,7 @@ describe("browser control server", () => { expect(started.error ?? "").toMatch(/attachOnly/i); }); - it("opens tabs via CDP createTarget path and falls back to PNG screenshots", async () => { + it("opens tabs via CDP createTarget path", async () => { const { startBrowserControlServerFromConfig } = await import("./server.js"); await startBrowserControlServerFromConfig(); const base = `http://127.0.0.1:${testPort}`; @@ -357,13 +370,6 @@ describe("browser control server", () => { body: JSON.stringify({ url: "https://example.com" }), }).then((r) => r.json())) as { targetId?: string }; expect(opened.targetId).toBe("abcd1234"); - - screenshotThrowsOnce = true; - const shot = (await realFetch(`${base}/screenshot`).then((r) => - r.json(), - )) as { ok: boolean; path?: string }; - expect(shot.ok).toBe(true); - expect(typeof shot.path).toBe("string"); }); it("covers additional endpoint branches", async () => { @@ -398,16 +404,9 @@ describe("browser control server", () => { }); expect(delAmbiguous.status).toBe(409); - const shotAmbiguous = await realFetch(`${base}/screenshot?targetId=abc`); - expect(shotAmbiguous.status).toBe(409); - - const queryMissing = await realFetch(`${base}/query`); - expect(queryMissing.status).toBe(400); - - const snapDom = (await realFetch( - `${base}/snapshot?format=domSnapshot&limit=1`, - ).then((r) => r.json())) as { ok: boolean; format?: string }; - expect(snapDom.ok).toBe(true); - expect(snapDom.format).toBe("domSnapshot"); + const snapAmbiguous = await realFetch( + `${base}/snapshot?format=aria&targetId=abc`, + ); + expect(snapAmbiguous.status).toBe(409); }); }); diff --git a/src/cli/browser-cli-actions-input.ts b/src/cli/browser-cli-actions-input.ts index aa0b7d1c0..c7e92a926 100644 --- a/src/cli/browser-cli-actions-input.ts +++ b/src/cli/browser-cli-actions-input.ts @@ -1,19 +1,10 @@ import type { Command } from "commander"; import { resolveBrowserControlUrl } from "../browser/client.js"; import { - browserClick, - browserDrag, - browserEvaluate, - browserFillForm, - browserHandleDialog, - browserHover, + browserAct, + browserArmDialog, + browserArmFileChooser, browserNavigate, - browserPressKey, - browserResize, - browserSelectOption, - browserType, - browserUpload, - browserWaitFor, } from "../browser/client-actions.js"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; @@ -80,7 +71,8 @@ export function registerBrowserActionInputCommands( return; } try { - const result = await browserResize(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "resize", width, height, targetId: opts.targetId?.trim() || undefined, @@ -114,7 +106,8 @@ export function registerBrowserActionInputCommands( .filter(Boolean) : undefined; try { - const result = await browserClick(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "click", ref, targetId: opts.targetId?.trim() || undefined, doubleClick: Boolean(opts.double), @@ -145,7 +138,8 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserType(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "type", ref, text, submit: Boolean(opts.submit), @@ -172,7 +166,8 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserPressKey(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "press", key, targetId: opts.targetId?.trim() || undefined, }); @@ -196,7 +191,8 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserHover(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "hover", ref, targetId: opts.targetId?.trim() || undefined, }); @@ -221,7 +217,8 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserDrag(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "drag", startRef, endRef, targetId: opts.targetId?.trim() || undefined, @@ -247,7 +244,8 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserSelectOption(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "select", ref, values, targetId: opts.targetId?.trim() || undefined, @@ -268,13 +266,21 @@ export function registerBrowserActionInputCommands( .description("Arm file upload for the next file chooser") .argument("", "File paths to upload") .option("--target-id ", "CDP target id (or unique prefix)") + .option( + "--timeout-ms ", + "How long to wait for the next file chooser (default: 10000)", + (v: string) => Number(v), + ) .action(async (paths: string[], opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserUpload(baseUrl, { + const result = await browserArmFileChooser(baseUrl, { paths, targetId: opts.targetId?.trim() || undefined, + timeoutMs: Number.isFinite(opts.timeoutMs) + ? opts.timeoutMs + : undefined, }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); @@ -301,7 +307,8 @@ export function registerBrowserActionInputCommands( fields: opts.fields, fieldsFile: opts.fieldsFile, }); - const result = await browserFillForm(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "fill", fields, targetId: opts.targetId?.trim() || undefined, }); @@ -323,6 +330,11 @@ export function registerBrowserActionInputCommands( .option("--dismiss", "Dismiss the dialog", false) .option("--prompt ", "Prompt response text") .option("--target-id ", "CDP target id (or unique prefix)") + .option( + "--timeout-ms ", + "How long to wait for the next dialog (default: 10000)", + (v: string) => Number(v), + ) .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); @@ -333,10 +345,13 @@ export function registerBrowserActionInputCommands( return; } try { - const result = await browserHandleDialog(baseUrl, { + const result = await browserArmDialog(baseUrl, { accept, promptText: opts.prompt?.trim() || undefined, targetId: opts.targetId?.trim() || undefined, + timeoutMs: Number.isFinite(opts.timeoutMs) + ? opts.timeoutMs + : undefined, }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); @@ -360,8 +375,9 @@ export function registerBrowserActionInputCommands( const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const result = await browserWaitFor(baseUrl, { - time: Number.isFinite(opts.time) ? opts.time : undefined, + const result = await browserAct(baseUrl, { + kind: "wait", + timeMs: Number.isFinite(opts.time) ? opts.time : undefined, text: opts.text?.trim() || undefined, textGone: opts.textGone?.trim() || undefined, targetId: opts.targetId?.trim() || undefined, @@ -392,7 +408,8 @@ export function registerBrowserActionInputCommands( return; } try { - const result = await browserEvaluate(baseUrl, { + const result = await browserAct(baseUrl, { + kind: "evaluate", fn: opts.fn, ref: opts.ref?.trim() || undefined, targetId: opts.targetId?.trim() || undefined, @@ -401,7 +418,7 @@ export function registerBrowserActionInputCommands( defaultRuntime.log(JSON.stringify(result, null, 2)); return; } - defaultRuntime.log(JSON.stringify(result.result, null, 2)); + defaultRuntime.log(JSON.stringify(result.result ?? null, null, 2)); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts index f08e8af03..7ca1693ef 100644 --- a/src/cli/browser-cli-actions-observe.ts +++ b/src/cli/browser-cli-actions-observe.ts @@ -3,10 +3,6 @@ import { resolveBrowserControlUrl } from "../browser/client.js"; import { browserConsoleMessages, browserPdfSave, - browserVerifyElementVisible, - browserVerifyListVisible, - browserVerifyTextVisible, - browserVerifyValue, } from "../browser/client-actions.js"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; @@ -61,118 +57,4 @@ export function registerBrowserActionObserveCommands( defaultRuntime.exit(1); } }); - - browser - .command("verify-element") - .description("Verify element visible by role + name") - .option("--role ", "ARIA role") - .option("--name ", "Accessible name") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - if (!opts.role || !opts.name) { - defaultRuntime.error(danger("--role and --name are required")); - defaultRuntime.exit(1); - return; - } - try { - const result = await browserVerifyElementVisible(baseUrl, { - role: opts.role, - accessibleName: opts.name, - targetId: opts.targetId?.trim() || undefined, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log("element visible"); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("verify-text") - .description("Verify text is visible") - .argument("", "Text to find") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (text: string, opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - try { - const result = await browserVerifyTextVisible(baseUrl, { - text, - targetId: opts.targetId?.trim() || undefined, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log("text visible"); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("verify-list") - .description("Verify list items under a ref") - .argument("", "Ref id from ai snapshot") - .argument("", "List items to verify") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (ref: string, items: string[], opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - try { - const result = await browserVerifyListVisible(baseUrl, { - ref, - items, - targetId: opts.targetId?.trim() || undefined, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log("list visible"); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("verify-value") - .description("Verify a form control value") - .option("--ref ", "Ref id from ai snapshot") - .option("--type ", "Input type (textbox, checkbox, slider, etc)") - .option("--value ", "Expected value") - .option("--target-id ", "CDP target id (or unique prefix)") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - if (!opts.ref || !opts.type) { - defaultRuntime.error(danger("--ref and --type are required")); - defaultRuntime.exit(1); - return; - } - try { - const result = await browserVerifyValue(baseUrl, { - ref: opts.ref, - type: opts.type, - value: opts.value, - targetId: opts.targetId?.trim() || undefined, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log("value verified"); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); } diff --git a/src/cli/browser-cli-examples.ts b/src/cli/browser-cli-examples.ts index e5f2fb3d5..e86d480b9 100644 --- a/src/cli/browser-cli-examples.ts +++ b/src/cli/browser-cli-examples.ts @@ -9,8 +9,6 @@ export const browserCoreExamples = [ "clawdis browser screenshot", "clawdis browser screenshot --full-page", "clawdis browser screenshot --ref 12", - 'clawdis browser query "a" --limit 5', - "clawdis browser dom --format text --max-chars 5000", "clawdis browser snapshot --format aria --limit 200", "clawdis browser snapshot --format ai", ]; @@ -31,8 +29,4 @@ export const browserActionExamples = [ "clawdis browser evaluate --fn '(el) => el.textContent' --ref 7", "clawdis browser console --level error", "clawdis browser pdf", - 'clawdis browser verify-element --role button --name "Submit"', - 'clawdis browser verify-text "Welcome"', - "clawdis browser verify-list 3 ItemA ItemB", - "clawdis browser verify-value --ref 4 --type textbox --value hello", ]; diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts index f2141ed0b..09aeabf96 100644 --- a/src/cli/browser-cli-inspect.ts +++ b/src/cli/browser-cli-inspect.ts @@ -1,9 +1,6 @@ import type { Command } from "commander"; import { - browserDom, - browserQuery, - browserScreenshot, browserSnapshot, resolveBrowserControlUrl, } from "../browser/client.js"; @@ -24,25 +21,17 @@ export function registerBrowserInspectCommands( .option("--ref ", "ARIA ref from ai snapshot") .option("--element ", "CSS selector for element screenshot") .option("--type ", "Output type (default: png)", "png") - .option("--filename ", "Preferred output filename") .action(async (targetId: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); try { - const advanced = Boolean(opts.ref || opts.element || opts.filename); - const result = advanced - ? await browserScreenshotAction(baseUrl, { - targetId: targetId?.trim() || undefined, - fullPage: Boolean(opts.fullPage), - ref: opts.ref?.trim() || undefined, - element: opts.element?.trim() || undefined, - filename: opts.filename?.trim() || undefined, - type: opts.type === "jpeg" ? "jpeg" : "png", - }) - : await browserScreenshot(baseUrl, { - targetId: targetId?.trim() || undefined, - fullPage: Boolean(opts.fullPage), - }); + const result = await browserScreenshotAction(baseUrl, { + targetId: targetId?.trim() || undefined, + fullPage: Boolean(opts.fullPage), + ref: opts.ref?.trim() || undefined, + element: opts.element?.trim() || undefined, + type: opts.type === "jpeg" ? "jpeg" : "png", + }); if (parent?.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; @@ -54,88 +43,10 @@ export function registerBrowserInspectCommands( } }); - browser - .command("query") - .description("Query selector matches") - .argument("", "CSS selector") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--limit ", "Max matches (default: 20)", (v: string) => - Number(v), - ) - .action(async (selector: string, opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - try { - const result = await browserQuery(baseUrl, { - selector, - targetId: opts.targetId?.trim() || undefined, - limit: Number.isFinite(opts.limit) ? opts.limit : undefined, - }); - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log(JSON.stringify(result.matches, null, 2)); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - - browser - .command("dom") - .description("Dump DOM (html or text) with truncation") - .option("--format ", "Output format (default: html)", "html") - .option("--target-id ", "CDP target id (or unique prefix)") - .option("--selector ", "Optional CSS selector to scope the dump") - .option( - "--max-chars ", - "Max characters (default: 200000)", - (v: string) => Number(v), - ) - .option("--out ", "Write output to a file") - .action(async (opts, cmd) => { - const parent = parentOpts(cmd); - const baseUrl = resolveBrowserControlUrl(parent?.url); - const format = opts.format === "text" ? "text" : "html"; - try { - const result = await browserDom(baseUrl, { - format, - targetId: opts.targetId?.trim() || undefined, - maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined, - selector: opts.selector?.trim() || undefined, - }); - if (opts.out) { - const fs = await import("node:fs/promises"); - await fs.writeFile(opts.out, result.text, "utf8"); - if (parent?.json) { - defaultRuntime.log( - JSON.stringify({ ok: true, out: opts.out }, null, 2), - ); - } else { - defaultRuntime.log(opts.out); - } - return; - } - if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - defaultRuntime.log(result.text); - } catch (err) { - defaultRuntime.error(danger(String(err))); - defaultRuntime.exit(1); - } - }); - browser .command("snapshot") - .description("Capture an AI-friendly snapshot (aria, domSnapshot, or ai)") - .option( - "--format ", - "Snapshot format (default: aria)", - "aria", - ) + .description("Capture an AI-friendly snapshot (aria or ai)") + .option("--format ", "Snapshot format (default: aria)", "aria") .option("--target-id ", "CDP target id (or unique prefix)") .option("--limit ", "Max nodes (default: 500/800)", (v: string) => Number(v), @@ -144,12 +55,7 @@ export function registerBrowserInspectCommands( .action(async (opts, cmd) => { const parent = parentOpts(cmd); const baseUrl = resolveBrowserControlUrl(parent?.url); - const format = - opts.format === "domSnapshot" - ? "domSnapshot" - : opts.format === "ai" - ? "ai" - : "aria"; + const format = opts.format === "ai" ? "ai" : "aria"; try { const result = await browserSnapshot(baseUrl, { format, @@ -185,11 +91,6 @@ export function registerBrowserInspectCommands( return; } - if (result.format === "domSnapshot") { - defaultRuntime.log(JSON.stringify(result, null, 2)); - return; - } - const nodes = "nodes" in result ? result.nodes : []; defaultRuntime.log( nodes diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 90950bb38..b7dbaee03 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -10,7 +10,7 @@ import { browserTabs, resolveBrowserControlUrl, } from "../browser/client.js"; -import { browserClosePage } from "../browser/client-actions.js"; +import { browserAct } from "../browser/client-actions.js"; import { danger, info } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js"; @@ -168,7 +168,7 @@ export function registerBrowserManageCommands( if (targetId?.trim()) { await browserCloseTab(baseUrl, targetId.trim()); } else { - await browserClosePage(baseUrl); + await browserAct(baseUrl, { kind: "close" }); } if (parent?.json) { defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));