mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(emulate): add mobile device emulation via CDP Emulation domain (#60)
* feat(emulate): add mobile device emulation via CDP Emulation domain Add `bsk emulate` so agents can debug mobile page behaviour by emulating a device environment on a session tab: - Viewport metrics (Emulation.setDeviceMetricsOverride), User-Agent (Emulation.setUserAgentOverride, optional accept-language and UA client-hints metadata), and touch (Emulation.setTouchEmulationEnabled with maxTouchPoints). - Seven CLI-side device presets (iphone-14, iphone-14-pro-max, iphone-se, pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8) via --device; manual flags (--width/--height/--dpr/--mobile/--ua/ --accept-language/--touch/--max-touch-points) work standalone or override individual preset fields. - `bsk emulate --off` clears every override (clearDeviceMetricsOverride + touch disabled + empty UA override) and restores the real environment. - Overrides are per-tab (CDP per-target) and not inherited by new tabs; the result JSON carries a note reminding agents to re-apply. Wire: new tool.emulate method end-to-end (bsk-protocol types + JSON schemas + Method::ToolEmulate classified BrowserMutation, daemon forwarding, CLI subcommand, extension tool handler + ChromiumCdp Emulation wrappers, dispatcher registration), docs in both SKILL.md copies, and unit tests on every layer. * refine(emulate): merge per-tab state and align preset override semantics - extension: remember the applied emulation state per tab and merge each new request onto it field by field before applying, so a later --width/--height no longer resets an earlier preset's dpr/mobile; --off clears the stored state along with the CDP overrides - extension: cdp_failed errors now note that already-applied overrides were not rolled back and can be reset with --off - cli: validate width/height pairing and metric requirements on the merged preset+flags result, so --device iphone-14 --width 500 keeps the preset height - cli: add --no-mobile/--no-touch (conflicting with --mobile/--touch) to turn preset mobile/touch fields back off - docs: describe the merge behaviour, --no-mobile/--no-touch and --off state clearing in both SKILL.md copies
This commit is contained in:
@@ -85,6 +85,23 @@ export const CDP_PROTOCOL_VERSION = "1.3";
|
||||
/** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */
|
||||
export type DialogCursor = number;
|
||||
|
||||
/** CDP `Emulation.setDeviceMetricsOverride` payload. */
|
||||
export interface DeviceMetricsOverride {
|
||||
width: number;
|
||||
height: number;
|
||||
/** `0` asks Chrome to use the display's native factor. */
|
||||
deviceScaleFactor: number;
|
||||
mobile: boolean;
|
||||
}
|
||||
|
||||
/** CDP `Emulation.setUserAgentOverride` payload. */
|
||||
export interface UserAgentOverride {
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
/** CDP `Emulation.UserAgentMetadata` (already camelCase). */
|
||||
userAgentMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_DIALOG_BUFFER = 32;
|
||||
const MAX_DIALOG_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_BUFFER = 200;
|
||||
@@ -194,6 +211,33 @@ export class ChromiumCdp {
|
||||
return this.dialogSequences.get(tabId) ?? 0;
|
||||
}
|
||||
|
||||
/** `Emulation.setDeviceMetricsOverride` — pin the tab's viewport metrics. */
|
||||
async setDeviceMetricsOverride(tabId: number, metrics: DeviceMetricsOverride): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setDeviceMetricsOverride", { ...metrics });
|
||||
}
|
||||
|
||||
/** `Emulation.clearDeviceMetricsOverride` — restore native viewport metrics. */
|
||||
async clearDeviceMetricsOverride(tabId: number): Promise<void> {
|
||||
await this.send(tabId, "Emulation.clearDeviceMetricsOverride", {});
|
||||
}
|
||||
|
||||
/** `Emulation.setUserAgentOverride` — an empty `userAgent` restores the real UA. */
|
||||
async setUserAgentOverride(tabId: number, override: UserAgentOverride): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setUserAgentOverride", { ...override });
|
||||
}
|
||||
|
||||
/** `Emulation.setTouchEmulationEnabled`. */
|
||||
async setTouchEmulationEnabled(
|
||||
tabId: number,
|
||||
enabled: boolean,
|
||||
maxTouchPoints?: number,
|
||||
): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setTouchEmulationEnabled", {
|
||||
enabled,
|
||||
...(maxTouchPoints !== undefined ? { maxTouchPoints } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Dialogs observed on `tabId` with sequence strictly greater than `cursor`. */
|
||||
dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] {
|
||||
const buf = this.dialogBuffers.get(tabId) ?? [];
|
||||
|
||||
@@ -124,6 +124,10 @@ describe("ToolDispatcher", () => {
|
||||
truncated: false,
|
||||
}) satisfies ConsoleResult,
|
||||
),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||
dispatcher.start();
|
||||
@@ -163,6 +167,10 @@ describe("ToolDispatcher", () => {
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||
dispatcher.start();
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DeviceMetricsOverride, UserAgentOverride } from "@/browser-driver/chromium-cdp";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { EmulateOverrides, RpcError } from "@/transport/types";
|
||||
import {
|
||||
EMULATE_SCOPE_NOTE,
|
||||
type EmulateCdpRunner,
|
||||
handleEmulate,
|
||||
resetEmulateStatesForTests,
|
||||
toCdpUserAgentMetadata,
|
||||
validateOverrides,
|
||||
} from "../emulate";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface CdpCalls {
|
||||
metrics: Array<{ tabId: number; metrics: DeviceMetricsOverride }>;
|
||||
clearedMetrics: number[];
|
||||
ua: Array<{ tabId: number; override: UserAgentOverride }>;
|
||||
touch: Array<{ tabId: number; enabled: boolean; maxTouchPoints?: number }>;
|
||||
}
|
||||
|
||||
function makeDeps(opts: { throwOn?: string; tabWindowId?: number } = {}) {
|
||||
const calls: CdpCalls = { metrics: [], clearedMetrics: [], ua: [], touch: [] };
|
||||
const maybeThrow = (kind: string) => {
|
||||
if (opts.throwOn === kind) throw new Error(`simulated ${kind} failure`);
|
||||
};
|
||||
const cdp: EmulateCdpRunner = {
|
||||
setDeviceMetricsOverride: vi.fn(async (tabId, metrics) => {
|
||||
maybeThrow("metrics");
|
||||
calls.metrics.push({ tabId, metrics });
|
||||
}),
|
||||
clearDeviceMetricsOverride: vi.fn(async (tabId) => {
|
||||
maybeThrow("metrics");
|
||||
calls.clearedMetrics.push(tabId);
|
||||
}),
|
||||
setUserAgentOverride: vi.fn(async (tabId, override) => {
|
||||
maybeThrow("ua");
|
||||
calls.ua.push({ tabId, override });
|
||||
}),
|
||||
setTouchEmulationEnabled: vi.fn(async (tabId, enabled, maxTouchPoints) => {
|
||||
maybeThrow("touch");
|
||||
calls.touch.push({ tabId, enabled, maxTouchPoints });
|
||||
}),
|
||||
trackSessionTab: vi.fn(),
|
||||
};
|
||||
const tabsApi = {
|
||||
get: vi.fn(
|
||||
async (tabId: number) =>
|
||||
({ id: tabId, windowId: opts.tabWindowId ?? 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query: vi.fn(async () => [
|
||||
{ id: 7, windowId: opts.tabWindowId ?? 100, active: true } as chrome.tabs.Tab,
|
||||
]),
|
||||
};
|
||||
return { cdp, tabsApi, calls };
|
||||
}
|
||||
|
||||
async function makeManager(): Promise<SessionManager> {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
return sm;
|
||||
}
|
||||
|
||||
const fullOverrides: EmulateOverrides = {
|
||||
width: 390,
|
||||
height: 844,
|
||||
device_scale_factor: 3,
|
||||
mobile: true,
|
||||
user_agent: "Mozilla/5.0 (iPhone)",
|
||||
accept_language: "zh-CN",
|
||||
touch: true,
|
||||
max_touch_points: 5,
|
||||
};
|
||||
|
||||
describe("handleEmulate", () => {
|
||||
beforeEach(() => {
|
||||
resetEmulateStatesForTests();
|
||||
});
|
||||
|
||||
it("applies viewport, UA and touch overrides to the active tab", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
expect(result).toEqual({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: fullOverrides,
|
||||
note: EMULATE_SCOPE_NOTE,
|
||||
});
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 390, height: 844, deviceScaleFactor: 3, mobile: true } },
|
||||
]);
|
||||
expect(deps.calls.ua).toEqual([
|
||||
{
|
||||
tabId: 7,
|
||||
override: { userAgent: "Mozilla/5.0 (iPhone)", acceptLanguage: "zh-CN" },
|
||||
},
|
||||
]);
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: true, maxTouchPoints: 5 }]);
|
||||
expect(deps.cdp.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||
});
|
||||
|
||||
it("defaults deviceScaleFactor to 0 and mobile to false when omitted", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 800, height: 1280 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 800, height: 1280, deviceScaleFactor: 0, mobile: false } },
|
||||
]);
|
||||
expect(deps.calls.ua).toEqual([]);
|
||||
expect(deps.calls.touch).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies a UA-only override", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.metrics).toEqual([]);
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 7, override: { userAgent: "custom-ua" } }]);
|
||||
});
|
||||
|
||||
it("converts user_agent_metadata to CDP camelCase", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
overrides: {
|
||||
user_agent: "custom-ua",
|
||||
user_agent_metadata: {
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
full_version: "120.0.0.0",
|
||||
platform: "Android",
|
||||
platform_version: "14",
|
||||
mobile: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7 });
|
||||
expect(deps.calls.ua[0].override.userAgentMetadata).toEqual({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
fullVersion: "120.0.0.0",
|
||||
platform: "Android",
|
||||
platformVersion: "14",
|
||||
mobile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("max_touch_points without touch enables touch emulation", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { max_touch_points: 5 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: true, maxTouchPoints: 5 }]);
|
||||
});
|
||||
|
||||
it("touch: false only disables touch emulation", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { touch: false } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: false, maxTouchPoints: undefined }]);
|
||||
});
|
||||
|
||||
it("targets an explicit tab_id", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", tab_id: 9, overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 9 });
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 9, override: { userAgent: "custom-ua" } }]);
|
||||
});
|
||||
|
||||
it("off clears metrics, touch and UA overrides", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
expect(result).toEqual({ tab_id: 7, cleared: true, note: EMULATE_SCOPE_NOTE });
|
||||
expect(deps.calls.clearedMetrics).toEqual([7]);
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: false, maxTouchPoints: undefined }]);
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 7, override: { userAgent: "" } }]);
|
||||
});
|
||||
|
||||
it("rejects off combined with overrides", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", off: true, overrides: { touch: true } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ code: "invalid_params" });
|
||||
expect(deps.calls.clearedMetrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects missing overrides and missing off", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11" }, deps);
|
||||
expect(result).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects an unknown session", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "zz99", off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
it("rejects a tab outside the Agent Window", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ tabWindowId: 200 });
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", tab_id: 9, off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "permission_denied" });
|
||||
expect(deps.calls.clearedMetrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges onto the tab's stored state: later width/height keep the earlier dpr/mobile", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: { width: 500, height: 900, device_scale_factor: 3, mobile: true },
|
||||
});
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 390, height: 844, deviceScaleFactor: 3, mobile: true } },
|
||||
{ tabId: 7, metrics: { width: 500, height: 900, deviceScaleFactor: 3, mobile: true } },
|
||||
]);
|
||||
// The merged state is applied as a whole: UA and touch are re-applied too.
|
||||
expect(deps.calls.ua).toHaveLength(2);
|
||||
expect(deps.calls.touch).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps emulation state per tab", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", tab_id: 9, overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
// Tab 9 has no stored state, so dpr/mobile fall back to the CDP defaults.
|
||||
expect(deps.calls.metrics.at(-1)).toEqual({
|
||||
tabId: 9,
|
||||
metrics: { width: 500, height: 900, deviceScaleFactor: 0, mobile: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("off clears the stored state: a later request starts from scratch", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: { width: 500, height: 900 },
|
||||
note: EMULATE_SCOPE_NOTE,
|
||||
});
|
||||
// dpr/mobile fell back to the CDP defaults instead of the cleared values,
|
||||
// and the cleared UA/touch were not re-applied.
|
||||
expect(deps.calls.metrics.at(-1)).toEqual({
|
||||
tabId: 7,
|
||||
metrics: { width: 500, height: 900, deviceScaleFactor: 0, mobile: false },
|
||||
});
|
||||
expect(deps.calls.ua).toHaveLength(2); // preset + the clearing empty UA
|
||||
expect(deps.calls.touch).toHaveLength(2); // preset + the disabling call
|
||||
});
|
||||
|
||||
it("maps CDP failures to cdp_failed and points at --off", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ throwOn: "ua" });
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ code: "cdp_failed" });
|
||||
const message = (result as RpcError).message;
|
||||
expect(message).toContain("simulated ua failure");
|
||||
expect(message).toContain("not rolled back");
|
||||
expect(message).toContain("--off");
|
||||
});
|
||||
|
||||
it("reports an off-branch CDP failure with the same not-rolled-back note", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ throwOn: "metrics" });
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "cdp_failed" });
|
||||
const message = (result as RpcError).message;
|
||||
expect(message).toContain("simulated metrics failure");
|
||||
expect(message).toContain("not rolled back");
|
||||
expect(message).toContain("--off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOverrides", () => {
|
||||
it("accepts a full override set", () => {
|
||||
expect(validateOverrides(fullOverrides)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects width without height and vice versa", () => {
|
||||
expect(validateOverrides({ width: 390 })).toMatchObject({ code: "invalid_params" });
|
||||
expect(validateOverrides({ height: 844 })).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects non-positive or non-integer dimensions", () => {
|
||||
expect(validateOverrides({ width: 0, height: 844 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ width: 390.5, height: 844 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects metrics extras without dimensions", () => {
|
||||
expect(validateOverrides({ device_scale_factor: 3 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ mobile: true })).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects a non-positive device_scale_factor", () => {
|
||||
expect(validateOverrides({ width: 390, height: 844, device_scale_factor: 0 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(
|
||||
validateOverrides({ width: 390, height: 844, device_scale_factor: Number.NaN }),
|
||||
).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects accept_language / user_agent_metadata without user_agent", () => {
|
||||
expect(validateOverrides({ accept_language: "zh-CN" })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ user_agent_metadata: { platform: "Android" } })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid max_touch_points", () => {
|
||||
expect(validateOverrides({ touch: true, max_touch_points: 0 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an empty override set", () => {
|
||||
expect(validateOverrides({})).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCdpUserAgentMetadata", () => {
|
||||
it("maps snake_case fields to camelCase and drops absent ones", () => {
|
||||
expect(
|
||||
toCdpUserAgentMetadata({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
platform: "Android",
|
||||
mobile: true,
|
||||
}),
|
||||
).toEqual({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
platform: "Android",
|
||||
mobile: true,
|
||||
});
|
||||
expect(toCdpUserAgentMetadata({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
|
||||
import type {
|
||||
ClickParams,
|
||||
ConsoleParams,
|
||||
EmulateParams,
|
||||
EvaluateParams,
|
||||
FillParams,
|
||||
GetHtmlParams,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
} from "@/transport/types";
|
||||
import { isRequestFrame } from "@/transport/types";
|
||||
import { handleConsole } from "./console";
|
||||
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
||||
import { handleEvaluate } from "./evaluate";
|
||||
import { handleRequestHelp } from "./human-loop";
|
||||
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
|
||||
@@ -74,7 +76,8 @@ import { handleWaitForNavigation } from "./waits";
|
||||
import { handleWindowResize, type WindowResizeParams } from "./window";
|
||||
|
||||
type DispatcherCdpRunner = CdpRunner &
|
||||
NetworkCdpRunner & {
|
||||
NetworkCdpRunner &
|
||||
EmulateCdpRunner & {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -289,6 +292,12 @@ export class ToolDispatcher {
|
||||
return handleTabReturn(this.sessions, req.params as TabReturnParams);
|
||||
case "tool.window_resize":
|
||||
return handleWindowResize(this.sessions, req.params as WindowResizeParams);
|
||||
case "tool.emulate":
|
||||
return handleEmulate(
|
||||
this.sessions,
|
||||
req.params as EmulateParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.screenshot":
|
||||
return handleScreenshot(
|
||||
this.sessions,
|
||||
@@ -482,6 +491,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
||||
case "tool.tab_borrow":
|
||||
case "tool.tab_return":
|
||||
case "tool.window_resize":
|
||||
case "tool.emulate":
|
||||
case "tool.navigate":
|
||||
case "tool.navigate_back":
|
||||
case "tool.navigate_forward":
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// `tool.emulate` — apply or clear CDP Emulation-domain overrides
|
||||
// (viewport metrics, user agent, touch) on a session tab, so agents can
|
||||
// debug mobile page behaviour. Overrides are per-target: new tabs do
|
||||
// not inherit them, and `off` restores the tab's real environment.
|
||||
//
|
||||
// Device presets are resolved CLI-side; this handler only executes the
|
||||
// concrete override parameters it receives. Each request is merged
|
||||
// field by field onto the tab's remembered emulation state and the
|
||||
// merged state is applied as a whole, so a partial request does not
|
||||
// reset fields set by an earlier one.
|
||||
|
||||
import type { DeviceMetricsOverride, UserAgentOverride } from "@/browser-driver/chromium-cdp";
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type {
|
||||
EmulateOverrides,
|
||||
EmulateParams,
|
||||
EmulateResult,
|
||||
RpcError,
|
||||
UserAgentMetadata,
|
||||
} from "@/transport/types";
|
||||
import {
|
||||
type ChromeTabsApi,
|
||||
chromeTabsApi,
|
||||
enforceAgentWindow,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* Emulation-domain surface the handler needs. Backed by `ChromiumCdp`
|
||||
* in production; tests inject a fake. `trackSessionTab` is optional so
|
||||
* test doubles need not implement it.
|
||||
*/
|
||||
export interface EmulateCdpRunner {
|
||||
setDeviceMetricsOverride(tabId: number, metrics: DeviceMetricsOverride): Promise<void>;
|
||||
clearDeviceMetricsOverride(tabId: number): Promise<void>;
|
||||
setUserAgentOverride(tabId: number, override: UserAgentOverride): Promise<void>;
|
||||
setTouchEmulationEnabled(tabId: number, enabled: boolean, maxTouchPoints?: number): Promise<void>;
|
||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||
}
|
||||
|
||||
export interface EmulateDeps {
|
||||
cdp: EmulateCdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
}
|
||||
|
||||
function defaultEmulateDeps(): EmulateDeps {
|
||||
return {
|
||||
cdp: new ChromiumCdp(),
|
||||
tabsApi: chromeTabsApi,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result note explaining the per-target scope of CDP emulation
|
||||
* overrides, echoed on every successful call so agents know to
|
||||
* re-apply emulation after opening a new tab.
|
||||
*/
|
||||
export const EMULATE_SCOPE_NOTE =
|
||||
"emulation overrides are per-tab (CDP target): new tabs do not inherit them — re-run `bsk emulate` on each new tab";
|
||||
|
||||
/** Convert the wire (snake_case) UA metadata to CDP's camelCase shape. */
|
||||
export function toCdpUserAgentMetadata(meta: UserAgentMetadata): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (meta.brands !== undefined) out.brands = meta.brands;
|
||||
if (meta.full_version !== undefined) out.fullVersion = meta.full_version;
|
||||
if (meta.platform !== undefined) out.platform = meta.platform;
|
||||
if (meta.platform_version !== undefined) out.platformVersion = meta.platform_version;
|
||||
if (meta.architecture !== undefined) out.architecture = meta.architecture;
|
||||
if (meta.model !== undefined) out.model = meta.model;
|
||||
if (meta.mobile !== undefined) out.mobile = meta.mobile;
|
||||
return out;
|
||||
}
|
||||
|
||||
function invalidParams(message: string): RpcError {
|
||||
return { code: "invalid_params", message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab record of the emulation state applied so far. New requests
|
||||
* are merged onto it field by field (see `mergeEmulateOverrides`) and
|
||||
* the merged state is applied as a whole, so e.g. a later
|
||||
* `--width/--height` does not silently reset the dpr/mobile of an
|
||||
* earlier `--device` preset.
|
||||
*
|
||||
* The record is best-effort: an entry left behind by a closed tab is
|
||||
* simply overwritten by the next emulate call that targets the same tab
|
||||
* id, and the whole map is lost when the extension service worker
|
||||
* reloads — after a reload the next emulate is equivalent to a full
|
||||
* (re)set of just the fields it carries.
|
||||
*/
|
||||
const tabEmulationStates = new Map<number, EmulateOverrides>();
|
||||
|
||||
/** Test hook: drop every per-tab emulation state record. */
|
||||
export function resetEmulateStatesForTests(): void {
|
||||
tabEmulationStates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one validated request onto the tab's stored state: fields
|
||||
* present in `overrides` overwrite the stored value, absent fields keep
|
||||
* it. Returns a fresh object; `stored` is not mutated.
|
||||
*/
|
||||
export function mergeEmulateOverrides(
|
||||
stored: EmulateOverrides | undefined,
|
||||
overrides: EmulateOverrides,
|
||||
): EmulateOverrides {
|
||||
const merged: EmulateOverrides = { ...stored };
|
||||
if (overrides.width !== undefined) merged.width = overrides.width;
|
||||
if (overrides.height !== undefined) merged.height = overrides.height;
|
||||
if (overrides.device_scale_factor !== undefined) {
|
||||
merged.device_scale_factor = overrides.device_scale_factor;
|
||||
}
|
||||
if (overrides.mobile !== undefined) merged.mobile = overrides.mobile;
|
||||
if (overrides.user_agent !== undefined) merged.user_agent = overrides.user_agent;
|
||||
if (overrides.accept_language !== undefined) {
|
||||
merged.accept_language = overrides.accept_language;
|
||||
}
|
||||
if (overrides.user_agent_metadata !== undefined) {
|
||||
merged.user_agent_metadata = overrides.user_agent_metadata;
|
||||
}
|
||||
if (overrides.touch !== undefined) merged.touch = overrides.touch;
|
||||
if (overrides.max_touch_points !== undefined) {
|
||||
merged.max_touch_points = overrides.max_touch_points;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* CDP calls run in sequence without rollback: on a mid-way failure the
|
||||
* overrides already applied stay in effect, so the error says how to
|
||||
* reset them.
|
||||
*/
|
||||
function cdpFailed(err: unknown): RpcError {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: `${cause} (overrides applied before the failure were not rolled back — use --off to reset)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-field validation for one overrides set. The CLI performs the
|
||||
* same checks; the extension re-validates because the wire format is
|
||||
* public.
|
||||
*/
|
||||
export function validateOverrides(overrides: EmulateOverrides): RpcError | null {
|
||||
const hasWidth = overrides.width !== undefined;
|
||||
const hasHeight = overrides.height !== undefined;
|
||||
if (hasWidth !== hasHeight) {
|
||||
return invalidParams("emulate overrides require width and height together");
|
||||
}
|
||||
if (hasWidth) {
|
||||
if (!Number.isSafeInteger(overrides.width) || (overrides.width as number) <= 0) {
|
||||
return invalidParams("width must be a positive integer");
|
||||
}
|
||||
if (!Number.isSafeInteger(overrides.height) || (overrides.height as number) <= 0) {
|
||||
return invalidParams("height must be a positive integer");
|
||||
}
|
||||
}
|
||||
if (
|
||||
(overrides.device_scale_factor !== undefined || overrides.mobile !== undefined) &&
|
||||
!hasWidth
|
||||
) {
|
||||
return invalidParams("device_scale_factor and mobile require width and height");
|
||||
}
|
||||
if (
|
||||
overrides.device_scale_factor !== undefined &&
|
||||
(!Number.isFinite(overrides.device_scale_factor) || overrides.device_scale_factor <= 0)
|
||||
) {
|
||||
return invalidParams("device_scale_factor must be a positive finite number");
|
||||
}
|
||||
if (
|
||||
(overrides.accept_language !== undefined || overrides.user_agent_metadata !== undefined) &&
|
||||
overrides.user_agent === undefined
|
||||
) {
|
||||
return invalidParams("accept_language and user_agent_metadata require user_agent");
|
||||
}
|
||||
if (
|
||||
overrides.max_touch_points !== undefined &&
|
||||
(!Number.isSafeInteger(overrides.max_touch_points) || overrides.max_touch_points < 1)
|
||||
) {
|
||||
return invalidParams("max_touch_points must be a positive integer");
|
||||
}
|
||||
const anyOverride =
|
||||
hasWidth ||
|
||||
overrides.user_agent !== undefined ||
|
||||
overrides.touch !== undefined ||
|
||||
overrides.max_touch_points !== undefined;
|
||||
if (!anyOverride) {
|
||||
return invalidParams(
|
||||
"emulate requires at least one override (viewport, user agent, or touch) or off: true",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for `tool.emulate` (called by the daemon over WS).
|
||||
*
|
||||
* Merges the requested overrides onto the tab's remembered state and
|
||||
* applies the merged state in a fixed order — viewport metrics, then
|
||||
* UA, then touch — or clears everything (state included) when `off` is
|
||||
* set. Raw CDP failures surface as `cdp_failed` (§4.5).
|
||||
*/
|
||||
export async function handleEmulate(
|
||||
manager: SessionManager,
|
||||
params: EmulateParams,
|
||||
deps: EmulateDeps = defaultEmulateDeps(),
|
||||
): Promise<EmulateResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "emulate");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
const target = await resolveTargetTab(manager, ctx, params?.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
const denied = enforceAgentWindow(ctx, target, "emulate");
|
||||
if (denied) return denied;
|
||||
|
||||
if (params.off === true) {
|
||||
if (params.overrides !== undefined) {
|
||||
return invalidParams("emulate off cannot be combined with overrides");
|
||||
}
|
||||
try {
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
await deps.cdp.clearDeviceMetricsOverride(target.tabId);
|
||||
await deps.cdp.setTouchEmulationEnabled(target.tabId, false);
|
||||
// An empty userAgent string clears the override (CDP convention).
|
||||
await deps.cdp.setUserAgentOverride(target.tabId, { userAgent: "" });
|
||||
} catch (err) {
|
||||
return cdpFailed(err);
|
||||
}
|
||||
tabEmulationStates.delete(target.tabId);
|
||||
return { tab_id: target.tabId, cleared: true, note: EMULATE_SCOPE_NOTE };
|
||||
}
|
||||
|
||||
const overrides = params.overrides;
|
||||
if (!overrides) {
|
||||
return invalidParams("emulate requires overrides or off: true");
|
||||
}
|
||||
const invalid = validateOverrides(overrides);
|
||||
if (invalid) return invalid;
|
||||
|
||||
// Fields absent from this request keep their previously applied
|
||||
// values; the merged state is what gets applied (and echoed back).
|
||||
const merged = mergeEmulateOverrides(tabEmulationStates.get(target.tabId), overrides);
|
||||
try {
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
if (merged.width !== undefined && merged.height !== undefined) {
|
||||
await deps.cdp.setDeviceMetricsOverride(target.tabId, {
|
||||
width: merged.width,
|
||||
height: merged.height,
|
||||
deviceScaleFactor: merged.device_scale_factor ?? 0,
|
||||
mobile: merged.mobile ?? false,
|
||||
});
|
||||
}
|
||||
if (merged.user_agent !== undefined) {
|
||||
await deps.cdp.setUserAgentOverride(target.tabId, {
|
||||
userAgent: merged.user_agent,
|
||||
...(merged.accept_language !== undefined ? { acceptLanguage: merged.accept_language } : {}),
|
||||
...(merged.user_agent_metadata !== undefined
|
||||
? { userAgentMetadata: toCdpUserAgentMetadata(merged.user_agent_metadata) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (merged.touch !== undefined || merged.max_touch_points !== undefined) {
|
||||
await deps.cdp.setTouchEmulationEnabled(
|
||||
target.tabId,
|
||||
merged.touch ?? true,
|
||||
merged.max_touch_points,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
return cdpFailed(err);
|
||||
}
|
||||
// Record the merged state only once it was fully applied.
|
||||
tabEmulationStates.set(target.tabId, merged);
|
||||
return {
|
||||
tab_id: target.tabId,
|
||||
cleared: false,
|
||||
applied: merged,
|
||||
note: EMULATE_SCOPE_NOTE,
|
||||
};
|
||||
}
|
||||
@@ -565,6 +565,63 @@ export interface RequestHelpResult {
|
||||
resolved_targets?: ResolvedTarget[];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Device-emulation payloads — tool.emulate (mirror bsk-protocol emulate.rs)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface UserAgentBrandVersion {
|
||||
brand: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** Mirror of CDP `Emulation.UserAgentMetadata`; all fields optional. */
|
||||
export interface UserAgentMetadata {
|
||||
brands?: UserAgentBrandVersion[];
|
||||
full_version?: string;
|
||||
platform?: string;
|
||||
platform_version?: string;
|
||||
architecture?: string;
|
||||
model?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete emulation overrides for one tab. The extension merges each
|
||||
* request field by field onto the tab's remembered emulation state:
|
||||
* fields present here overwrite the stored value, absent fields keep
|
||||
* it, and the merged state is applied as a whole.
|
||||
*/
|
||||
export interface EmulateOverrides {
|
||||
width?: number;
|
||||
height?: number;
|
||||
device_scale_factor?: number;
|
||||
mobile?: boolean;
|
||||
user_agent?: string;
|
||||
accept_language?: string;
|
||||
user_agent_metadata?: UserAgentMetadata;
|
||||
touch?: boolean;
|
||||
max_touch_points?: number;
|
||||
}
|
||||
|
||||
export interface EmulateParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
/** Clear every emulation override on the tab. Exclusive with `overrides`. */
|
||||
off?: boolean;
|
||||
/** Overrides to apply. Required unless `off` is set. */
|
||||
overrides?: EmulateOverrides;
|
||||
}
|
||||
|
||||
export interface EmulateResult {
|
||||
tab_id: number;
|
||||
/** True when overrides were cleared (`off`); false when applied. */
|
||||
cleared: boolean;
|
||||
/** Echo of the overrides that were applied. Absent when cleared. */
|
||||
applied?: EmulateOverrides;
|
||||
/** Scope note: overrides are per-tab (CDP target), not inherited by new tabs. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Semantic record payloads — mirror bsk-protocol record.rs (Trace v2)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user