test(browser-tools): restore automation coverage
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||||
import { mountBrowserAutomationHandler } from "./handler";
|
import { mountBrowserAutomationHandler } from "./handler";
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
import { useBrowserStore } from "@/stores/browser-store";
|
||||||
@@ -8,20 +9,6 @@ import {
|
|||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: {
|
|
||||||
navigate: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
||||||
default: {
|
|
||||||
getItem: vi.fn(async () => null),
|
|
||||||
setItem: vi.fn(async () => undefined),
|
|
||||||
removeItem: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "browser.automation.execute.request" }
|
{ type: "browser.automation.execute.request" }
|
||||||
@@ -30,9 +17,28 @@ type BrowserAutomationExecuteResponse = Extract<
|
|||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
{ type: "browser.automation.execute.response" }
|
{ type: "browser.automation.execute.response" }
|
||||||
>;
|
>;
|
||||||
|
type BrowserAutomationResponsePayload = BrowserAutomationExecuteResponse["payload"];
|
||||||
|
|
||||||
|
class FakeStateStorage implements StateStorage {
|
||||||
|
private readonly values = new Map<string, string>();
|
||||||
|
|
||||||
|
public getItem = (key: string): string | null => this.values.get(key) ?? null;
|
||||||
|
|
||||||
|
public setItem = (key: string, value: string): void => {
|
||||||
|
this.values.set(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
public removeItem = (key: string): void => {
|
||||||
|
this.values.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this.values.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
public sentResponses: BrowserAutomationExecuteResponse[] = [];
|
public readonly sentResponses: BrowserAutomationExecuteResponse[] = [];
|
||||||
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
private handler: ((request: BrowserAutomationExecuteRequest) => void) | null = null;
|
||||||
|
|
||||||
public on(
|
public on(
|
||||||
@@ -55,6 +61,97 @@ class FakeDaemonClient {
|
|||||||
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
public receive(nextRequest: BrowserAutomationExecuteRequest): void {
|
||||||
this.handler?.(nextRequest);
|
this.handler?.(nextRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public payloadAt(index: number): BrowserAutomationResponsePayload {
|
||||||
|
const response = this.sentResponses[index];
|
||||||
|
if (!response) {
|
||||||
|
throw new Error(`Missing browser automation response at index ${index}`);
|
||||||
|
}
|
||||||
|
return response.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeBrowserBridge {
|
||||||
|
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
|
||||||
|
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
|
||||||
|
[];
|
||||||
|
public readonly activeWorkspaceBrowsers: Array<{
|
||||||
|
browserId: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
}> = [];
|
||||||
|
public response: BrowserAutomationResponsePayload | null = null;
|
||||||
|
public thrownError: unknown = null;
|
||||||
|
|
||||||
|
public executeAutomationCommand = async (
|
||||||
|
request: BrowserAutomationExecuteRequest,
|
||||||
|
): Promise<BrowserAutomationResponsePayload> => {
|
||||||
|
this.executedRequests.push(request);
|
||||||
|
if (this.thrownError) {
|
||||||
|
throw this.thrownError;
|
||||||
|
}
|
||||||
|
return this.response ?? currentListTabsPayload(request.requestId);
|
||||||
|
};
|
||||||
|
|
||||||
|
public registerWorkspaceBrowser = async (input: {
|
||||||
|
browserId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
this.registeredWorkspaceBrowsers.push(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
public setWorkspaceActiveBrowser = async (input: {
|
||||||
|
browserId: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
this.activeWorkspaceBrowsers.push(input);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeResidentBrowser {
|
||||||
|
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
|
||||||
|
|
||||||
|
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
|
||||||
|
this.ensuredWebviews.push(input);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowserAutomationHandlerHarness {
|
||||||
|
public readonly client = new FakeDaemonClient();
|
||||||
|
public readonly browser = new FakeBrowserBridge();
|
||||||
|
public readonly resident = new FakeResidentBrowser();
|
||||||
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
public mount(
|
||||||
|
input: {
|
||||||
|
serverId?: string;
|
||||||
|
host?: DesktopHostBridge | null;
|
||||||
|
registrationWaitTimeoutMs?: number;
|
||||||
|
registrationPollIntervalMs?: number;
|
||||||
|
} = {},
|
||||||
|
): void {
|
||||||
|
this.unsubscribe = mountBrowserAutomationHandler({
|
||||||
|
client: this.client,
|
||||||
|
...(input.serverId ? { serverId: input.serverId } : {}),
|
||||||
|
getHost: () => (input.host === undefined ? { browser: this.browser } : input.host),
|
||||||
|
ensureResidentBrowserWebview: this.resident.ensure,
|
||||||
|
...(input.registrationWaitTimeoutMs !== undefined
|
||||||
|
? { registrationWaitTimeoutMs: input.registrationWaitTimeoutMs }
|
||||||
|
: {}),
|
||||||
|
...(input.registrationPollIntervalMs !== undefined
|
||||||
|
? { registrationPollIntervalMs: input.registrationPollIntervalMs }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public unmount(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
this.unsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public receive(request: BrowserAutomationExecuteRequest): void {
|
||||||
|
this.client.receive(request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
function browserAutomationRequest(): BrowserAutomationExecuteRequest {
|
||||||
@@ -78,36 +175,28 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyListTabsPayload(requestId = "req-new:list_tabs") {
|
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: [],
|
tabs: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentListTabsPayload(requestId = "req-new:list_tabs") {
|
function currentListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
command: "list_tabs" as const,
|
command: "list_tabs",
|
||||||
tabs: currentBrowserTabs(),
|
tabs: currentBrowserTabs(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPromises(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForAsyncWork(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentBrowserTabs() {
|
function currentBrowserTabs() {
|
||||||
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
return Object.values(useBrowserStore.getState().browsersById).map((browser) => ({
|
||||||
browserId: browser.browserId,
|
browserId: browser.browserId,
|
||||||
@@ -119,152 +208,170 @@ function currentBrowserTabs() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newTabResultFrom(payload: BrowserAutomationResponsePayload) {
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
requestId: "req-new",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "new_tab", workspaceId: "wks_workspace_a", url: "https://example.com" },
|
||||||
|
});
|
||||||
|
if (!payload.ok || payload.result.command !== "new_tab") {
|
||||||
|
throw new Error("Expected browser_new_tab success payload");
|
||||||
|
}
|
||||||
|
return payload.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceBrowserTabs(workspaceKey: string, browserId: string) {
|
||||||
|
return useWorkspaceLayoutStore
|
||||||
|
.getState()
|
||||||
|
.getWorkspaceTabs(workspaceKey)
|
||||||
|
.filter((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushAsyncWork(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForRegistrationTimeout(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserAutomationStorage = new FakeStateStorage();
|
||||||
|
useBrowserStore.persist.setOptions({
|
||||||
|
storage: createJSONStorage(() => browserAutomationStorage),
|
||||||
|
});
|
||||||
|
useWorkspaceLayoutStore.persist.setOptions({
|
||||||
|
storage: createJSONStorage(() => browserAutomationStorage),
|
||||||
|
});
|
||||||
|
|
||||||
describe("mountBrowserAutomationHandler", () => {
|
describe("mountBrowserAutomationHandler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
browserAutomationStorage.clear();
|
||||||
useBrowserStore.setState({ browsersById: {} });
|
useBrowserStore.setState({ browsersById: {} });
|
||||||
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a focused workspace browser tab for browser_new_tab", async () => {
|
test("browser_new_tab creates and focuses a workspace browser tab", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const setWorkspaceActiveBrowser = vi.fn(async () => undefined);
|
|
||||||
const registerWorkspaceBrowser = vi.fn(async () => undefined);
|
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
|
||||||
const executeAutomationCommand = vi.fn(async () => currentListTabsPayload());
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspaceId: "wks_workspace_a",
|
workspaceId: "wks_workspace_a",
|
||||||
});
|
});
|
||||||
if (!workspaceKey) throw new Error("expected workspace key");
|
if (!workspaceKey) {
|
||||||
const focusedTabId = useWorkspaceLayoutStore
|
throw new Error("Expected workspace key");
|
||||||
|
}
|
||||||
|
const previousFocusedTabId = useWorkspaceLayoutStore
|
||||||
.getState()
|
.getState()
|
||||||
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
.openTabFocused(workspaceKey, { kind: "draft", draftId: "human-draft" });
|
||||||
if (!focusedTabId) throw new Error("expected focused tab");
|
browser.mount({ serverId: "server-1" });
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () =>
|
|
||||||
({
|
|
||||||
browser: {
|
|
||||||
executeAutomationCommand,
|
|
||||||
registerWorkspaceBrowser,
|
|
||||||
setWorkspaceActiveBrowser,
|
|
||||||
},
|
|
||||||
}) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
const payload = client.sentResponses[0]?.payload;
|
const result = newTabResultFrom(browser.client.payloadAt(0));
|
||||||
expect(payload?.ok).toBe(true);
|
const openedTabs = workspaceBrowserTabs(workspaceKey, result.browserId);
|
||||||
if (!payload?.ok) throw new Error("expected success");
|
|
||||||
expect(payload.result.command).toBe("new_tab");
|
|
||||||
if (payload.result.command !== "new_tab") throw new Error("expected new_tab result");
|
|
||||||
const browserId = payload.result.browserId;
|
|
||||||
expect(payload.result.url).toBe("https://example.com");
|
|
||||||
const browserTab = useWorkspaceLayoutStore
|
|
||||||
.getState()
|
|
||||||
.getWorkspaceTabs(workspaceKey)
|
|
||||||
.find((tab) => tab.target.kind === "browser" && tab.target.browserId === browserId);
|
|
||||||
expect(browserTab).toBeDefined();
|
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout?.root.kind).toBe("pane");
|
expect(openedTabs).toEqual([
|
||||||
if (layout?.root.kind !== "pane") throw new Error("expected root pane");
|
expect.objectContaining({
|
||||||
expect(layout.root.pane.focusedTabId).toBe(browserTab?.tabId);
|
target: { kind: "browser", browserId: result.browserId },
|
||||||
expect(layout.root.pane.focusedTabId).not.toBe(focusedTabId);
|
}),
|
||||||
expect(registerWorkspaceBrowser).toHaveBeenCalledWith({
|
]);
|
||||||
browserId,
|
expect(layout?.root).toEqual(
|
||||||
workspaceId: "wks_workspace_a",
|
expect.objectContaining({
|
||||||
});
|
kind: "pane",
|
||||||
expect(setWorkspaceActiveBrowser).toHaveBeenCalledWith({
|
pane: expect.objectContaining({ focusedTabId: openedTabs[0]?.tabId }),
|
||||||
browserId,
|
}),
|
||||||
workspaceId: "wks_workspace_a",
|
);
|
||||||
});
|
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith({
|
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||||
browserId,
|
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||||
url: "https://example.com",
|
]);
|
||||||
});
|
expect(browser.browser.activeWorkspaceBrowsers).toEqual([
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledTimes(1);
|
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||||
|
]);
|
||||||
|
expect(browser.resident.ensuredWebviews).toEqual([
|
||||||
|
{ browserId: result.browserId, url: "https://example.com" },
|
||||||
|
]);
|
||||||
|
expect(browser.browser.executedRequests).toEqual([
|
||||||
|
{
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-new:list_tabs",
|
||||||
|
agentId: "agent-1",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
command: { command: "list_tabs", args: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns browser_timeout when the resident webview does not register", async () => {
|
test("browser_new_tab returns a retryable timeout when the resident webview does not register", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const ensureResidentBrowserWebview = vi.fn();
|
browser.browser.response = emptyListTabsPayload();
|
||||||
const executeAutomationCommand = vi.fn(async () => emptyListTabsPayload());
|
browser.mount({
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
ensureResidentBrowserWebview,
|
|
||||||
registrationWaitTimeoutMs: 1,
|
registrationWaitTimeoutMs: 1,
|
||||||
registrationPollIntervalMs: 1,
|
registrationPollIntervalMs: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await waitForAsyncWork();
|
await waitForRegistrationTimeout();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toMatchObject({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
ok: false,
|
type: "browser.automation.execute.response",
|
||||||
error: {
|
payload: {
|
||||||
code: "browser_timeout",
|
requestId: "req-new",
|
||||||
retryable: true,
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_timeout",
|
||||||
|
message: expect.stringContaining("Timed out waiting for browser tab"),
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
expect(ensureResidentBrowserWebview).toHaveBeenCalledWith(
|
expect(browser.resident.ensuredWebviews).toEqual([
|
||||||
expect.objectContaining({ url: "https://example.com" }),
|
expect.objectContaining({ url: "https://example.com" }),
|
||||||
);
|
]);
|
||||||
expect(client.sentResponses[0]?.payload).not.toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
result: { command: "new_tab" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wraps browser_new_tab registration bridge errors in a response", async () => {
|
test("browser_new_tab wraps registration bridge errors in a response", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => {
|
browser.browser.thrownError = new Error("IPC registration check failed");
|
||||||
throw new Error("IPC registration check failed");
|
browser.mount({ serverId: "server-1" });
|
||||||
});
|
|
||||||
mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
serverId: "server-1",
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserNewTabRequest());
|
browser.receive(browserNewTabRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
expect(browser.client.sentResponses).toEqual([
|
||||||
requestId: "req-new",
|
{
|
||||||
ok: false,
|
type: "browser.automation.execute.response",
|
||||||
error: {
|
payload: {
|
||||||
code: "browser_unknown_error",
|
requestId: "req-new",
|
||||||
message: "IPC registration check failed",
|
ok: false,
|
||||||
retryable: false,
|
error: {
|
||||||
|
code: "browser_unknown_error",
|
||||||
|
message: "IPC registration check failed",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends a success response from the desktop bridge", async () => {
|
test("non-new-tab requests send the desktop bridge response", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
browser.browser.response = {
|
||||||
requestId: "req-1",
|
requestId: "desktop-req",
|
||||||
ok: true as const,
|
ok: true,
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
result: { command: "list_tabs", tabs: [] },
|
||||||
}));
|
};
|
||||||
|
browser.mount();
|
||||||
|
const request = browserAutomationRequest();
|
||||||
|
|
||||||
mountBrowserAutomationHandler({
|
browser.receive(request);
|
||||||
client,
|
await flushAsyncWork();
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }) satisfies DesktopHostBridge,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
expect(browser.browser.executedRequests).toEqual([request]);
|
||||||
await flushPromises();
|
expect(browser.client.sentResponses).toEqual([
|
||||||
|
|
||||||
expect(executeAutomationCommand).toHaveBeenCalledWith(browserAutomationRequest());
|
|
||||||
expect(client.sentResponses).toEqual([
|
|
||||||
{
|
{
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -276,14 +383,14 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing bridge sends browser_unsupported", async () => {
|
test("missing desktop bridge sends browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({ client, getHost: () => null });
|
browser.mount({ host: null });
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(client.sentResponses).toEqual([
|
expect(browser.client.sentResponses).toEqual([
|
||||||
{
|
{
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -300,80 +407,68 @@ describe("mountBrowserAutomationHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("typed bridge errors become failure responses", async () => {
|
test("typed bridge errors become failure responses", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = {
|
||||||
client,
|
code: "browser_tab_not_found",
|
||||||
getHost: () => ({
|
message: "Browser tab browser-1 was not found.",
|
||||||
browser: {
|
retryable: false,
|
||||||
executeAutomationCommand: async () => {
|
};
|
||||||
throw {
|
browser.mount();
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
browser.receive(browserAutomationRequest());
|
||||||
retryable: false,
|
await flushAsyncWork();
|
||||||
};
|
|
||||||
|
expect(browser.client.sentResponses).toEqual([
|
||||||
|
{
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_tab_not_found",
|
||||||
|
message: "Browser tab browser-1 was not found.",
|
||||||
|
retryable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_tab_not_found",
|
|
||||||
message: "Browser tab browser-1 was not found.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
test("unimplemented preload IPC reports browser_unsupported", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
mountBrowserAutomationHandler({
|
browser.browser.thrownError = new Error(
|
||||||
client,
|
'No handler registered for "paseo:browser:execute-automation-command"',
|
||||||
getHost: () => ({
|
);
|
||||||
browser: {
|
browser.mount();
|
||||||
executeAutomationCommand: async () => {
|
|
||||||
throw new Error('No handler registered for "paseo:browser:execute-automation-command"');
|
browser.receive(browserAutomationRequest());
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(browser.client.sentResponses).toEqual([
|
||||||
|
{
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-1",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_unsupported",
|
||||||
|
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
||||||
|
retryable: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.receive(browserAutomationRequest());
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(client.sentResponses[0]?.payload).toEqual({
|
|
||||||
requestId: "req-1",
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: "browser_unsupported",
|
|
||||||
message: "Desktop browser automation is not implemented by this desktop build yet.",
|
|
||||||
retryable: false,
|
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unsubscribe stops handling requests", async () => {
|
test("unsubscribe stops handling browser automation requests", async () => {
|
||||||
const client = new FakeDaemonClient();
|
const browser = new BrowserAutomationHandlerHarness();
|
||||||
const executeAutomationCommand = vi.fn(async () => ({
|
browser.mount();
|
||||||
requestId: "req-1",
|
|
||||||
ok: true as const,
|
|
||||||
result: { command: "list_tabs" as const, tabs: [] },
|
|
||||||
}));
|
|
||||||
const unsubscribe = mountBrowserAutomationHandler({
|
|
||||||
client,
|
|
||||||
getHost: () => ({ browser: { executeAutomationCommand } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe();
|
browser.unmount();
|
||||||
client.receive(browserAutomationRequest());
|
browser.receive(browserAutomationRequest());
|
||||||
await flushPromises();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(executeAutomationCommand).not.toHaveBeenCalled();
|
expect(browser.browser.executedRequests).toEqual([]);
|
||||||
expect(client.sentResponses).toEqual([]);
|
expect(browser.client.sentResponses).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,473 @@ const BROWSER_ID_MESSAGE =
|
|||||||
"browserId must be a real id returned by browser_new_tab or browser_list_tabs";
|
"browserId must be a real id returned by browser_new_tab or browser_list_tabs";
|
||||||
const WAIT_CONDITION_MESSAGE = "browser_wait requires exactly one of text or url";
|
const WAIT_CONDITION_MESSAGE = "browser_wait requires exactly one of text or url";
|
||||||
|
|
||||||
|
const commandParseCases = [
|
||||||
|
{
|
||||||
|
name: "click",
|
||||||
|
command: { command: "click", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
expected: { command: "click", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fill",
|
||||||
|
command: {
|
||||||
|
command: "fill",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", value: "Ada" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "fill",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", value: "Ada" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
command: {
|
||||||
|
command: "type",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", text: "Ada" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "type",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", text: "Ada" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keypress",
|
||||||
|
command: { command: "keypress", args: { browserId: BROWSER_ID, key: "Enter" } },
|
||||||
|
expected: { command: "keypress", args: { browserId: BROWSER_ID, key: "Enter" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "navigate",
|
||||||
|
command: {
|
||||||
|
command: "navigate",
|
||||||
|
args: { browserId: BROWSER_ID, url: "https://example.com/next" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "navigate",
|
||||||
|
args: { browserId: BROWSER_ID, url: "https://example.com/next" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "back",
|
||||||
|
command: { command: "back", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "back", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forward",
|
||||||
|
command: { command: "forward", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "forward", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reload",
|
||||||
|
command: { command: "reload", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "reload", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "screenshot",
|
||||||
|
command: { command: "screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full page screenshot",
|
||||||
|
command: { command: "full_page_screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "full_page_screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pdf",
|
||||||
|
command: { command: "pdf", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "pdf", args: { browserId: BROWSER_ID, printBackground: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "download",
|
||||||
|
command: {
|
||||||
|
command: "download",
|
||||||
|
args: { browserId: BROWSER_ID, url: "https://example.com/file.txt" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "download",
|
||||||
|
args: { browserId: BROWSER_ID, url: "https://example.com/file.txt" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
command: {
|
||||||
|
command: "upload",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", filePaths: ["/tmp/file.txt"] },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "upload",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", filePaths: ["/tmp/file.txt"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "focus",
|
||||||
|
command: { command: "focus", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
expected: { command: "focus", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear",
|
||||||
|
command: { command: "clear", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
expected: { command: "clear", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "check",
|
||||||
|
command: { command: "check", args: { browserId: BROWSER_ID, ref: "@e2" } },
|
||||||
|
expected: { command: "check", args: { browserId: BROWSER_ID, ref: "@e2", checked: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "select",
|
||||||
|
command: {
|
||||||
|
command: "select",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "select",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hover",
|
||||||
|
command: { command: "hover", args: { browserId: BROWSER_ID, ref: "@e4" } },
|
||||||
|
expected: { command: "hover", args: { browserId: BROWSER_ID, ref: "@e4" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drag",
|
||||||
|
command: {
|
||||||
|
command: "drag",
|
||||||
|
args: { browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "drag",
|
||||||
|
args: { browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logs",
|
||||||
|
command: { command: "logs", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "logs", args: { browserId: BROWSER_ID, maxEntries: 50 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage",
|
||||||
|
command: { command: "storage", args: { browserId: BROWSER_ID } },
|
||||||
|
expected: { command: "storage", args: { browserId: BROWSER_ID } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment",
|
||||||
|
command: {
|
||||||
|
command: "environment",
|
||||||
|
args: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "environment",
|
||||||
|
args: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set background",
|
||||||
|
command: { command: "set_background", args: { browserId: BROWSER_ID, color: "red" } },
|
||||||
|
expected: { command: "set_background", args: { browserId: BROWSER_ID, color: "red" } },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const resultParseCases = [
|
||||||
|
{
|
||||||
|
name: "page info",
|
||||||
|
result: {
|
||||||
|
command: "page_info",
|
||||||
|
tab: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "page_info",
|
||||||
|
tab: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "Example",
|
||||||
|
isActive: false,
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "snapshot",
|
||||||
|
result: {
|
||||||
|
command: "snapshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
url: "https://example.com/form",
|
||||||
|
title: "Fixture",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
ref: "@e1",
|
||||||
|
role: "textbox",
|
||||||
|
tagName: "input",
|
||||||
|
text: "Name",
|
||||||
|
selector: "#name",
|
||||||
|
attributes: { id: "name", type: "text" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "snapshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
url: "https://example.com/form",
|
||||||
|
title: "Fixture",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
ref: "@e1",
|
||||||
|
role: "textbox",
|
||||||
|
tagName: "input",
|
||||||
|
text: "Name",
|
||||||
|
selector: "#name",
|
||||||
|
attributes: { id: "name", type: "text" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "click",
|
||||||
|
result: { command: "click", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
expected: { command: "click", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fill",
|
||||||
|
result: { command: "fill", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
expected: { command: "fill", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wait",
|
||||||
|
result: { command: "wait", browserId: BROWSER_ID, matched: "text" },
|
||||||
|
expected: { command: "wait", browserId: BROWSER_ID, matched: "text" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
result: { command: "type", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
expected: { command: "type", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keypress",
|
||||||
|
result: { command: "keypress", browserId: BROWSER_ID, key: "Enter" },
|
||||||
|
expected: { command: "keypress", browserId: BROWSER_ID, key: "Enter" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "navigate",
|
||||||
|
result: { command: "navigate", browserId: BROWSER_ID, url: "https://example.com/next" },
|
||||||
|
expected: { command: "navigate", browserId: BROWSER_ID, url: "https://example.com/next" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "back",
|
||||||
|
result: { command: "back", browserId: BROWSER_ID },
|
||||||
|
expected: { command: "back", browserId: BROWSER_ID },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forward",
|
||||||
|
result: { command: "forward", browserId: BROWSER_ID },
|
||||||
|
expected: { command: "forward", browserId: BROWSER_ID },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reload",
|
||||||
|
result: { command: "reload", browserId: BROWSER_ID },
|
||||||
|
expected: { command: "reload", browserId: BROWSER_ID },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "screenshot",
|
||||||
|
result: {
|
||||||
|
command: "screenshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataBase64: "iVBORw0KGgo=",
|
||||||
|
width: 100,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "screenshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataBase64: "iVBORw0KGgo=",
|
||||||
|
width: 100,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full page screenshot",
|
||||||
|
result: {
|
||||||
|
command: "full_page_screenshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataBase64: "iVBORw0KGgo=",
|
||||||
|
width: 390,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "full_page_screenshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataBase64: "iVBORw0KGgo=",
|
||||||
|
width: 390,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pdf",
|
||||||
|
result: {
|
||||||
|
command: "pdf",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
dataBase64: "JVBERg==",
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "pdf",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
dataBase64: "JVBERg==",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "download",
|
||||||
|
result: {
|
||||||
|
command: "download",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com/file.txt",
|
||||||
|
filePath: "/tmp/file.txt",
|
||||||
|
totalBytes: 5,
|
||||||
|
state: "completed",
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "download",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com/file.txt",
|
||||||
|
filePath: "/tmp/file.txt",
|
||||||
|
totalBytes: 5,
|
||||||
|
state: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
result: {
|
||||||
|
command: "upload",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
ref: "@e1",
|
||||||
|
filePaths: ["/tmp/file.txt"],
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "upload",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
ref: "@e1",
|
||||||
|
filePaths: ["/tmp/file.txt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "focus",
|
||||||
|
result: { command: "focus", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
expected: { command: "focus", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear",
|
||||||
|
result: { command: "clear", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
expected: { command: "clear", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "check",
|
||||||
|
result: { command: "check", browserId: BROWSER_ID, ref: "@e2", checked: false },
|
||||||
|
expected: { command: "check", browserId: BROWSER_ID, ref: "@e2", checked: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "select",
|
||||||
|
result: { command: "select", browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
expected: { command: "select", browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hover",
|
||||||
|
result: { command: "hover", browserId: BROWSER_ID, ref: "@e4" },
|
||||||
|
expected: { command: "hover", browserId: BROWSER_ID, ref: "@e4" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drag",
|
||||||
|
result: { command: "drag", browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
expected: { command: "drag", browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logs",
|
||||||
|
result: {
|
||||||
|
command: "logs",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
console: [{ level: "info", message: "ready", timestamp: 10 }],
|
||||||
|
network: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/app.js",
|
||||||
|
type: "script",
|
||||||
|
startTime: 1,
|
||||||
|
duration: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "logs",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
console: [{ level: "info", message: "ready", timestamp: 10 }],
|
||||||
|
network: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/app.js",
|
||||||
|
type: "script",
|
||||||
|
startTime: 1,
|
||||||
|
duration: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage",
|
||||||
|
result: {
|
||||||
|
command: "storage",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com",
|
||||||
|
cookies: [{ name: "theme", value: "dark", domain: "example.com", httpOnly: true }],
|
||||||
|
localStorage: [{ key: "token", value: "abc" }],
|
||||||
|
sessionStorage: [{ key: "tab", value: "1" }],
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "storage",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com",
|
||||||
|
cookies: [{ name: "theme", value: "dark", domain: "example.com", httpOnly: true }],
|
||||||
|
localStorage: [{ key: "token", value: "abc" }],
|
||||||
|
sessionStorage: [{ key: "tab", value: "1" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment",
|
||||||
|
result: {
|
||||||
|
command: "environment",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
command: "environment",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set background",
|
||||||
|
result: { command: "set_background", browserId: BROWSER_ID, color: "red" },
|
||||||
|
expected: { command: "set_background", browserId: BROWSER_ID, color: "red" },
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
describe("browser automation execute RPC schemas", () => {
|
describe("browser automation execute RPC schemas", () => {
|
||||||
test("list tabs reads workspace from the request envelope", () => {
|
test("list tabs reads workspace from the request envelope", () => {
|
||||||
const parsed = BrowserAutomationExecuteRequestSchema.parse({
|
const parsed = BrowserAutomationExecuteRequestSchema.parse({
|
||||||
@@ -161,6 +628,20 @@ describe("browser automation execute RPC schemas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(commandParseCases)(
|
||||||
|
"$name requests parse explicit browser ids",
|
||||||
|
({ command, expected }) => {
|
||||||
|
const parsed = BrowserAutomationExecuteRequestSchema.parse({
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-command",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.command).toEqual(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("navigate rejects non-http URLs at the protocol boundary", () => {
|
test("navigate rejects non-http URLs at the protocol boundary", () => {
|
||||||
const parsed = BrowserAutomationExecuteRequestSchema.safeParse({
|
const parsed = BrowserAutomationExecuteRequestSchema.safeParse({
|
||||||
type: "browser.automation.execute.request",
|
type: "browser.automation.execute.request",
|
||||||
@@ -177,6 +658,22 @@ describe("browser automation execute RPC schemas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("download rejects non-http URLs at the protocol boundary", () => {
|
||||||
|
const parsed = BrowserAutomationExecuteRequestSchema.safeParse({
|
||||||
|
type: "browser.automation.execute.request",
|
||||||
|
requestId: "req-download",
|
||||||
|
command: {
|
||||||
|
command: "download",
|
||||||
|
args: { browserId: BROWSER_ID, url: "file:///tmp/secret.txt" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: { issues: [expect.objectContaining({ message: "URL must use http or https" })] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("new tab responses declare the generated browser id shape", () => {
|
test("new tab responses declare the generated browser id shape", () => {
|
||||||
const parsed = BrowserAutomationExecuteResponseSchema.parse({
|
const parsed = BrowserAutomationExecuteResponseSchema.parse({
|
||||||
type: "browser.automation.execute.response",
|
type: "browser.automation.execute.response",
|
||||||
@@ -227,4 +724,48 @@ describe("browser automation execute RPC schemas", () => {
|
|||||||
error: { issues: [expect.objectContaining({ message: BROWSER_ID_MESSAGE })] },
|
error: { issues: [expect.objectContaining({ message: BROWSER_ID_MESSAGE })] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(resultParseCases)(
|
||||||
|
"$name results parse under the response payload",
|
||||||
|
({ result, expected }) => {
|
||||||
|
const parsed = BrowserAutomationExecuteResponseSchema.parse({
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-result",
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.payload).toEqual({
|
||||||
|
requestId: "req-result",
|
||||||
|
ok: true,
|
||||||
|
result: expected,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("error responses keep stable codes, messages, and retry defaults", () => {
|
||||||
|
const parsed = BrowserAutomationExecuteResponseSchema.parse({
|
||||||
|
type: "browser.automation.execute.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-error",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_no_desktop",
|
||||||
|
message: "No desktop browser automation client is connected.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.payload).toEqual({
|
||||||
|
requestId: "req-error",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_no_desktop",
|
||||||
|
message: "No desktop browser automation client is connected.",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,6 +153,418 @@ function screenshotPayload(): Extract<BrowserToolsResponsePayload, { ok: true }>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routedToolCases = [
|
||||||
|
{
|
||||||
|
name: "set background",
|
||||||
|
toolName: "browser_set_background",
|
||||||
|
input: { browserId: BROWSER_ID, color: "red" },
|
||||||
|
command: { command: "set_background", args: { browserId: BROWSER_ID, color: "red" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-set-background",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "set_background", browserId: BROWSER_ID, color: "red" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Set browser page background to red." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "click",
|
||||||
|
toolName: "browser_click",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e2" },
|
||||||
|
command: { command: "click", args: { browserId: BROWSER_ID, ref: "@e2" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-click",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "click", browserId: BROWSER_ID, ref: "@e2" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Clicked browser element @e2." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fill",
|
||||||
|
toolName: "browser_fill",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e1", value: "Ada" },
|
||||||
|
command: { command: "fill", args: { browserId: BROWSER_ID, ref: "@e1", value: "Ada" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-fill",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "fill", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Filled browser element @e1." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
toolName: "browser_type",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e1", text: "Ada" },
|
||||||
|
command: { command: "type", args: { browserId: BROWSER_ID, ref: "@e1", text: "Ada" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-type",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "type", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Typed into browser element @e1." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keypress",
|
||||||
|
toolName: "browser_keypress",
|
||||||
|
input: { browserId: BROWSER_ID, key: "Enter" },
|
||||||
|
command: { command: "keypress", args: { browserId: BROWSER_ID, key: "Enter" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-keypress",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "keypress", browserId: BROWSER_ID, key: "Enter" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Pressed Enter in the browser." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "back",
|
||||||
|
toolName: "browser_back",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
command: { command: "back", args: { browserId: BROWSER_ID } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-back",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "back", browserId: BROWSER_ID },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Browser back complete." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "screenshot",
|
||||||
|
toolName: "browser_screenshot",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
command: { command: "screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
payload: screenshotPayload(),
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Captured browser screenshot (800x600)." },
|
||||||
|
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "logs",
|
||||||
|
toolName: "browser_logs",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
command: { command: "logs", args: { browserId: BROWSER_ID, maxEntries: 50 } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-logs",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "logs",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
console: [{ level: "info", message: "ready", timestamp: 10 }],
|
||||||
|
network: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/app.js",
|
||||||
|
type: "script",
|
||||||
|
startTime: 1,
|
||||||
|
duration: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Read 1 console log and 1 network entry." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage",
|
||||||
|
toolName: "browser_storage",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
command: { command: "storage", args: { browserId: BROWSER_ID } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-storage",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "storage",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com",
|
||||||
|
cookies: [{ name: "theme", value: "dark" }],
|
||||||
|
localStorage: [{ key: "token", value: "abc" }],
|
||||||
|
sessionStorage: [{ key: "tab", value: "1" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Read 1 cookie, 1 localStorage entry, and 1 sessionStorage entry." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment",
|
||||||
|
toolName: "browser_environment",
|
||||||
|
input: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
command: "environment",
|
||||||
|
args: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-environment",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "environment",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
viewport: { width: 390, height: 844, deviceScaleFactor: 3 },
|
||||||
|
geolocation: { latitude: 37.7749, longitude: -122.4194, accuracy: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Browser environment viewport is 390x844." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full page screenshot",
|
||||||
|
toolName: "browser_full_page_screenshot",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
command: { command: "full_page_screenshot", args: { browserId: BROWSER_ID } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-full-page",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "full_page_screenshot",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "image/png",
|
||||||
|
dataBase64: "iVBORw0KGgo=",
|
||||||
|
width: 390,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Captured full-page browser screenshot (390x1200)." },
|
||||||
|
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pdf",
|
||||||
|
toolName: "browser_pdf",
|
||||||
|
input: { browserId: BROWSER_ID, landscape: true },
|
||||||
|
command: {
|
||||||
|
command: "pdf",
|
||||||
|
args: { browserId: BROWSER_ID, landscape: true, printBackground: true },
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-pdf",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "pdf",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
dataBase64: "JVBERg==",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Exported browser page PDF." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "download",
|
||||||
|
toolName: "browser_download",
|
||||||
|
input: { browserId: BROWSER_ID, url: "https://example.com/file.txt", fileName: "file.txt" },
|
||||||
|
command: {
|
||||||
|
command: "download",
|
||||||
|
args: {
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com/file.txt",
|
||||||
|
fileName: "file.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-download",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "download",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
url: "https://example.com/file.txt",
|
||||||
|
filePath: "/tmp/file.txt",
|
||||||
|
totalBytes: 5,
|
||||||
|
state: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Downloaded browser file to /tmp/file.txt." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
toolName: "browser_upload",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e1", filePaths: ["/tmp/file.txt"] },
|
||||||
|
command: {
|
||||||
|
command: "upload",
|
||||||
|
args: { browserId: BROWSER_ID, ref: "@e1", filePaths: ["/tmp/file.txt"] },
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-upload",
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
command: "upload",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
ref: "@e1",
|
||||||
|
filePaths: ["/tmp/file.txt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Uploaded 1 file to browser element @e1." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "focus",
|
||||||
|
toolName: "browser_focus",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
command: { command: "focus", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-focus",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "focus", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Focused browser element @e1." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear",
|
||||||
|
toolName: "browser_clear",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
command: { command: "clear", args: { browserId: BROWSER_ID, ref: "@e1" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-clear",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "clear", browserId: BROWSER_ID, ref: "@e1" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Cleared browser element @e1." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "check",
|
||||||
|
toolName: "browser_check",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e2", checked: false },
|
||||||
|
command: { command: "check", args: { browserId: BROWSER_ID, ref: "@e2", checked: false } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-check",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "check", browserId: BROWSER_ID, ref: "@e2", checked: false },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Unchecked browser element @e2." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "select",
|
||||||
|
toolName: "browser_select",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
command: { command: "select", args: { browserId: BROWSER_ID, ref: "@e3", value: "us" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-select",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "select", browserId: BROWSER_ID, ref: "@e3", value: "us" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Selected us in browser element @e3." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hover",
|
||||||
|
toolName: "browser_hover",
|
||||||
|
input: { browserId: BROWSER_ID, ref: "@e4" },
|
||||||
|
command: { command: "hover", args: { browserId: BROWSER_ID, ref: "@e4" } },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-hover",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "hover", browserId: BROWSER_ID, ref: "@e4" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Hovered browser element @e4." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "drag",
|
||||||
|
toolName: "browser_drag",
|
||||||
|
input: { browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
command: {
|
||||||
|
command: "drag",
|
||||||
|
args: { browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-drag",
|
||||||
|
ok: true,
|
||||||
|
result: { command: "drag", browserId: BROWSER_ID, sourceRef: "@e4", targetRef: "@e5" },
|
||||||
|
},
|
||||||
|
content: [{ type: "text", text: "Dragged browser element @e4 to @e5." }],
|
||||||
|
},
|
||||||
|
] satisfies Array<{
|
||||||
|
name: string;
|
||||||
|
toolName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
command: BrowserToolsExecuteInput["command"];
|
||||||
|
payload: Extract<BrowserToolsResponsePayload, { ok: true }>;
|
||||||
|
content: PaseoToolResult["content"];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const brokerErrorCases = [
|
||||||
|
{
|
||||||
|
name: "disabled browser tools",
|
||||||
|
toolName: "browser_list_tabs",
|
||||||
|
input: {},
|
||||||
|
payload: {
|
||||||
|
requestId: "req-disabled",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_disabled",
|
||||||
|
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Browser tools are disabled. Enable desktop browser tools on the host, then try again.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: { agentId: "agent-1", cwd: "/repo", workspaceId: "wks_workspace_a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typed timeout errors",
|
||||||
|
toolName: "browser_page_info",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-timeout",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "browser_timeout",
|
||||||
|
message: "Browser automation timed out after 15000ms.",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "The browser did not respond before the timeout. Try again or check the desktop app.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "screenshot no-frame errors",
|
||||||
|
toolName: "browser_screenshot",
|
||||||
|
input: { browserId: BROWSER_ID },
|
||||||
|
payload: {
|
||||||
|
requestId: "req-no-frame",
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "screenshot_no_frame",
|
||||||
|
message: "The browser tab has no painted frame. Focus the tab in the app, then try again.",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "The browser tab has no painted frame. Focus the tab in the app, then try again.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies Array<{
|
||||||
|
name: string;
|
||||||
|
toolName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
payload: Extract<BrowserToolsResponsePayload, { ok: false }>;
|
||||||
|
content: PaseoToolResult["content"];
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
|
||||||
describe("registerBrowserTools", () => {
|
describe("registerBrowserTools", () => {
|
||||||
test("list tabs sends workspace in the request envelope", async () => {
|
test("list tabs sends workspace in the request envelope", async () => {
|
||||||
const harness = new BrowserToolHarness();
|
const harness = new BrowserToolHarness();
|
||||||
@@ -429,6 +841,53 @@ describe("registerBrowserTools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(routedToolCases)(
|
||||||
|
"$name routes browser id in command args and workspace id in the envelope",
|
||||||
|
async ({ toolName, input, command, payload, content }) => {
|
||||||
|
const harness = new BrowserToolHarness();
|
||||||
|
harness.broker.setResponse(payload);
|
||||||
|
|
||||||
|
const response = await harness.execute(toolName, input);
|
||||||
|
|
||||||
|
expect(harness.broker.calls).toEqual([
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(response.content).toEqual(content);
|
||||||
|
expect(response.structuredContent).toEqual({
|
||||||
|
ok: payload.ok,
|
||||||
|
result: payload.result,
|
||||||
|
context: {
|
||||||
|
agentId: "agent-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
workspaceId: "wks_workspace_a",
|
||||||
|
browserId: BROWSER_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(brokerErrorCases)(
|
||||||
|
"$name keep broker error summaries model-actionable",
|
||||||
|
async ({ toolName, input, payload, content, context }) => {
|
||||||
|
const harness = new BrowserToolHarness();
|
||||||
|
harness.broker.setResponse(payload);
|
||||||
|
|
||||||
|
const response = await harness.execute(toolName, input);
|
||||||
|
|
||||||
|
expect(response.content).toEqual(content);
|
||||||
|
expect(response.structuredContent).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: payload.error,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("wait rejects calls without a condition", () => {
|
test("wait rejects calls without a condition", () => {
|
||||||
const harness = new BrowserToolHarness();
|
const harness = new BrowserToolHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
@@ -228,17 +228,17 @@ function createVoiceAssistantWebSocketServer(params: {
|
|||||||
}): VoiceAssistantWebSocketServer {
|
}): VoiceAssistantWebSocketServer {
|
||||||
const { httpServer, broker } = params;
|
const { httpServer, broker } = params;
|
||||||
const agentManager = {
|
const agentManager = {
|
||||||
setAgentAttentionCallback: vi.fn(),
|
setAgentAttentionCallback() {},
|
||||||
subscribe: vi.fn(() => () => {}),
|
subscribe: () => () => {},
|
||||||
getMetricsSnapshot: vi.fn(() => ({
|
getMetricsSnapshot: () => ({
|
||||||
total: 0,
|
total: 0,
|
||||||
byLifecycle: {},
|
byLifecycle: {},
|
||||||
withActiveForegroundTurn: 0,
|
withActiveForegroundTurn: 0,
|
||||||
timelineStats: { totalItems: 0, maxItemsPerAgent: 0 },
|
timelineStats: { totalItems: 0, maxItemsPerAgent: 0 },
|
||||||
})),
|
}),
|
||||||
};
|
};
|
||||||
const daemonConfigStore = {
|
const daemonConfigStore = {
|
||||||
onChange: vi.fn(() => () => {}),
|
onChange: () => () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new VoiceAssistantWebSocketServer(
|
return new VoiceAssistantWebSocketServer(
|
||||||
@@ -264,15 +264,15 @@ function createVoiceAssistantWebSocketServer(params: {
|
|||||||
createStub<LoopService>({}),
|
createStub<LoopService>({}),
|
||||||
createStub<ScheduleService>({}),
|
createStub<ScheduleService>({}),
|
||||||
createStub<CheckoutDiffManager>({
|
createStub<CheckoutDiffManager>({
|
||||||
subscribe: vi.fn(),
|
subscribe: () => {},
|
||||||
scheduleRefreshForCwd: vi.fn(),
|
scheduleRefreshForCwd: () => {},
|
||||||
getMetrics: vi.fn(() => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
checkoutDiffWatcherCount: 0,
|
checkoutDiffWatcherCount: 0,
|
||||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||||
})),
|
}),
|
||||||
dispose: vi.fn(),
|
dispose: () => {},
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -348,12 +348,12 @@ function createBrowserRequestQueue(): QueuedBrowserRequests {
|
|||||||
|
|
||||||
function createLogger() {
|
function createLogger() {
|
||||||
const logger = {
|
const logger = {
|
||||||
child: vi.fn(() => logger),
|
child: () => logger,
|
||||||
trace: vi.fn(),
|
trace() {},
|
||||||
debug: vi.fn(),
|
debug() {},
|
||||||
info: vi.fn(),
|
info() {},
|
||||||
warn: vi.fn(),
|
warn() {},
|
||||||
error: vi.fn(),
|
error() {},
|
||||||
};
|
};
|
||||||
return logger;
|
return logger;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user