/** * Tests for the `browser_execute` route. * * Mocks executeBrowserOperation and findConversation at the module boundary * so the route handler can be exercised without spinning up real browser * state or the daemon conversation store. */ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { ToolExecutionResult } from "../../tools/types.js"; // --------------------------------------------------------------------------- // Mock state // --------------------------------------------------------------------------- let mockOperationResult: ToolExecutionResult = { content: "ok", isError: false, }; let mockOperationCalls: Array<{ operation: string; input: Record; conversationId: string; trustClass?: string; transportInterface?: string; sourceActorPrincipalId?: string; }> = []; /** When set, findConversation returns a fake conversation object. */ let mockConversation: { trustContext?: { trustClass: string }; transportInterface?: string; getTurnActorPrincipalId?: () => string | undefined; } | null = null; let mockFindConversationCalls: string[] = []; mock.module("../../browser/operations.js", () => ({ executeBrowserOperation: async ( operation: string, input: Record, context: { conversationId: string; trustClass?: string; transportInterface?: string; sourceActorPrincipalId?: string; }, ) => { mockOperationCalls.push({ operation, input, conversationId: context.conversationId, trustClass: context.trustClass, transportInterface: context.transportInterface, sourceActorPrincipalId: context.sourceActorPrincipalId, }); return mockOperationResult; }, })); mock.module("../../daemon/conversation-registry.js", () => ({ findConversation: (conversationId: string) => { mockFindConversationCalls.push(conversationId); return mockConversation ?? undefined; }, })); // Import after mocking — now from the shared routes location const { ROUTES, browserCliConversationKey } = await import("../../runtime/routes/browser-routes.js"); const browserExecuteHandler = ROUTES.find( (r) => r.operationId === "browser_execute", )!.handler; /** Call the handler with the RouteHandlerArgs shape. */ function callHandler( body: Record, headers?: Record, ) { return browserExecuteHandler({ body, headers, pathParams: {}, queryParams: {}, }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- afterEach(() => { mockOperationResult = { content: "ok", isError: false }; mockOperationCalls = []; mockConversation = null; mockFindConversationCalls = []; }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("browser_execute route", () => { test("operationId is browser_execute", () => { const route = ROUTES.find((r) => r.operationId === "browser_execute"); expect(route).toBeDefined(); }); // ── Successful dispatch ──────────────────────────────────────────── test("dispatches a valid operation and returns structured result", async () => { mockOperationResult = { content: "Navigated to https://example.com", isError: false, }; const result = await callHandler({ operation: "navigate", input: { url: "https://example.com" }, sessionId: "test-session", }); expect(result).toEqual({ content: "Navigated to https://example.com", isError: false, }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].operation).toBe("navigate"); expect(mockOperationCalls[0].input).toEqual({ url: "https://example.com", }); }); // ── Unknown operation rejection ──────────────────────────────────── test("rejects unknown operation with a validation error", async () => { await expect( callHandler({ operation: "nonexistent_operation", input: {}, }), ).rejects.toThrow(); expect(mockOperationCalls).toHaveLength(0); }); // ── Session ID mapping ───────────────────────────────────────────── test("maps sessionId to deterministic conversation key", async () => { await callHandler({ operation: "snapshot", input: {}, sessionId: "my-session", }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].conversationId).toBe("browser-cli:my-session"); }); test("defaults sessionId to 'default' when omitted", async () => { await callHandler({ operation: "snapshot", input: {}, }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].conversationId).toBe("browser-cli:default"); }); test("looks up conversation when conversationId is provided", async () => { await callHandler({ operation: "status", input: {}, sessionId: "s1", conversationId: "conv-live-123", }); expect(mockFindConversationCalls).toEqual(["conv-live-123"]); }); test("falls back to session key when conversation not found", async () => { mockConversation = null; await callHandler({ operation: "status", input: {}, sessionId: "s1", conversationId: "conv-missing", }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].conversationId).toBe("browser-cli:s1"); expect(mockOperationCalls[0].trustClass).toBe("unknown"); }); test("uses live conversation context fields when found", async () => { mockConversation = { trustContext: { trustClass: "trusted" }, transportInterface: "chrome-extension", getTurnActorPrincipalId: () => undefined, }; await callHandler({ operation: "status", input: {}, sessionId: "unused", conversationId: "conv-live-456", }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0]).toMatchObject({ conversationId: "conv-live-456", trustClass: "trusted", transportInterface: "chrome-extension", }); }); test("threads actor principal header into browser tool context", async () => { await callHandler( { operation: "status", input: {}, sessionId: "actor-session", }, { "x-vellum-actor-principal-id": "actor-123" }, ); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].sourceActorPrincipalId).toBe("actor-123"); }); test("falls back to live conversation actor when no actor header exists", async () => { mockConversation = { trustContext: { trustClass: "trusted" }, transportInterface: "macos", getTurnActorPrincipalId: () => "conversation-actor", }; await callHandler({ operation: "status", input: {}, sessionId: "unused", conversationId: "conv-live-actor", }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0]).toMatchObject({ conversationId: "conv-live-actor", sourceActorPrincipalId: "conversation-actor", }); }); test("prefers live conversation actor over the request header", async () => { mockConversation = { trustContext: { trustClass: "trusted" }, transportInterface: "macos", getTurnActorPrincipalId: () => "conversation-actor", }; await callHandler( { operation: "status", input: {}, sessionId: "unused", conversationId: "conv-live-actor", }, { "x-vellum-actor-principal-id": "header-actor" }, ); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].sourceActorPrincipalId).toBe( "conversation-actor", ); }); test("does not call findConversation when no conversationId provided", async () => { await callHandler({ operation: "snapshot", input: {}, sessionId: "s1", }); expect(mockFindConversationCalls).toHaveLength(0); }); test("same sessionId produces same conversation key", () => { const key1 = browserCliConversationKey("alpha"); const key2 = browserCliConversationKey("alpha"); expect(key1).toBe(key2); expect(key1).toBe("browser-cli:alpha"); }); test("different sessionIds produce different conversation keys", () => { const key1 = browserCliConversationKey("alpha"); const key2 = browserCliConversationKey("beta"); expect(key1).not.toBe(key2); }); // ── Screenshot payload transport ─────────────────────────────────── test("extracts screenshot base64 payloads from content blocks", async () => { mockOperationResult = { content: "Screenshot taken", isError: false, contentBlocks: [ { type: "image", source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgoAAAANS...", }, }, ], }; const result = (await callHandler({ operation: "screenshot", input: {}, sessionId: "screenshot-test", })) as { content: string; isError: boolean; screenshots: Array<{ mediaType: string; data: string }>; }; expect(result.content).toBe("Screenshot taken"); expect(result.isError).toBe(false); expect(result.screenshots).toHaveLength(1); expect(result.screenshots[0].mediaType).toBe("image/png"); expect(result.screenshots[0].data).toBe("iVBORw0KGgoAAAANS..."); }); test("omits screenshots field when no image blocks present", async () => { mockOperationResult = { content: "Snapshot taken", isError: false, }; const result = (await callHandler({ operation: "snapshot", input: {}, })) as Record; expect(result.content).toBe("Snapshot taken"); expect(result.isError).toBe(false); expect(result).not.toHaveProperty("screenshots"); }); test("handles multiple screenshot content blocks", async () => { mockOperationResult = { content: "Multiple screenshots", isError: false, contentBlocks: [ { type: "image", source: { type: "base64", media_type: "image/png", data: "first-screenshot-data", }, }, { type: "text", text: "some text block", }, { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "second-screenshot-data", }, }, ], }; const result = (await callHandler({ operation: "screenshot", input: { full_page: true }, })) as { content: string; isError: boolean; screenshots: Array<{ mediaType: string; data: string }>; }; expect(result.screenshots).toHaveLength(2); expect(result.screenshots[0].data).toBe("first-screenshot-data"); expect(result.screenshots[1].data).toBe("second-screenshot-data"); expect(result.screenshots[1].mediaType).toBe("image/jpeg"); }); // ── Error propagation ────────────────────────────────────────────── test("propagates isError from operation result", async () => { mockOperationResult = { content: "Error: page not found", isError: true, }; const result = await callHandler({ operation: "navigate", input: { url: "https://404.example.com" }, }); expect(result).toEqual({ content: "Error: page not found", isError: true, }); }); // ── Input defaults ───────────────────────────────────────────────── test("defaults input to empty object when omitted", async () => { await callHandler({ operation: "snapshot", }); expect(mockOperationCalls).toHaveLength(1); expect(mockOperationCalls[0].input).toEqual({}); }); });