From 429972b5c5f4725ab52ee4215adcf7a1f0078747 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 09:34:22 +0000 Subject: [PATCH] test(browser): cover agent contract --- src/browser/pw-tools-core.test.ts | 110 ++++++++++ src/browser/routes/agent.ts | 8 + src/browser/server.test.ts | 339 ++++++++++++++++++++++++++---- 3 files changed, 421 insertions(+), 36 deletions(-) create mode 100644 src/browser/pw-tools-core.test.ts diff --git a/src/browser/pw-tools-core.test.ts b/src/browser/pw-tools-core.test.ts new file mode 100644 index 000000000..4c2cfbd65 --- /dev/null +++ b/src/browser/pw-tools-core.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let currentPage: Record | null = null; +let currentRefLocator: Record | null = null; + +const sessionMocks = vi.hoisted(() => ({ + getPageForTargetId: vi.fn(async () => { + if (!currentPage) throw new Error("missing page"); + return currentPage; + }), + ensurePageState: vi.fn(() => ({ + console: [], + armIdUpload: 0, + armIdDialog: 0, + })), + refLocator: vi.fn(() => { + if (!currentRefLocator) throw new Error("missing locator"); + return currentRefLocator; + }), +})); + +vi.mock("./pw-session.js", () => sessionMocks); + +async function importModule() { + return await import("./pw-tools-core.js"); +} + +describe("pw-tools-core", () => { + beforeEach(() => { + currentPage = null; + currentRefLocator = null; + for (const fn of Object.values(sessionMocks)) fn.mockClear(); + }); + + it("screenshots an element selector", async () => { + const elementScreenshot = vi.fn(async () => Buffer.from("E")); + currentPage = { + locator: vi.fn(() => ({ + first: () => ({ screenshot: elementScreenshot }), + })), + screenshot: vi.fn(async () => Buffer.from("P")), + }; + + const mod = await importModule(); + const res = await mod.takeScreenshotViaPlaywright({ + cdpPort: 18792, + targetId: "T1", + element: "#main", + type: "png", + }); + + expect(res.buffer.toString()).toBe("E"); + expect(sessionMocks.getPageForTargetId).toHaveBeenCalled(); + expect( + currentPage.locator as ReturnType, + ).toHaveBeenCalledWith("#main"); + expect(elementScreenshot).toHaveBeenCalledWith({ type: "png" }); + }); + + it("screenshots a ref locator", async () => { + const refScreenshot = vi.fn(async () => Buffer.from("R")); + currentRefLocator = { screenshot: refScreenshot }; + currentPage = { + locator: vi.fn(), + screenshot: vi.fn(async () => Buffer.from("P")), + }; + + const mod = await importModule(); + const res = await mod.takeScreenshotViaPlaywright({ + cdpPort: 18792, + targetId: "T1", + ref: "76", + type: "jpeg", + }); + + expect(res.buffer.toString()).toBe("R"); + expect(sessionMocks.refLocator).toHaveBeenCalledWith(currentPage, "76"); + expect(refScreenshot).toHaveBeenCalledWith({ type: "jpeg" }); + }); + + it("rejects fullPage for element or ref screenshots", async () => { + currentRefLocator = { screenshot: vi.fn(async () => Buffer.from("R")) }; + currentPage = { + locator: vi.fn(() => ({ + first: () => ({ screenshot: vi.fn(async () => Buffer.from("E")) }), + })), + screenshot: vi.fn(async () => Buffer.from("P")), + }; + + const mod = await importModule(); + + await expect( + mod.takeScreenshotViaPlaywright({ + cdpPort: 18792, + targetId: "T1", + element: "#x", + fullPage: true, + }), + ).rejects.toThrow(/fullPage is not supported/i); + + await expect( + mod.takeScreenshotViaPlaywright({ + cdpPort: 18792, + targetId: "T1", + ref: "1", + fullPage: true, + }), + ).rejects.toThrow(/fullPage is not supported/i); + }); +}); diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index 2ce85e94f..95ac809a8 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -382,6 +382,14 @@ export function registerBrowserAgentRoutes( const element = toStringOrEmpty(body.element) || undefined; const type = body.type === "jpeg" ? "jpeg" : "png"; + if (fullPage && (ref || element)) { + return jsonError( + res, + 400, + "fullPage is not supported for element screenshots", + ); + } + try { const tab = await ctx.ensureTabAvailable(targetId); const snap = await takeScreenshotViaPlaywright({ diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index a027bd52f..f1a953abd 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -8,6 +8,39 @@ let reachable = false; let cfgAttachOnly = false; let createTargetId: string | null = null; +const cdpMocks = vi.hoisted(() => ({ + createTargetViaCdp: vi.fn(async () => { + throw new Error("cdp disabled"); + }), + snapshotAria: vi.fn(async () => ({ + nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], + })), +})); + +const pwMocks = vi.hoisted(() => ({ + armDialogViaPlaywright: vi.fn(async () => {}), + armFileUploadViaPlaywright: vi.fn(async () => {}), + clickViaPlaywright: vi.fn(async () => {}), + closePageViaPlaywright: vi.fn(async () => {}), + closePlaywrightBrowserConnection: vi.fn(async () => {}), + dragViaPlaywright: vi.fn(async () => {}), + evaluateViaPlaywright: vi.fn(async () => "ok"), + fillFormViaPlaywright: vi.fn(async () => {}), + getConsoleMessagesViaPlaywright: vi.fn(async () => []), + hoverViaPlaywright: vi.fn(async () => {}), + navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), + pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })), + pressKeyViaPlaywright: vi.fn(async () => {}), + resizeViewportViaPlaywright: vi.fn(async () => {}), + selectOptionViaPlaywright: vi.fn(async () => {}), + snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })), + takeScreenshotViaPlaywright: vi.fn(async () => ({ + buffer: Buffer.from("png"), + })), + typeViaPlaywright: vi.fn(async () => {}), + waitForViaPlaywright: vi.fn(async () => {}), +})); + function makeProc(pid = 123) { const handlers = new Map void>>(); return { @@ -62,38 +95,11 @@ vi.mock("./chrome.js", () => ({ })); vi.mock("./cdp.js", () => ({ - createTargetViaCdp: vi.fn(async () => { - if (createTargetId) return { targetId: createTargetId }; - throw new Error("cdp disabled"); - }), - snapshotAria: vi.fn(async () => ({ - nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], - })), + createTargetViaCdp: cdpMocks.createTargetViaCdp, + snapshotAria: cdpMocks.snapshotAria, })); -vi.mock("./pw-ai.js", () => ({ - armDialogViaPlaywright: vi.fn(async () => {}), - armFileUploadViaPlaywright: vi.fn(async () => {}), - clickViaPlaywright: vi.fn(async () => {}), - closePageViaPlaywright: vi.fn(async () => {}), - closePlaywrightBrowserConnection: vi.fn(async () => {}), - evaluateViaPlaywright: vi.fn(async () => "ok"), - fillFormViaPlaywright: vi.fn(async () => {}), - getConsoleMessagesViaPlaywright: vi.fn(async () => []), - hoverViaPlaywright: vi.fn(async () => {}), - navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })), - pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })), - pressKeyViaPlaywright: vi.fn(async () => {}), - resizeViewportViaPlaywright: vi.fn(async () => {}), - selectOptionViaPlaywright: vi.fn(async () => {}), - snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })), - takeScreenshotViaPlaywright: vi.fn(async () => ({ - buffer: Buffer.from("png"), - })), - typeViaPlaywright: vi.fn(async () => {}), - waitForViaPlaywright: vi.fn(async () => {}), - dragViaPlaywright: vi.fn(async () => {}), -})); +vi.mock("./pw-ai.js", () => pwMocks); vi.mock("../media/store.js", () => ({ ensureMediaDir: vi.fn(async () => {}), @@ -141,6 +147,14 @@ describe("browser control server", () => { cfgAttachOnly = false; createTargetId = null; + cdpMocks.createTargetViaCdp.mockImplementation(async () => { + if (createTargetId) return { targetId: createTargetId }; + throw new Error("cdp disabled"); + }); + + for (const fn of Object.values(pwMocks)) fn.mockClear(); + for (const fn of Object.values(cdpMocks)) fn.mockClear(); + testPort = await getFreePort(); // Minimal CDP JSON endpoints used by the server. @@ -265,12 +279,20 @@ describe("browser control server", () => { }; expect(snapAria.ok).toBe(true); expect(snapAria.format).toBe("aria"); + expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({ + wsUrl: "ws://127.0.0.1/devtools/page/abcd1234", + limit: 1, + }); const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json(), )) as { ok: boolean; format?: string; snapshot?: string }; expect(snapAi.ok).toBe(true); expect(snapAi.format).toBe("ai"); + expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + }); const nav = (await realFetch(`${base}/navigate`, { method: "POST", @@ -279,13 +301,138 @@ describe("browser control server", () => { }).then((r) => r.json())) as { ok: boolean; targetId?: string }; expect(nav.ok).toBe(true); expect(typeof nav.targetId).toBe("string"); + expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + url: "https://example.com", + }); const click = (await realFetch(`${base}/act`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ kind: "click", ref: "1" }), + body: JSON.stringify({ + kind: "click", + ref: "1", + button: "left", + modifiers: ["Shift"], + }), }).then((r) => r.json())) as { ok: boolean }; expect(click.ok).toBe(true); + expect(pwMocks.clickViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + ref: "1", + doubleClick: false, + button: "left", + modifiers: ["Shift"], + }); + + const type = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "type", ref: "1", text: "" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(type.ok).toBe(true); + expect(pwMocks.typeViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + ref: "1", + text: "", + submit: false, + slowly: false, + }); + + const press = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "press", key: "Enter" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(press.ok).toBe(true); + expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + key: "Enter", + }); + + const hover = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "hover", ref: "2" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(hover.ok).toBe(true); + expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + ref: "2", + }); + + const drag = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "drag", startRef: "3", endRef: "4" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(drag.ok).toBe(true); + expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + startRef: "3", + endRef: "4", + }); + + const select = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "select", ref: "5", values: ["a", "b"] }), + }).then((r) => r.json())) as { ok: boolean }; + expect(select.ok).toBe(true); + expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + ref: "5", + values: ["a", "b"], + }); + + const fill = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + kind: "fill", + fields: [{ ref: "6", type: "textbox", value: "hello" }], + }), + }).then((r) => r.json())) as { ok: boolean }; + expect(fill.ok).toBe(true); + expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + fields: [{ ref: "6", type: "textbox", value: "hello" }], + }); + + const resize = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "resize", width: 800, height: 600 }), + }).then((r) => r.json())) as { ok: boolean }; + expect(resize.ok).toBe(true); + expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + width: 800, + height: 600, + }); + + const wait = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "wait", timeMs: 5 }), + }).then((r) => r.json())) as { ok: boolean }; + expect(wait.ok).toBe(true); + expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + timeMs: 5, + text: undefined, + textGone: undefined, + }); const evalRes = (await realFetch(`${base}/act`, { method: "POST", @@ -293,26 +440,51 @@ describe("browser control server", () => { body: JSON.stringify({ kind: "evaluate", fn: "() => 1" }), }).then((r) => r.json())) as { ok: boolean; result?: unknown }; expect(evalRes.ok).toBe(true); + expect(evalRes.result).toBe("ok"); + expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + fn: "() => 1", + ref: undefined, + }); const upload = await realFetch(`${base}/hooks/file-chooser`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ paths: ["/tmp/a.txt"] }), + body: JSON.stringify({ paths: ["/tmp/a.txt"], timeoutMs: 1234 }), }).then((r) => r.json()); expect(upload).toMatchObject({ ok: true }); + expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + paths: ["/tmp/a.txt"], + timeoutMs: 1234, + }); const dialog = await realFetch(`${base}/hooks/dialog`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accept: true }), + body: JSON.stringify({ accept: true, timeoutMs: 5678 }), }).then((r) => r.json()); expect(dialog).toMatchObject({ ok: true }); + expect(pwMocks.armDialogViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + accept: true, + promptText: undefined, + timeoutMs: 5678, + }); - const consoleRes = (await realFetch(`${base}/console`).then((r) => - r.json(), + const consoleRes = (await realFetch(`${base}/console?level=error`).then( + (r) => r.json(), )) as { ok: boolean; messages?: unknown[] }; expect(consoleRes.ok).toBe(true); expect(Array.isArray(consoleRes.messages)).toBe(true); + expect(pwMocks.getConsoleMessagesViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + level: "error", + }); const pdf = (await realFetch(`${base}/pdf`, { method: "POST", @@ -325,10 +497,29 @@ describe("browser control server", () => { const shot = (await realFetch(`${base}/screenshot`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullPage: true }), + body: JSON.stringify({ element: "body", type: "jpeg" }), }).then((r) => r.json())) as { ok: boolean; path?: string }; expect(shot.ok).toBe(true); expect(typeof shot.path).toBe("string"); + expect(pwMocks.takeScreenshotViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + ref: undefined, + element: "body", + fullPage: false, + type: "jpeg", + }); + + const close = (await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "close" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(close.ok).toBe(true); + expect(pwMocks.closePageViaPlaywright).toHaveBeenCalledWith({ + cdpPort: testPort + 1, + targetId: "abcd1234", + }); const stopped = (await realFetch(`${base}/stop`, { method: "POST", @@ -337,6 +528,82 @@ describe("browser control server", () => { expect(stopped.stopped).toBe(true); }); + it("validates agent inputs (agent routes)", 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 navMissing = await realFetch(`${base}/navigate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(navMissing.status).toBe(400); + + const actMissing = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(actMissing.status).toBe(400); + + const clickMissingRef = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "click" }), + }); + expect(clickMissingRef.status).toBe(400); + + const clickBadButton = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "click", ref: "1", button: "nope" }), + }); + expect(clickBadButton.status).toBe(400); + + const clickBadModifiers = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "click", ref: "1", modifiers: ["Nope"] }), + }); + expect(clickBadModifiers.status).toBe(400); + + const typeBadText = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "type", ref: "1", text: 123 }), + }); + expect(typeBadText.status).toBe(400); + + const uploadMissingPaths = await realFetch(`${base}/hooks/file-chooser`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(uploadMissingPaths.status).toBe(400); + + const dialogMissingAccept = await realFetch(`${base}/hooks/dialog`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(dialogMissingAccept.status).toBe(400); + + const snapDefault = (await realFetch(`${base}/snapshot?format=wat`).then( + (r) => r.json(), + )) as { ok: boolean; format?: string }; + expect(snapDefault.ok).toBe(true); + expect(snapDefault.format).toBe("ai"); + + const screenshotBadCombo = await realFetch(`${base}/screenshot`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullPage: true, element: "body" }), + }); + expect(screenshotBadCombo.status).toBe(400); + }); + it("covers common error branches", async () => { cfgAttachOnly = true; const { startBrowserControlServerFromConfig } = await import("./server.js");