import { afterEach, describe, expect, it, vi } from "vitest"; const browserClientMocks = vi.hoisted(() => ({ browserCloseTab: vi.fn(async () => ({})), browserFocusTab: vi.fn(async () => ({})), browserOpenTab: vi.fn(async () => ({})), browserProfiles: vi.fn(async () => []), browserSnapshot: vi.fn(async () => ({ ok: true, format: "ai", targetId: "t1", url: "https://example.com", snapshot: "ok", })), browserStart: vi.fn(async () => ({})), browserStatus: vi.fn(async () => ({ ok: true, running: true, pid: 1, cdpPort: 18792, cdpUrl: "http://127.0.0.1:18792", })), browserStop: vi.fn(async () => ({})), browserTabs: vi.fn(async () => []), })); vi.mock("../../browser/client.js", () => browserClientMocks); const browserConfigMocks = vi.hoisted(() => ({ resolveBrowserConfig: vi.fn(() => ({ enabled: true, controlPort: 18791, })), })); vi.mock("../../browser/config.js", () => browserConfigMocks); const nodesUtilsMocks = vi.hoisted(() => ({ listNodes: vi.fn(async () => []), })); vi.mock("./nodes-utils.js", async () => { const actual = await vi.importActual("./nodes-utils.js"); return { ...actual, listNodes: nodesUtilsMocks.listNodes, }; }); const gatewayMocks = vi.hoisted(() => ({ callGatewayTool: vi.fn(async () => ({ ok: true, payload: { result: { ok: true, running: true } }, })), })); vi.mock("./gateway.js", () => gatewayMocks); const configMocks = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({ browser: {} })), })); vi.mock("../../config/config.js", () => configMocks); const toolCommonMocks = vi.hoisted(() => ({ imageResultFromFile: vi.fn(), })); vi.mock("./common.js", async () => { const actual = await vi.importActual("./common.js"); return { ...actual, imageResultFromFile: toolCommonMocks.imageResultFromFile, }; }); import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js"; import { createBrowserTool } from "./browser-tool.js"; describe("browser tool snapshot maxChars", () => { afterEach(() => { vi.clearAllMocks(); configMocks.loadConfig.mockReturnValue({ browser: {} }); nodesUtilsMocks.listNodes.mockResolvedValue([]); }); it("applies the default ai snapshot limit", async () => { const tool = createBrowserTool(); await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ format: "ai", maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS, }), ); }); it("respects an explicit maxChars override", async () => { const tool = createBrowserTool(); const override = 2_000; await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", maxChars: override, }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ maxChars: override, }), ); }); it("skips the default when maxChars is explicitly zero", async () => { const tool = createBrowserTool(); await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", maxChars: 0, }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalled(); const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? []; expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false); }); it("lists profiles", async () => { const tool = createBrowserTool(); await tool.execute?.(null, { action: "profiles" }); expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined); }); it("passes refs mode through to browser snapshot", async () => { const tool = createBrowserTool(); await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ format: "ai", refs: "aria", }), ); }); it("uses config snapshot defaults when mode is not provided", async () => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, }); const tool = createBrowserTool(); await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ mode: "efficient", }), ); }); it("does not apply config snapshot defaults to aria snapshots", async () => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, }); const tool = createBrowserTool(); await tool.execute?.(null, { action: "snapshot", snapshotFormat: "aria" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalled(); const [, opts] = browserClientMocks.browserSnapshot.mock.calls.at(-1) ?? []; expect(opts?.mode).toBeUndefined(); }); it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => { const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" }); expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( undefined, expect.objectContaining({ profile: "chrome", }), ); }); it("routes to node proxy when target=node", async () => { nodesUtilsMocks.listNodes.mockResolvedValue([ { nodeId: "node-1", displayName: "Browser Node", connected: true, caps: ["browser"], commands: ["browser.proxy"], }, ]); const tool = createBrowserTool(); await tool.execute?.(null, { action: "status", target: "node" }); expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", { timeoutMs: 20000 }, expect.objectContaining({ nodeId: "node-1", command: "browser.proxy", }), ); expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); }); it("keeps sandbox bridge url when node proxy is available", async () => { nodesUtilsMocks.listNodes.mockResolvedValue([ { nodeId: "node-1", displayName: "Browser Node", connected: true, caps: ["browser"], commands: ["browser.proxy"], }, ]); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); await tool.execute?.(null, { action: "status" }); expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( "http://127.0.0.1:9999", expect.objectContaining({ profile: undefined }), ); expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); }); it("keeps chrome profile on host when node proxy is available", async () => { nodesUtilsMocks.listNodes.mockResolvedValue([ { nodeId: "node-1", displayName: "Browser Node", connected: true, caps: ["browser"], commands: ["browser.proxy"], }, ]); const tool = createBrowserTool(); await tool.execute?.(null, { action: "status", profile: "chrome" }); expect(browserClientMocks.browserStatus).toHaveBeenCalledWith( undefined, expect.objectContaining({ profile: "chrome" }), ); expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled(); }); }); describe("browser tool snapshot labels", () => { afterEach(() => { vi.clearAllMocks(); configMocks.loadConfig.mockReturnValue({ browser: {} }); }); it("returns image + text when labels are requested", async () => { const tool = createBrowserTool(); const imageResult = { content: [ { type: "text", text: "label text" }, { type: "image", data: "base64", mimeType: "image/png" }, ], details: { path: "/tmp/snap.png" }, }; toolCommonMocks.imageResultFromFile.mockResolvedValueOnce(imageResult); browserClientMocks.browserSnapshot.mockResolvedValueOnce({ ok: true, format: "ai", targetId: "t1", url: "https://example.com", snapshot: "label text", imagePath: "/tmp/snap.png", }); const result = await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", labels: true, }); expect(toolCommonMocks.imageResultFromFile).toHaveBeenCalledWith( expect.objectContaining({ path: "/tmp/snap.png", extraText: "label text", }), ); expect(result).toEqual(imageResult); expect(result?.content).toHaveLength(2); expect(result?.content?.[0]).toMatchObject({ type: "text", text: "label text" }); expect(result?.content?.[1]).toMatchObject({ type: "image" }); }); });