2025-12-13 17:37:00 +00:00
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
2025-12-20 03:27:12 +00:00
|
|
|
import {
|
|
|
|
|
browserAct,
|
|
|
|
|
browserArmDialog,
|
|
|
|
|
browserArmFileChooser,
|
|
|
|
|
browserConsoleMessages,
|
|
|
|
|
browserNavigate,
|
|
|
|
|
browserPdfSave,
|
|
|
|
|
browserScreenshotAction,
|
|
|
|
|
} from "./client-actions.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { browserOpenTab, browserSnapshot, browserStatus, browserTabs } from "./client.js";
|
2025-12-13 17:37:00 +00:00
|
|
|
|
|
|
|
|
describe("browser client", () => {
|
2026-02-16 14:52:15 +00:00
|
|
|
function stubSnapshotFetch(calls: string[]) {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async (url: string) => {
|
|
|
|
|
calls.push(url);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
format: "ai",
|
|
|
|
|
targetId: "t1",
|
|
|
|
|
url: "https://x",
|
|
|
|
|
snapshot: "ok",
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 17:37:00 +00:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.unstubAllGlobals();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 03:23:42 +00:00
|
|
|
it("wraps connection failures with a sandbox hint", async () => {
|
2025-12-13 17:37:00 +00:00
|
|
|
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
|
|
|
|
|
code: "ECONNREFUSED",
|
|
|
|
|
});
|
|
|
|
|
const fetchFailed = Object.assign(new TypeError("fetch failed"), {
|
|
|
|
|
cause: refused,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
|
|
|
|
|
|
2026-01-27 03:23:42 +00:00
|
|
|
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
|
2025-12-13 17:37:00 +00:00
|
|
|
});
|
2025-12-13 20:37:56 +00:00
|
|
|
|
|
|
|
|
it("adds useful timeout messaging for abort-like failures", async () => {
|
|
|
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted")));
|
2026-01-14 14:31:43 +00:00
|
|
|
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
|
2025-12-13 20:37:56 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("surfaces non-2xx responses with body text", async () => {
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn().mockResolvedValue({
|
|
|
|
|
ok: false,
|
|
|
|
|
status: 409,
|
|
|
|
|
text: async () => "conflict",
|
|
|
|
|
} as unknown as Response),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expect(
|
2025-12-20 03:27:12 +00:00
|
|
|
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
2026-01-27 03:23:42 +00:00
|
|
|
).rejects.toThrow(/conflict/i);
|
2025-12-13 20:37:56 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-15 04:04:23 +00:00
|
|
|
it("adds labels + efficient mode query params to snapshots", async () => {
|
|
|
|
|
const calls: string[] = [];
|
2026-02-16 14:52:15 +00:00
|
|
|
stubSnapshotFetch(calls);
|
2026-01-15 04:04:23 +00:00
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
browserSnapshot("http://127.0.0.1:18791", {
|
|
|
|
|
format: "ai",
|
|
|
|
|
labels: true,
|
|
|
|
|
mode: "efficient",
|
|
|
|
|
}),
|
|
|
|
|
).resolves.toMatchObject({ ok: true, format: "ai" });
|
|
|
|
|
|
|
|
|
|
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
|
|
|
|
expect(snapshotCall).toBeTruthy();
|
|
|
|
|
const parsed = new URL(snapshotCall as string);
|
|
|
|
|
expect(parsed.searchParams.get("labels")).toBe("1");
|
|
|
|
|
expect(parsed.searchParams.get("mode")).toBe("efficient");
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-15 10:16:33 +00:00
|
|
|
it("adds refs=aria to snapshots when requested", async () => {
|
|
|
|
|
const calls: string[] = [];
|
2026-02-16 14:52:15 +00:00
|
|
|
stubSnapshotFetch(calls);
|
2026-01-15 10:16:33 +00:00
|
|
|
|
|
|
|
|
await browserSnapshot("http://127.0.0.1:18791", {
|
|
|
|
|
format: "ai",
|
|
|
|
|
refs: "aria",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
|
|
|
|
expect(snapshotCall).toBeTruthy();
|
|
|
|
|
const parsed = new URL(snapshotCall as string);
|
|
|
|
|
expect(parsed.searchParams.get("refs")).toBe("aria");
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-08 23:45:59 +00:00
|
|
|
it("omits format when the caller wants server-side snapshot capability defaults", async () => {
|
|
|
|
|
const calls: string[] = [];
|
|
|
|
|
stubSnapshotFetch(calls);
|
|
|
|
|
|
|
|
|
|
await browserSnapshot("http://127.0.0.1:18791", {
|
|
|
|
|
profile: "chrome",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
|
|
|
|
expect(snapshotCall).toBeTruthy();
|
|
|
|
|
const parsed = new URL(snapshotCall as string);
|
|
|
|
|
expect(parsed.searchParams.get("format")).toBeNull();
|
|
|
|
|
expect(parsed.searchParams.get("profile")).toBe("chrome");
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-13 20:37:56 +00:00
|
|
|
it("uses the expected endpoints + methods for common calls", async () => {
|
|
|
|
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
|
|
|
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
"fetch",
|
|
|
|
|
vi.fn(async (url: string, init?: RequestInit) => {
|
|
|
|
|
calls.push({ url, init });
|
|
|
|
|
if (url.endsWith("/tabs") && (!init || init.method === undefined)) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
running: true,
|
|
|
|
|
tabs: [{ targetId: "t1", title: "T", url: "https://x" }],
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
|
|
|
|
if (url.endsWith("/tabs/open")) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
targetId: "t2",
|
|
|
|
|
title: "N",
|
|
|
|
|
url: "https://y",
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
2025-12-20 03:27:12 +00:00
|
|
|
if (url.endsWith("/navigate")) {
|
2025-12-13 20:37:56 +00:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
targetId: "t1",
|
2025-12-20 03:27:12 +00:00
|
|
|
url: "https://y",
|
2025-12-13 20:37:56 +00:00
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
2025-12-20 03:27:12 +00:00
|
|
|
if (url.endsWith("/act")) {
|
2025-12-13 20:37:56 +00:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
targetId: "t1",
|
|
|
|
|
url: "https://x",
|
2025-12-20 03:27:12 +00:00
|
|
|
result: 1,
|
fix(browser): normalize batch act dispatch for selector and batch support (#45457)
* feat(browser): add batch actions, CSS selector support, and click delayMs
Adds three improvements to the browser act tool:
1. CSS selector support: All element-targeting actions (click, type,
hover, drag, scrollIntoView, select) now accept an optional
'selector' parameter alongside 'ref'. When selector is provided,
Playwright's page.locator() is used directly, skipping the need
for a snapshot to obtain refs. This reduces roundtrips for agents
that already know the DOM structure.
2. Click delay (delayMs): The click action now accepts an optional
'delayMs' parameter. When set, the element is hovered first, then
after the specified delay, clicked. This enables human-like
hover-before-click in a single tool call instead of three
(hover + wait + click).
3. Batch actions: New 'batch' action kind that accepts an array of
actions to execute sequentially in a single tool call. Supports
'stopOnError' (default true) to control whether execution halts
on first failure. Results are returned as an array. This eliminates
the AI inference roundtrip between each action, dramatically
reducing latency and token cost for multi-step flows.
Addresses: #44431, #38844
* fix(browser): address security review — batch evaluateEnabled guard, input validation, recursion limit
Fixes all 4 issues raised by Greptile review:
1. Security: batch actions now respect evaluateEnabled flag.
executeSingleAction and batchViaPlaywright accept evaluateEnabled
param. evaluate and wait-with-fn inside batches are rejected
when evaluateEnabled=false, matching the direct route guards.
2. Security: batch input validation. Each action in body.actions
is validated as a plain object with a known kind string before
dispatch. Applies same normalization as direct action handlers.
3. Perf: SELECTOR_ALLOWED_KINDS moved to module scope as a
ReadonlySet<string> constant (was re-created on every request).
4. Security: max batch nesting depth of 5. Nested batch actions
track depth and throw if MAX_BATCH_DEPTH exceeded, preventing
call stack exhaustion from crafted payloads.
* fix(browser): normalize batch act dispatch
* fix(browser): tighten existing-session act typing
* fix(browser): preserve batch type text
* fix(browser): complete batch action execution
* test(browser): cover batch route normalization
* test(browser): cover batch interaction dispatch
* fix(browser): bound batch route action inputs
* fix(browser): harden batch interaction limits
* test(browser): cover batch security guardrails
---------
Co-authored-by: Diwakar <diwakarrankawat@gmail.com>
2026-03-13 18:10:55 -04:00
|
|
|
results: [{ ok: true }],
|
2025-12-13 20:37:56 +00:00
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
2025-12-20 03:27:12 +00:00
|
|
|
if (url.endsWith("/hooks/file-chooser")) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({ ok: true }),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
|
|
|
|
if (url.endsWith("/hooks/dialog")) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({ ok: true }),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
|
|
|
|
if (url.includes("/console?")) {
|
2025-12-13 20:37:56 +00:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
targetId: "t1",
|
2025-12-20 03:27:12 +00:00
|
|
|
messages: [],
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
|
|
|
|
if (url.endsWith("/pdf")) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
path: "/tmp/a.pdf",
|
|
|
|
|
targetId: "t1",
|
2025-12-13 20:37:56 +00:00
|
|
|
url: "https://x",
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
2025-12-20 03:27:12 +00:00
|
|
|
if (url.endsWith("/screenshot")) {
|
2025-12-13 20:37:56 +00:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
2025-12-20 03:27:12 +00:00
|
|
|
path: "/tmp/a.png",
|
2025-12-13 20:37:56 +00:00
|
|
|
targetId: "t1",
|
|
|
|
|
url: "https://x",
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
2025-12-20 03:27:12 +00:00
|
|
|
if (url.includes("/snapshot?")) {
|
2025-12-13 20:37:56 +00:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
2025-12-20 03:27:12 +00:00
|
|
|
json: async () => ({
|
|
|
|
|
ok: true,
|
|
|
|
|
format: "aria",
|
|
|
|
|
targetId: "t1",
|
|
|
|
|
url: "https://x",
|
|
|
|
|
nodes: [],
|
|
|
|
|
}),
|
2025-12-13 20:37:56 +00:00
|
|
|
} as unknown as Response;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
json: async () => ({
|
|
|
|
|
enabled: true,
|
|
|
|
|
running: true,
|
|
|
|
|
pid: 1,
|
|
|
|
|
cdpPort: 18792,
|
2026-01-01 22:44:52 +01:00
|
|
|
cdpUrl: "http://127.0.0.1:18792",
|
2025-12-13 20:37:56 +00:00
|
|
|
chosenBrowser: "chrome",
|
|
|
|
|
userDataDir: "/tmp",
|
|
|
|
|
color: "#FF4500",
|
|
|
|
|
headless: false,
|
2026-01-01 22:44:52 +01:00
|
|
|
noSandbox: false,
|
|
|
|
|
executablePath: null,
|
2025-12-13 20:37:56 +00:00
|
|
|
attachOnly: false,
|
|
|
|
|
}),
|
|
|
|
|
} as unknown as Response;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
await expect(browserStatus("http://127.0.0.1:18791")).resolves.toMatchObject({
|
2025-12-13 20:37:56 +00:00
|
|
|
running: true,
|
|
|
|
|
cdpPort: 18792,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength(1);
|
2025-12-13 20:37:56 +00:00
|
|
|
await expect(
|
|
|
|
|
browserOpenTab("http://127.0.0.1:18791", "https://example.com"),
|
|
|
|
|
).resolves.toMatchObject({ targetId: "t2" });
|
|
|
|
|
|
|
|
|
|
await expect(
|
2025-12-20 03:27:12 +00:00
|
|
|
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
|
|
|
|
).resolves.toMatchObject({ ok: true, format: "aria" });
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
browserNavigate("http://127.0.0.1:18791", { url: "https://example.com" }),
|
|
|
|
|
).resolves.toMatchObject({ ok: true, targetId: "t1" });
|
2025-12-13 20:37:56 +00:00
|
|
|
await expect(
|
2025-12-20 03:27:12 +00:00
|
|
|
browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }),
|
fix(browser): normalize batch act dispatch for selector and batch support (#45457)
* feat(browser): add batch actions, CSS selector support, and click delayMs
Adds three improvements to the browser act tool:
1. CSS selector support: All element-targeting actions (click, type,
hover, drag, scrollIntoView, select) now accept an optional
'selector' parameter alongside 'ref'. When selector is provided,
Playwright's page.locator() is used directly, skipping the need
for a snapshot to obtain refs. This reduces roundtrips for agents
that already know the DOM structure.
2. Click delay (delayMs): The click action now accepts an optional
'delayMs' parameter. When set, the element is hovered first, then
after the specified delay, clicked. This enables human-like
hover-before-click in a single tool call instead of three
(hover + wait + click).
3. Batch actions: New 'batch' action kind that accepts an array of
actions to execute sequentially in a single tool call. Supports
'stopOnError' (default true) to control whether execution halts
on first failure. Results are returned as an array. This eliminates
the AI inference roundtrip between each action, dramatically
reducing latency and token cost for multi-step flows.
Addresses: #44431, #38844
* fix(browser): address security review — batch evaluateEnabled guard, input validation, recursion limit
Fixes all 4 issues raised by Greptile review:
1. Security: batch actions now respect evaluateEnabled flag.
executeSingleAction and batchViaPlaywright accept evaluateEnabled
param. evaluate and wait-with-fn inside batches are rejected
when evaluateEnabled=false, matching the direct route guards.
2. Security: batch input validation. Each action in body.actions
is validated as a plain object with a known kind string before
dispatch. Applies same normalization as direct action handlers.
3. Perf: SELECTOR_ALLOWED_KINDS moved to module scope as a
ReadonlySet<string> constant (was re-created on every request).
4. Security: max batch nesting depth of 5. Nested batch actions
track depth and throw if MAX_BATCH_DEPTH exceeded, preventing
call stack exhaustion from crafted payloads.
* fix(browser): normalize batch act dispatch
* fix(browser): tighten existing-session act typing
* fix(browser): preserve batch type text
* fix(browser): complete batch action execution
* test(browser): cover batch route normalization
* test(browser): cover batch interaction dispatch
* fix(browser): bound batch route action inputs
* fix(browser): harden batch interaction limits
* test(browser): cover batch security guardrails
---------
Co-authored-by: Diwakar <diwakarrankawat@gmail.com>
2026-03-13 18:10:55 -04:00
|
|
|
).resolves.toMatchObject({ ok: true, targetId: "t1", results: [{ ok: true }] });
|
2025-12-20 03:27:12 +00:00
|
|
|
await expect(
|
|
|
|
|
browserArmFileChooser("http://127.0.0.1:18791", {
|
|
|
|
|
paths: ["/tmp/a.txt"],
|
|
|
|
|
}),
|
2025-12-13 20:37:56 +00:00
|
|
|
).resolves.toMatchObject({ ok: true });
|
|
|
|
|
await expect(
|
2025-12-20 03:27:12 +00:00
|
|
|
browserArmDialog("http://127.0.0.1:18791", { accept: true }),
|
2025-12-13 20:37:56 +00:00
|
|
|
).resolves.toMatchObject({ ok: true });
|
|
|
|
|
await expect(
|
2025-12-20 03:27:12 +00:00
|
|
|
browserConsoleMessages("http://127.0.0.1:18791", { level: "error" }),
|
|
|
|
|
).resolves.toMatchObject({ ok: true, targetId: "t1" });
|
2026-01-14 14:31:43 +00:00
|
|
|
await expect(browserPdfSave("http://127.0.0.1:18791")).resolves.toMatchObject({
|
|
|
|
|
ok: true,
|
|
|
|
|
path: "/tmp/a.pdf",
|
|
|
|
|
});
|
2025-12-20 03:27:12 +00:00
|
|
|
await expect(
|
|
|
|
|
browserScreenshotAction("http://127.0.0.1:18791", { fullPage: true }),
|
|
|
|
|
).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" });
|
2025-12-13 20:37:56 +00:00
|
|
|
|
|
|
|
|
expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true);
|
|
|
|
|
const open = calls.find((c) => c.url.endsWith("/tabs/open"));
|
|
|
|
|
expect(open?.init?.method).toBe("POST");
|
2025-12-20 03:27:12 +00:00
|
|
|
|
|
|
|
|
const screenshot = calls.find((c) => c.url.endsWith("/screenshot"));
|
|
|
|
|
expect(screenshot?.init?.method).toBe("POST");
|
2025-12-13 20:37:56 +00:00
|
|
|
});
|
2025-12-13 17:37:00 +00:00
|
|
|
});
|