From d54ecc39619ce62da4937e81e4247ac6dcff6450 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 19 Dec 2025 23:57:32 +0000 Subject: [PATCH] test(browser): cover MCP tool routes --- src/browser/routes/tool-core.test.ts | 418 ++++++++++++++++++++++++++ src/browser/routes/tool-extra.test.ts | 254 ++++++++++++++++ 2 files changed, 672 insertions(+) create mode 100644 src/browser/routes/tool-core.test.ts create mode 100644 src/browser/routes/tool-extra.test.ts diff --git a/src/browser/routes/tool-core.test.ts b/src/browser/routes/tool-core.test.ts new file mode 100644 index 000000000..3c676bacf --- /dev/null +++ b/src/browser/routes/tool-core.test.ts @@ -0,0 +1,418 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { BrowserRouteContext } from "../server-context.js"; + +const pw = vi.hoisted(() => ({ + clickViaPlaywright: vi.fn().mockResolvedValue(undefined), + closePageViaPlaywright: vi.fn().mockResolvedValue(undefined), + dragViaPlaywright: vi.fn().mockResolvedValue(undefined), + evaluateViaPlaywright: vi.fn().mockResolvedValue("result"), + fileUploadViaPlaywright: vi.fn().mockResolvedValue(undefined), + fillFormViaPlaywright: vi.fn().mockResolvedValue(undefined), + handleDialogViaPlaywright: vi + .fn() + .mockResolvedValue({ message: "ok", type: "alert" }), + hoverViaPlaywright: vi.fn().mockResolvedValue(undefined), + navigateBackViaPlaywright: vi.fn().mockResolvedValue({ url: "about:blank" }), + navigateViaPlaywright: vi + .fn() + .mockResolvedValue({ url: "https://example.com" }), + pressKeyViaPlaywright: vi.fn().mockResolvedValue(undefined), + resizeViewportViaPlaywright: vi.fn().mockResolvedValue(undefined), + runCodeViaPlaywright: vi.fn().mockResolvedValue("ok"), + selectOptionViaPlaywright: vi.fn().mockResolvedValue(undefined), + snapshotAiViaPlaywright: vi + .fn() + .mockResolvedValue({ snapshot: "SNAP" }), + takeScreenshotViaPlaywright: vi + .fn() + .mockResolvedValue({ buffer: Buffer.from("png") }), + typeViaPlaywright: vi.fn().mockResolvedValue(undefined), + waitForViaPlaywright: vi.fn().mockResolvedValue(undefined), +})); + +const screenshot = vi.hoisted(() => ({ + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64, + normalizeBrowserScreenshot: vi + .fn() + .mockImplementation(async (buf: Buffer) => ({ + buffer: buf, + contentType: "image/png", + })), +})); + +const media = vi.hoisted(() => ({ + ensureMediaDir: vi.fn().mockResolvedValue(undefined), + saveMediaBuffer: vi.fn().mockResolvedValue({ path: "/tmp/fake.png" }), +})); + +vi.mock("../pw-ai.js", () => pw); +vi.mock("../screenshot.js", () => screenshot); +vi.mock("../../media/store.js", () => media); + +import { handleBrowserToolCore } from "./tool-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 callTool( + name: string, + args: Record = {}, + ctxOverride?: Partial, +) { + const res = createRes(); + const ctx = createCtx(ctxOverride); + const handled = await handleBrowserToolCore({ + name, + args, + targetId: "", + cdpPort: 18792, + ctx, + res, + }); + return { res, ctx, handled }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleBrowserToolCore", () => { + it("dispatches core Playwright tools", async () => { + const cases = [ + { + name: "browser_close", + args: {}, + fn: pw.closePageViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1" }, + expectBody: { ok: true, targetId: "tab1", url: baseTab.url }, + }, + { + name: "browser_resize", + 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 }, + }, + { + name: "browser_handle_dialog", + args: { accept: true, promptText: "ok" }, + fn: pw.handleDialogViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + accept: true, + promptText: "ok", + }, + expectBody: { ok: true, message: "ok", type: "alert" }, + }, + { + name: "browser_evaluate", + args: { function: "() => 1", ref: "1" }, + fn: pw.evaluateViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + fn: "() => 1", + ref: "1", + }, + expectBody: { ok: true, result: "result" }, + }, + { + name: "browser_file_upload", + args: { paths: ["/tmp/file.txt"] }, + fn: pw.fileUploadViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + paths: ["/tmp/file.txt"], + }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_fill_form", + args: { fields: [{ ref: "1", value: "x" }] }, + fn: pw.fillFormViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + fields: [{ ref: "1", value: "x" }], + }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_press_key", + args: { key: "Enter" }, + fn: pw.pressKeyViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1", key: "Enter" }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_type", + 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" }, + }, + { + name: "browser_navigate", + 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 }, + }, + { + name: "browser_navigate_back", + args: {}, + fn: pw.navigateBackViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1" }, + expectBody: { ok: true, targetId: "tab1", url: "about:blank" }, + }, + { + name: "browser_run_code", + args: { code: "return 1" }, + fn: pw.runCodeViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1", code: "return 1" }, + expectBody: { ok: true, result: "ok" }, + }, + { + name: "browser_click", + 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 }, + }, + { + name: "browser_drag", + args: { startRef: "1", endRef: "2" }, + fn: pw.dragViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + startRef: "1", + endRef: "2", + }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_hover", + args: { ref: "3" }, + fn: pw.hoverViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1", ref: "3" }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_select_option", + args: { ref: "4", values: ["A"] }, + fn: pw.selectOptionViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + ref: "4", + values: ["A"], + }, + expectBody: { ok: true, targetId: "tab1" }, + }, + { + name: "browser_wait_for", + 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 callTool(item.name, item.args); + expect(handled).toBe(true); + expect(item.fn).toHaveBeenCalledWith(item.expectArgs); + expect(res.body).toEqual(item.expectBody); + } + }); + + it("handles screenshots via media storage", async () => { + const { res } = await callTool("browser_take_screenshot", { + type: "jpeg", + ref: "1", + fullPage: true, + element: "main", + filename: "shot.jpg", + }); + + expect(pw.takeScreenshotViaPlaywright).toHaveBeenCalledWith({ + cdpPort: 18792, + targetId: "tab1", + ref: "1", + element: "main", + fullPage: true, + type: "jpeg", + }); + expect(media.ensureMediaDir).toHaveBeenCalled(); + expect(media.saveMediaBuffer).toHaveBeenCalled(); + expect(res.body).toMatchObject({ + ok: true, + path: "/tmp/fake.png", + filename: "shot.jpg", + targetId: "tab1", + url: baseTab.url, + }); + }); + + it("handles snapshots with optional file output", async () => { + const { res } = await callTool("browser_snapshot", { + filename: "snapshot.txt", + }); + + expect(pw.snapshotAiViaPlaywright).toHaveBeenCalledWith({ + cdpPort: 18792, + targetId: "tab1", + }); + expect(media.ensureMediaDir).toHaveBeenCalled(); + expect(media.saveMediaBuffer).toHaveBeenCalledWith( + expect.any(Buffer), + "text/plain", + "browser", + ); + expect(res.body).toMatchObject({ + ok: true, + path: "/tmp/fake.png", + filename: "snapshot.txt", + targetId: "tab1", + url: baseTab.url, + }); + }); + + it("returns a message for browser_install", async () => { + const { res } = await callTool("browser_install"); + expect(res.body).toMatchObject({ ok: true }); + }); + + it("supports browser_tabs actions", async () => { + const ctx = createCtx(); + + const listRes = createRes(); + await handleBrowserToolCore({ + name: "browser_tabs", + args: { action: "list" }, + targetId: "", + cdpPort: 18792, + ctx, + res: listRes, + }); + expect(listRes.body).toMatchObject({ ok: true }); + expect(ctx.listTabs).toHaveBeenCalled(); + + const newRes = createRes(); + await handleBrowserToolCore({ + name: "browser_tabs", + args: { action: "new" }, + targetId: "", + cdpPort: 18792, + ctx, + res: newRes, + }); + expect(ctx.ensureBrowserAvailable).toHaveBeenCalled(); + expect(ctx.openTab).toHaveBeenCalled(); + expect(newRes.body).toMatchObject({ ok: true, tab: { targetId: "newtab" } }); + + const closeRes = createRes(); + await handleBrowserToolCore({ + name: "browser_tabs", + args: { action: "close", index: 1 }, + targetId: "", + cdpPort: 18792, + ctx, + res: closeRes, + }); + expect(ctx.closeTab).toHaveBeenCalledWith("tab2"); + expect(closeRes.body).toMatchObject({ ok: true, targetId: "tab2" }); + + const selectRes = createRes(); + await handleBrowserToolCore({ + name: "browser_tabs", + args: { action: "select", index: 0 }, + targetId: "", + cdpPort: 18792, + ctx, + res: selectRes, + }); + expect(ctx.focusTab).toHaveBeenCalledWith("tab1"); + expect(selectRes.body).toMatchObject({ ok: true, targetId: "tab1" }); + }); +}); diff --git a/src/browser/routes/tool-extra.test.ts b/src/browser/routes/tool-extra.test.ts new file mode 100644 index 000000000..ef1f402d9 --- /dev/null +++ b/src/browser/routes/tool-extra.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { BrowserRouteContext } from "../server-context.js"; + +const pw = vi.hoisted(() => ({ + generateLocatorForRef: vi + .fn() + .mockImplementation((ref: string) => `locator('aria-ref=${ref}')`), + getConsoleMessagesViaPlaywright: vi.fn().mockResolvedValue([]), + getNetworkRequestsViaPlaywright: vi.fn().mockResolvedValue([]), + mouseClickViaPlaywright: vi.fn().mockResolvedValue(undefined), + mouseDragViaPlaywright: vi.fn().mockResolvedValue(undefined), + mouseMoveViaPlaywright: vi.fn().mockResolvedValue(undefined), + pdfViaPlaywright: vi.fn().mockResolvedValue({ buffer: Buffer.from("pdf") }), + startTracingViaPlaywright: vi.fn().mockResolvedValue(undefined), + stopTracingViaPlaywright: vi + .fn() + .mockResolvedValue({ buffer: Buffer.from("trace") }), + 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 { handleBrowserToolExtra } from "./tool-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 callTool(name: string, args: Record = {}) { + const res = createRes(); + const ctx = createCtx(); + const handled = await handleBrowserToolExtra({ + name, + args, + targetId: "", + cdpPort: 18792, + ctx, + res, + }); + return { res, ctx, handled }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleBrowserToolExtra", () => { + it("dispatches extra Playwright tools", async () => { + const cases = [ + { + name: "browser_console_messages", + args: { level: "error" }, + fn: pw.getConsoleMessagesViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + level: "error", + }, + expectBody: { ok: true, messages: [], targetId: "tab1" }, + }, + { + name: "browser_network_requests", + args: { includeStatic: true }, + fn: pw.getNetworkRequestsViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + includeStatic: true, + }, + expectBody: { ok: true, requests: [], targetId: "tab1" }, + }, + { + name: "browser_start_tracing", + args: {}, + fn: pw.startTracingViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1" }, + expectBody: { ok: true }, + }, + { + name: "browser_verify_element_visible", + args: { role: "button", accessibleName: "Submit" }, + fn: pw.verifyElementVisibleViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + role: "button", + accessibleName: "Submit", + }, + expectBody: { ok: true }, + }, + { + name: "browser_verify_text_visible", + args: { text: "Hello" }, + fn: pw.verifyTextVisibleViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1", text: "Hello" }, + expectBody: { ok: true }, + }, + { + name: "browser_verify_list_visible", + args: { ref: "1", items: ["a", "b"] }, + fn: pw.verifyListVisibleViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + ref: "1", + items: ["a", "b"], + }, + expectBody: { ok: true }, + }, + { + name: "browser_verify_value", + args: { ref: "2", type: "textbox", value: "x" }, + fn: pw.verifyValueViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + ref: "2", + type: "textbox", + value: "x", + }, + expectBody: { ok: true }, + }, + { + name: "browser_mouse_move_xy", + args: { x: 10, y: 20 }, + fn: pw.mouseMoveViaPlaywright, + expectArgs: { cdpPort: 18792, targetId: "tab1", x: 10, y: 20 }, + expectBody: { ok: true }, + }, + { + name: "browser_mouse_click_xy", + args: { x: 1, y: 2, button: "right" }, + fn: pw.mouseClickViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + x: 1, + y: 2, + button: "right", + }, + expectBody: { ok: true }, + }, + { + name: "browser_mouse_drag_xy", + args: { startX: 1, startY: 2, endX: 3, endY: 4 }, + fn: pw.mouseDragViaPlaywright, + expectArgs: { + cdpPort: 18792, + targetId: "tab1", + startX: 1, + startY: 2, + endX: 3, + endY: 4, + }, + expectBody: { ok: true }, + }, + { + name: "browser_generate_locator", + args: { ref: "99" }, + fn: pw.generateLocatorForRef, + expectArgs: "99", + expectBody: { ok: true, locator: "locator('aria-ref=99')" }, + }, + ]; + + for (const item of cases) { + const { res, handled } = await callTool(item.name, item.args); + expect(handled).toBe(true); + expect(item.fn).toHaveBeenCalledWith(item.expectArgs); + expect(res.body).toEqual(item.expectBody); + } + }); + + it("stores PDF and trace outputs", async () => { + const { res: pdfRes } = await callTool("browser_pdf_save"); + 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, + }); + + media.saveMediaBuffer.mockResolvedValueOnce({ path: "/tmp/fake.zip" }); + const { res: traceRes } = await callTool("browser_stop_tracing"); + expect(pw.stopTracingViaPlaywright).toHaveBeenCalledWith({ + cdpPort: 18792, + targetId: "tab1", + }); + expect(traceRes.body).toMatchObject({ + ok: true, + path: "/tmp/fake.zip", + targetId: "tab1", + url: baseTab.url, + }); + }); +});