diff --git a/extensions/browser/src/browser/server.agent-contract-act-error-codes.test.ts b/extensions/browser/src/browser/server.agent-contract-act-error-codes.test.ts deleted file mode 100644 index 6c2df6ae75e..00000000000 --- a/extensions/browser/src/browser/server.agent-contract-act-error-codes.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - installAgentContractHooks, - startServerAndBase, -} from "./server.agent-contract.test-harness.js"; -import { - setBrowserControlServerEvaluateEnabled, - setBrowserControlServerProfiles, -} from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; - -type ActErrorResponse = { - error?: string; - code?: string; -}; - -type ActErrorHttpResponse = { - status: number; - body: ActErrorResponse; -}; - -async function postActAndReadError(base: string, body?: unknown): Promise { - const realFetch = getBrowserTestFetch(); - const response = await realFetch(`${base}/act`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - return { - status: response.status, - body: (await response.json()) as ActErrorResponse, - }; -} - -describe("browser control server", () => { - installAgentContractHooks(); - - const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000; - - it( - "returns ACT_KIND_REQUIRED when kind is missing", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, {}); - - expect(response.status).toBe(400); - expect(response.body.code).toBe("ACT_KIND_REQUIRED"); - expect(response.body.error).toContain("kind is required"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_INVALID_REQUEST for malformed action payloads", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "click", - ref: {}, - }); - - expect(response.status).toBe(400); - expect(response.body.code).toBe("ACT_INVALID_REQUEST"); - expect(response.body.error).toContain("click requires ref or selector"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions", - async () => { - setBrowserControlServerProfiles({ - openclaw: { - color: "#FF4500", - driver: "existing-session", - }, - }); - - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "batch", - actions: [{ kind: "press", key: "Enter" }], - }); - - expect(response.status).toBe(501); - expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED"); - expect(response.body.error).toContain("batch"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "batch", - actions: [{ kind: "click", ref: "5", targetId: "other-tab" }], - }); - - expect(response.status).toBe(403); - expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH"); - expect(response.body.error).toContain("batched action targetId must match request targetId"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "click", - ref: "5", - // Intentionally non-string: route-level target selection ignores this, - // while action normalization stringifies it. - targetId: 12345, - }); - - expect(response.status).toBe(403); - expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH"); - expect(response.body.error).toContain("action targetId must match request targetId"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "evaluate", - fn: "() => 1", - selector: "#submit", - }); - - expect(response.status).toBe(400); - expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED"); - expect(response.body.error).toContain("'selector' is not supported"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating", - async () => { - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "press", - selector: "#submit", - }); - - expect(response.status).toBe(400); - expect(response.body.code).toBe("ACT_INVALID_REQUEST"); - expect(response.body.error).toContain("press requires key"); - }, - slowTimeoutMs, - ); - - it( - "returns ACT_EVALUATE_DISABLED when evaluate is blocked by config", - async () => { - setBrowserControlServerEvaluateEnabled(false); - const base = await startServerAndBase(); - const response = await postActAndReadError(base, { - kind: "evaluate", - fn: "() => 1", - }); - - expect(response.status).toBe(403); - expect(response.body.code).toBe("ACT_EVALUATE_DISABLED"); - expect(response.body.error).toContain("browser.evaluateEnabled=false"); - }, - slowTimeoutMs, - ); -}); diff --git a/extensions/browser/src/browser/server.agent-contract-core.test.ts b/extensions/browser/src/browser/server.agent-contract-core.test.ts new file mode 100644 index 00000000000..9a504c34362 --- /dev/null +++ b/extensions/browser/src/browser/server.agent-contract-core.test.ts @@ -0,0 +1,577 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js"; +import { + installAgentContractHooks, + postJson, + startServerAndBase, +} from "./server.agent-contract.test-harness.js"; +import { + cleanupBrowserControlServerTestContext, + getBrowserControlServerBaseUrl, + getBrowserControlServerTestState, + getCdpMocks, + getPwMocks, + makeResponse, + resetBrowserControlServerTestContext, + setBrowserControlServerEvaluateEnabled, + setBrowserControlServerProfiles, + setBrowserControlServerReachable, + startBrowserControlServerFromConfig, +} from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch } from "./test-fetch.js"; + +type ActErrorResponse = { + error?: string; + code?: string; +}; + +type ActErrorHttpResponse = { + status: number; + body: ActErrorResponse; +}; + +async function postActAndReadError(base: string, body?: unknown): Promise { + const realFetch = getBrowserTestFetch(); + const response = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { + status: response.status, + body: (await response.json()) as ActErrorResponse, + }; +} + +const state = getBrowserControlServerTestState(); +const cdpMocks = getCdpMocks(); +const pwMocks = getPwMocks(); + +describe("browser control server", () => { + installAgentContractHooks(); + + const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000; + + it( + "returns ACT_KIND_REQUIRED when kind is missing", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, {}); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ACT_KIND_REQUIRED"); + expect(response.body.error).toContain("kind is required"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_INVALID_REQUEST for malformed action payloads", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "click", + ref: {}, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ACT_INVALID_REQUEST"); + expect(response.body.error).toContain("click requires ref or selector"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions", + async () => { + setBrowserControlServerProfiles({ + openclaw: { + color: "#FF4500", + driver: "existing-session", + }, + }); + + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "batch", + actions: [{ kind: "press", key: "Enter" }], + }); + + expect(response.status).toBe(501); + expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED"); + expect(response.body.error).toContain("batch"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "batch", + actions: [{ kind: "click", ref: "5", targetId: "other-tab" }], + }); + + expect(response.status).toBe(403); + expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH"); + expect(response.body.error).toContain("batched action targetId must match request targetId"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "click", + ref: "5", + // Intentionally non-string: route-level target selection ignores this, + // while action normalization stringifies it. + targetId: 12345, + }); + + expect(response.status).toBe(403); + expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH"); + expect(response.body.error).toContain("action targetId must match request targetId"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "evaluate", + fn: "() => 1", + selector: "#submit", + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED"); + expect(response.body.error).toContain("'selector' is not supported"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating", + async () => { + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "press", + selector: "#submit", + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ACT_INVALID_REQUEST"); + expect(response.body.error).toContain("press requires key"); + }, + slowTimeoutMs, + ); + + it( + "returns ACT_EVALUATE_DISABLED when evaluate is blocked by config", + async () => { + setBrowserControlServerEvaluateEnabled(false); + const base = await startServerAndBase(); + const response = await postActAndReadError(base, { + kind: "evaluate", + fn: "() => 1", + }); + + expect(response.status).toBe(403); + expect(response.body.code).toBe("ACT_EVALUATE_DISABLED"); + expect(response.body.error).toContain("browser.evaluateEnabled=false"); + }, + slowTimeoutMs, + ); + it("agent contract: snapshot endpoints", async () => { + const base = await startServerAndBase(); + const realFetch = getBrowserTestFetch(); + + const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) => + r.json(), + )) as { ok: boolean; format?: string }; + expect(snapAria.ok).toBe(true); + expect(snapAria.format).toBe("aria"); + expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({ + wsUrl: "ws://127.0.0.1/devtools/page/abcd1234", + limit: 1, + }); + + const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as { + ok: boolean; + format?: string; + }; + expect(snapAi.ok).toBe(true); + expect(snapAi.format).toBe("ai"); + expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }); + + const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) => + r.json(), + )) as { ok: boolean; format?: string }; + expect(snapAiZero.ok).toBe(true); + expect(snapAiZero.format).toBe("ai"); + const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? []; + expect(lastCall).toEqual({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }); + }); + + it("agent contract: navigation + common act commands", async () => { + const base = await startServerAndBase(); + const realFetch = getBrowserTestFetch(); + + const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, { + url: "https://example.com", + }); + expect(nav.ok).toBe(true); + expect(typeof nav.targetId).toBe("string"); + expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + url: "https://example.com", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + + const click = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "click", + ref: "1", + button: "left", + modifiers: ["Shift"], + }); + expect(click.ok).toBe(true); + expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + ref: "1", + button: "left", + modifiers: ["Shift"], + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? []; + expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined(); + + const clickSelector = await realFetch(`${base}/act`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "click", selector: "button.save" }), + }); + expect(clickSelector.status).toBe(200); + expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true); + expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + selector: "button.save", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? []; + expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined(); + + const type = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "type", + ref: "1", + text: "", + }); + expect(type.ok).toBe(true); + expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + ref: "1", + text: "", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? []; + expect((typeArgs as { submit?: boolean }).submit).toBeUndefined(); + expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined(); + + const press = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "press", + key: "Enter", + }); + expect(press.ok).toBe(true); + expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + key: "Enter", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); + const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? []; + expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined(); + + const hover = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "hover", + ref: "2", + }); + expect(hover.ok).toBe(true); + expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + ref: "2", + }), + ); + const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? []; + expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); + + const scroll = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "scrollIntoView", + ref: "2", + }); + expect(scroll.ok).toBe(true); + expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + ref: "2", + }), + ); + const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? []; + expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); + + const drag = await postJson<{ ok: boolean }>(`${base}/act`, { + kind: "drag", + startRef: "3", + endRef: "4", + }); + expect(drag.ok).toBe(true); + expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + startRef: "3", + endRef: "4", + }), + ); + const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? []; + expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); + }); + it("POST /tabs/open?profile=unknown returns 404", async () => { + await startBrowserControlServerFromConfig(); + const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); + + const result = await realFetch(`${base}/tabs/open?profile=unknown`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(result.status).toBe(404); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("not found"); + }); + + it("POST /tabs/open returns 400 for invalid URLs", async () => { + setBrowserControlServerReachable(true); + await startBrowserControlServerFromConfig(); + const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); + + const result = await realFetch(`${base}/tabs/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "not a url" }), + }); + expect(result.status).toBe(400); + const body = (await result.json()) as { error: string }; + expect(body.error).toContain("Invalid URL:"); + }); +}); + +describe("profile CRUD endpoints", () => { + beforeEach(async () => { + await resetBrowserControlServerTestContext(); + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const u = url; + if (u.includes("/json/list")) { + return makeResponse([]); + } + return makeResponse({}, { ok: false, status: 500, text: "unexpected" }); + }), + ); + }); + + afterEach(async () => { + await cleanupBrowserControlServerTestContext(); + }); + + it("validates profile create/delete endpoints", async () => { + await startBrowserControlServerFromConfig(); + const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); + + const createMissingName = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(createMissingName.status).toBe(400); + const createMissingNameBody = (await createMissingName.json()) as { error: string }; + expect(createMissingNameBody.error).toContain("name is required"); + + const createInvalidName = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Invalid Name!" }), + }); + expect(createInvalidName.status).toBe(400); + const createInvalidNameBody = (await createInvalidName.json()) as { error: string }; + expect(createInvalidNameBody.error).toContain("invalid profile name"); + + const createDuplicate = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "openclaw" }), + }); + expect(createDuplicate.status).toBe(409); + const createDuplicateBody = (await createDuplicate.json()) as { error: string }; + expect(createDuplicateBody.error).toContain("already exists"); + + const createRemote = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }), + }); + expect(createRemote.status).toBe(200); + const createRemoteBody = (await createRemote.json()) as { + profile?: string; + cdpUrl?: string; + isRemote?: boolean; + }; + expect(createRemoteBody.profile).toBe("remote"); + expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222"); + expect(createRemoteBody.isRemote).toBe(true); + + const createBadRemote = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }), + }); + expect(createBadRemote.status).toBe(400); + const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; + expect(createBadRemoteBody.error).toContain("cdpUrl"); + + const createClawd = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }), + }); + expect(createClawd.status).toBe(200); + const createClawdBody = (await createClawd.json()) as { + profile?: string; + transport?: string; + cdpPort?: number | null; + userDataDir?: string | null; + }; + expect(createClawdBody.profile).toBe("legacyclawd"); + expect(createClawdBody.transport).toBe("cdp"); + expect(createClawdBody.cdpPort).toBeTypeOf("number"); + expect(createClawdBody.userDataDir).toBeNull(); + + const explicitUserDataDir = "/tmp/openclaw-brave-profile"; + await fs.promises.mkdir(explicitUserDataDir, { recursive: true }); + const createExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "brave-live", + driver: "existing-session", + userDataDir: explicitUserDataDir, + }), + }); + expect(createExistingSession.status).toBe(200); + const createExistingSessionBody = (await createExistingSession.json()) as { + profile?: string; + transport?: string; + userDataDir?: string | null; + }; + expect(createExistingSessionBody.profile).toBe("brave-live"); + expect(createExistingSessionBody.transport).toBe("chrome-mcp"); + expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir); + + const createBadExistingSession = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "bad-live", + userDataDir: explicitUserDataDir, + }), + }); + expect(createBadExistingSession.status).toBe(400); + const createBadExistingSessionBody = (await createBadExistingSession.json()) as { + error: string; + }; + expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required"); + + const createLegacyDriver = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "legacy", driver: "extension" }), + }); + expect(createLegacyDriver.status).toBe(400); + const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string }; + expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"'); + + const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, { + method: "DELETE", + }); + expect(deleteMissing.status).toBe(404); + const deleteMissingBody = (await deleteMissing.json()) as { error: string }; + expect(deleteMissingBody.error).toContain("not found"); + + const deleteDefault = await realFetch(`${base}/profiles/openclaw`, { + method: "DELETE", + }); + expect(deleteDefault.status).toBe(400); + const deleteDefaultBody = (await deleteDefault.json()) as { error: string }; + expect(deleteDefaultBody.error).toContain("cannot delete the default profile"); + + const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, { + method: "DELETE", + }); + expect(deleteInvalid.status).toBe(400); + const deleteInvalidBody = (await deleteInvalid.json()) as { error: string }; + expect(deleteInvalidBody.error).toContain("invalid profile name"); + }); +}); diff --git a/extensions/browser/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/extensions/browser/src/browser/server.agent-contract-snapshot-endpoints.test.ts deleted file mode 100644 index 16912b38898..00000000000 --- a/extensions/browser/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js"; -import { - installAgentContractHooks, - postJson, - startServerAndBase, -} from "./server.agent-contract.test-harness.js"; -import { - getBrowserControlServerTestState, - getCdpMocks, - getPwMocks, -} from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; - -const state = getBrowserControlServerTestState(); -const cdpMocks = getCdpMocks(); -const pwMocks = getPwMocks(); - -describe("browser control server", () => { - installAgentContractHooks(); - - it("agent contract: snapshot endpoints", async () => { - const base = await startServerAndBase(); - const realFetch = getBrowserTestFetch(); - - const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) => - r.json(), - )) as { ok: boolean; format?: string }; - expect(snapAria.ok).toBe(true); - expect(snapAria.format).toBe("aria"); - expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({ - wsUrl: "ws://127.0.0.1/devtools/page/abcd1234", - limit: 1, - }); - - const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as { - ok: boolean; - format?: string; - }; - expect(snapAi.ok).toBe(true); - expect(snapAi.format).toBe("ai"); - expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS, - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }); - - const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) => - r.json(), - )) as { ok: boolean; format?: string }; - expect(snapAiZero.ok).toBe(true); - expect(snapAiZero.format).toBe("ai"); - const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? []; - expect(lastCall).toEqual({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }); - }); - - it("agent contract: navigation + common act commands", async () => { - const base = await startServerAndBase(); - const realFetch = getBrowserTestFetch(); - - const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, { - url: "https://example.com", - }); - expect(nav.ok).toBe(true); - expect(typeof nav.targetId).toBe("string"); - expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith( - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - url: "https://example.com", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }), - ); - - const click = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "click", - ref: "1", - button: "left", - modifiers: ["Shift"], - }); - expect(click.ok).toBe(true); - expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ref: "1", - button: "left", - modifiers: ["Shift"], - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }), - ); - const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? []; - expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined(); - - const clickSelector = await realFetch(`${base}/act`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ kind: "click", selector: "button.save" }), - }); - expect(clickSelector.status).toBe(200); - expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true); - expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - selector: "button.save", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }), - ); - const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? []; - expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined(); - - const type = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "type", - ref: "1", - text: "", - }); - expect(type.ok).toBe(true); - expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ref: "1", - text: "", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }), - ); - const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? []; - expect((typeArgs as { submit?: boolean }).submit).toBeUndefined(); - expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined(); - - const press = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "press", - key: "Enter", - }); - expect(press.ok).toBe(true); - expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith( - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - key: "Enter", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }), - ); - const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? []; - expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined(); - - const hover = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "hover", - ref: "2", - }); - expect(hover.ok).toBe(true); - expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith( - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ref: "2", - }), - ); - const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? []; - expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); - - const scroll = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "scrollIntoView", - ref: "2", - }); - expect(scroll.ok).toBe(true); - expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith( - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - ref: "2", - }), - ); - const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? []; - expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); - - const drag = await postJson<{ ok: boolean }>(`${base}/act`, { - kind: "drag", - startRef: "3", - endRef: "4", - }); - expect(drag.ok).toBe(true); - expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith( - expect.objectContaining({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - startRef: "3", - endRef: "4", - }), - ); - const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? []; - expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined(); - }); -}); diff --git a/extensions/browser/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/extensions/browser/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts deleted file mode 100644 index 590eb13d623..00000000000 --- a/extensions/browser/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import fs from "node:fs"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - cleanupBrowserControlServerTestContext, - getBrowserControlServerBaseUrl, - installBrowserControlServerHooks, - makeResponse, - resetBrowserControlServerTestContext, - setBrowserControlServerReachable, - startBrowserControlServerFromConfig, -} from "./server.control-server.test-harness.js"; -import { getBrowserTestFetch } from "./test-fetch.js"; - -describe("browser control server", () => { - installBrowserControlServerHooks(); - - it("POST /tabs/open?profile=unknown returns 404", async () => { - await startBrowserControlServerFromConfig(); - const base = getBrowserControlServerBaseUrl(); - const realFetch = getBrowserTestFetch(); - - const result = await realFetch(`${base}/tabs/open?profile=unknown`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: "https://example.com" }), - }); - expect(result.status).toBe(404); - const body = (await result.json()) as { error: string }; - expect(body.error).toContain("not found"); - }); - - it("POST /tabs/open returns 400 for invalid URLs", async () => { - setBrowserControlServerReachable(true); - await startBrowserControlServerFromConfig(); - const base = getBrowserControlServerBaseUrl(); - const realFetch = getBrowserTestFetch(); - - const result = await realFetch(`${base}/tabs/open`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: "not a url" }), - }); - expect(result.status).toBe(400); - const body = (await result.json()) as { error: string }; - expect(body.error).toContain("Invalid URL:"); - }); -}); - -describe("profile CRUD endpoints", () => { - beforeEach(async () => { - await resetBrowserControlServerTestContext(); - - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => { - const u = url; - if (u.includes("/json/list")) { - return makeResponse([]); - } - return makeResponse({}, { ok: false, status: 500, text: "unexpected" }); - }), - ); - }); - - afterEach(async () => { - await cleanupBrowserControlServerTestContext(); - }); - - it("validates profile create/delete endpoints", async () => { - await startBrowserControlServerFromConfig(); - const base = getBrowserControlServerBaseUrl(); - const realFetch = getBrowserTestFetch(); - - const createMissingName = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(createMissingName.status).toBe(400); - const createMissingNameBody = (await createMissingName.json()) as { error: string }; - expect(createMissingNameBody.error).toContain("name is required"); - - const createInvalidName = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Invalid Name!" }), - }); - expect(createInvalidName.status).toBe(400); - const createInvalidNameBody = (await createInvalidName.json()) as { error: string }; - expect(createInvalidNameBody.error).toContain("invalid profile name"); - - const createDuplicate = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "openclaw" }), - }); - expect(createDuplicate.status).toBe(409); - const createDuplicateBody = (await createDuplicate.json()) as { error: string }; - expect(createDuplicateBody.error).toContain("already exists"); - - const createRemote = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }), - }); - expect(createRemote.status).toBe(200); - const createRemoteBody = (await createRemote.json()) as { - profile?: string; - cdpUrl?: string; - isRemote?: boolean; - }; - expect(createRemoteBody.profile).toBe("remote"); - expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222"); - expect(createRemoteBody.isRemote).toBe(true); - - const createBadRemote = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }), - }); - expect(createBadRemote.status).toBe(400); - const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; - expect(createBadRemoteBody.error).toContain("cdpUrl"); - - const createClawd = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }), - }); - expect(createClawd.status).toBe(200); - const createClawdBody = (await createClawd.json()) as { - profile?: string; - transport?: string; - cdpPort?: number | null; - userDataDir?: string | null; - }; - expect(createClawdBody.profile).toBe("legacyclawd"); - expect(createClawdBody.transport).toBe("cdp"); - expect(createClawdBody.cdpPort).toBeTypeOf("number"); - expect(createClawdBody.userDataDir).toBeNull(); - - const explicitUserDataDir = "/tmp/openclaw-brave-profile"; - await fs.promises.mkdir(explicitUserDataDir, { recursive: true }); - const createExistingSession = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "brave-live", - driver: "existing-session", - userDataDir: explicitUserDataDir, - }), - }); - expect(createExistingSession.status).toBe(200); - const createExistingSessionBody = (await createExistingSession.json()) as { - profile?: string; - transport?: string; - userDataDir?: string | null; - }; - expect(createExistingSessionBody.profile).toBe("brave-live"); - expect(createExistingSessionBody.transport).toBe("chrome-mcp"); - expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir); - - const createBadExistingSession = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "bad-live", - userDataDir: explicitUserDataDir, - }), - }); - expect(createBadExistingSession.status).toBe(400); - const createBadExistingSessionBody = (await createBadExistingSession.json()) as { - error: string; - }; - expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required"); - - const createLegacyDriver = await realFetch(`${base}/profiles/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "legacy", driver: "extension" }), - }); - expect(createLegacyDriver.status).toBe(400); - const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string }; - expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"'); - - const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, { - method: "DELETE", - }); - expect(deleteMissing.status).toBe(404); - const deleteMissingBody = (await deleteMissing.json()) as { error: string }; - expect(deleteMissingBody.error).toContain("not found"); - - const deleteDefault = await realFetch(`${base}/profiles/openclaw`, { - method: "DELETE", - }); - expect(deleteDefault.status).toBe(400); - const deleteDefaultBody = (await deleteDefault.json()) as { error: string }; - expect(deleteDefaultBody.error).toContain("cannot delete the default profile"); - - const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, { - method: "DELETE", - }); - expect(deleteInvalid.status).toBe(400); - const deleteInvalidBody = (await deleteInvalid.json()) as { error: string }; - expect(deleteInvalidBody.error).toContain("invalid profile name"); - }); -});