From 0f264c5df12482457135ee877d14ae24cab0d2a5 Mon Sep 17 00:00:00 2001 From: BB-fat <45072480+BB-fat@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:10:39 +0800 Subject: [PATCH] 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 --- .../src/browser-driver/chromium-cdp.ts | 44 ++ .../src/tools/__tests__/dispatcher.test.ts | 8 + .../src/tools/__tests__/emulate.test.ts | 416 ++++++++++ apps/extension/src/tools/dispatcher.ts | 12 +- apps/extension/src/tools/emulate.ts | 285 +++++++ apps/extension/src/transport/types.ts | 57 ++ crates/bsk-cli/skill/SKILL.md | 16 + crates/bsk-cli/src/cli/emulate.rs | 728 ++++++++++++++++++ crates/bsk-cli/src/cli/mod.rs | 5 + crates/bsk-cli/src/daemon/ipc.rs | 1 + crates/bsk-cli/src/main.rs | 1 + crates/bsk-cli/tests/cli_parse.rs | 159 ++++ .../schema/tool_emulate_overrides.json | 151 ++++ .../schema/tool_emulate_params.json | 188 +++++ .../schema/tool_emulate_result.json | 186 +++++ .../tool_emulate_user_agent_metadata.json | 71 ++ crates/bsk-protocol/src/bin/dump-schema.rs | 5 + crates/bsk-protocol/src/method.rs | 11 + crates/bsk-protocol/src/tools/emulate.rs | 218 ++++++ crates/bsk-protocol/src/tools/mod.rs | 2 + skill/SKILL.md | 16 + 21 files changed, 2579 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/tools/__tests__/emulate.test.ts create mode 100644 apps/extension/src/tools/emulate.ts create mode 100644 crates/bsk-cli/src/cli/emulate.rs create mode 100644 crates/bsk-protocol/schema/tool_emulate_overrides.json create mode 100644 crates/bsk-protocol/schema/tool_emulate_params.json create mode 100644 crates/bsk-protocol/schema/tool_emulate_result.json create mode 100644 crates/bsk-protocol/schema/tool_emulate_user_agent_metadata.json create mode 100644 crates/bsk-protocol/src/tools/emulate.rs diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 3848e97..19ba733 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -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; +} + 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 { + await this.send(tabId, "Emulation.setDeviceMetricsOverride", { ...metrics }); + } + + /** `Emulation.clearDeviceMetricsOverride` — restore native viewport metrics. */ + async clearDeviceMetricsOverride(tabId: number): Promise { + await this.send(tabId, "Emulation.clearDeviceMetricsOverride", {}); + } + + /** `Emulation.setUserAgentOverride` — an empty `userAgent` restores the real UA. */ + async setUserAgentOverride(tabId: number, override: UserAgentOverride): Promise { + await this.send(tabId, "Emulation.setUserAgentOverride", { ...override }); + } + + /** `Emulation.setTouchEmulationEnabled`. */ + async setTouchEmulationEnabled( + tabId: number, + enabled: boolean, + maxTouchPoints?: number, + ): Promise { + 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) ?? []; diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index b2f00c5..060b30a 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -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(); diff --git a/apps/extension/src/tools/__tests__/emulate.test.ts b/apps/extension/src/tools/__tests__/emulate.test.ts new file mode 100644 index 0000000..37b375b --- /dev/null +++ b/apps/extension/src/tools/__tests__/emulate.test.ts @@ -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 { + 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({}); + }); +}); diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index 0aca4fd..dd419fb 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -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; }; @@ -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": diff --git a/apps/extension/src/tools/emulate.ts b/apps/extension/src/tools/emulate.ts new file mode 100644 index 0000000..06e19b6 --- /dev/null +++ b/apps/extension/src/tools/emulate.ts @@ -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; + clearDeviceMetricsOverride(tabId: number): Promise; + setUserAgentOverride(tabId: number, override: UserAgentOverride): Promise; + setTouchEmulationEnabled(tabId: number, enabled: boolean, maxTouchPoints?: number): Promise; + 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 { + const out: Record = {}; + 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(); + +/** 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 { + 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, + }; +} diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 275d4e8..a335747 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -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) // -------------------------------------------------------------------------- diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index a5ddf77..3c36961 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -134,6 +134,22 @@ Details and flags: **`bsk --help`** |---------|---------| | `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | +### Device emulation — `bsk emulate` (requires `--session `) + +Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour: + +```bash +bsk emulate --session --device iphone-14 +bsk emulate --session --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch +bsk emulate --session --off +``` + +- Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off. +- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.) +- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment. +- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides). +- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams. + ### Tabs (require `--session `) | Command | Summary | diff --git a/crates/bsk-cli/src/cli/emulate.rs b/crates/bsk-cli/src/cli/emulate.rs new file mode 100644 index 0000000..b9331fa --- /dev/null +++ b/crates/bsk-cli/src/cli/emulate.rs @@ -0,0 +1,728 @@ +//! `bsk emulate` — emulate a mobile device environment on a tab via the +//! CDP Emulation domain (viewport metrics, user agent, touch), for +//! mobile page debugging. +//! +//! Device presets are resolved here, CLI-side: the extension only +//! executes the concrete override parameters it receives, so presets are +//! not duplicated in TypeScript. + +use std::path::PathBuf; + +use anyhow::Context; +use bsk_protocol::tools::{EmulateOverrides, EmulateParams, EmulateResult}; +use bsk_protocol::{ErrorCode, Method, RpcError}; +use clap::Args; + +use crate::cli::TOOL_IPC_TIMEOUT; +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +/// One built-in device preset: viewport metrics + UA + touch applied in +/// a single `--device` invocation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DevicePreset { + pub name: &'static str, + pub width: u32, + pub height: u32, + pub device_scale_factor: f64, + pub mobile: bool, + pub user_agent: &'static str, + pub max_touch_points: u32, +} + +const IPHONE_UA: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; +const IPAD_UA: &str = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + +/// Built-in device presets for `--device`. Keep names lowercase-kebab; +/// they are the public CLI identifiers. +pub const DEVICE_PRESETS: &[DevicePreset] = &[ + DevicePreset { + name: "iphone-14", + width: 390, + height: 844, + device_scale_factor: 3.0, + mobile: true, + user_agent: IPHONE_UA, + max_touch_points: 5, + }, + DevicePreset { + name: "iphone-14-pro-max", + width: 430, + height: 932, + device_scale_factor: 3.0, + mobile: true, + user_agent: IPHONE_UA, + max_touch_points: 5, + }, + DevicePreset { + name: "iphone-se", + width: 375, + height: 667, + device_scale_factor: 2.0, + mobile: true, + user_agent: IPHONE_UA, + max_touch_points: 5, + }, + DevicePreset { + name: "pixel-7", + width: 412, + height: 915, + device_scale_factor: 2.625, + mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36", + max_touch_points: 5, + }, + DevicePreset { + name: "galaxy-s23", + width: 360, + height: 780, + device_scale_factor: 3.0, + mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36", + max_touch_points: 5, + }, + DevicePreset { + name: "ipad-mini", + width: 744, + height: 1133, + device_scale_factor: 2.0, + mobile: true, + user_agent: IPAD_UA, + max_touch_points: 5, + }, + DevicePreset { + name: "galaxy-tab-s8", + width: 800, + height: 1280, + device_scale_factor: 2.0, + mobile: true, + user_agent: "Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Safari/537.36", + max_touch_points: 5, + }, +]; + +/// Comma-separated preset names for error messages. +pub fn preset_names() -> String { + DEVICE_PRESETS + .iter() + .map(|p| p.name) + .collect::>() + .join(", ") +} + +/// Look up a preset by its CLI name. +pub fn find_preset(name: &str) -> Option<&'static DevicePreset> { + DEVICE_PRESETS.iter().find(|p| p.name == name) +} + +#[derive(Debug, Clone, Args)] +pub struct EmulateArgs { + /// Session id (must be active). + #[arg(long)] + pub session: String, + + /// Target tab. Defaults to the Agent Window's active tab. + #[arg(long = "tab-id")] + pub tab_id: Option, + + /// Built-in device preset (iphone-14, iphone-14-pro-max, iphone-se, + /// pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8). Manual flags + /// override individual preset fields. + #[arg(long)] + pub device: Option, + + /// Viewport width in CSS pixels (requires --height unless --device + /// supplies it). + #[arg(long, value_parser = viewport_dimension)] + pub width: Option, + + /// Viewport height in CSS pixels (requires --width unless --device + /// supplies it). + #[arg(long, value_parser = viewport_dimension)] + pub height: Option, + + /// Device pixel ratio (requires --width/--height without --device). + #[arg(long, value_parser = device_pixel_ratio)] + pub dpr: Option, + + /// Emulate a mobile viewport (requires --width/--height without + /// --device). + #[arg(long)] + pub mobile: bool, + + /// Turn a preset's mobile viewport flag back off. + #[arg(long, conflicts_with = "mobile")] + pub no_mobile: bool, + + /// User-Agent override string. + #[arg(long)] + pub ua: Option, + + /// Accept-Language header override (requires --ua or --device). + #[arg(long = "accept-language")] + pub accept_language: Option, + + /// Enable touch emulation (combines with --device or manual flags). + #[arg(long)] + pub touch: bool, + + /// Turn a preset's touch emulation back off. + #[arg(long, conflicts_with = "touch")] + pub no_touch: bool, + + /// Touch points reported while touch emulation is enabled (implies + /// --touch). + #[arg(long = "max-touch-points", value_parser = touch_points)] + pub max_touch_points: Option, + + /// Clear all emulation overrides and restore the tab's real + /// environment. Mutually exclusive with every other option. + #[arg(long)] + pub off: bool, +} + +/// Parse a `--width` / `--height` viewport dimension (CSS pixels). +fn viewport_dimension(s: &str) -> Result { + let value: u32 = s + .parse() + .map_err(|_| format!("invalid viewport dimension {s:?}: expected a positive integer"))?; + if (1..=7680).contains(&value) { + Ok(value) + } else { + Err(format!( + "viewport dimension {value} out of range (1..=7680)" + )) + } +} + +/// Parse a `--dpr` device pixel ratio. +fn device_pixel_ratio(s: &str) -> Result { + let value: f64 = s + .parse() + .map_err(|_| format!("invalid device pixel ratio {s:?}: expected a number"))?; + if value.is_finite() && value > 0.0 { + Ok(value) + } else { + Err(format!( + "device pixel ratio {value} out of range (must be positive and finite)" + )) + } +} + +/// Parse a `--max-touch-points` value. +fn touch_points(s: &str) -> Result { + let value: u32 = s + .parse() + .map_err(|_| format!("invalid max touch points {s:?}: expected a positive integer"))?; + if value >= 1 { + Ok(value) + } else { + Err("max touch points must be at least 1".to_string()) + } +} + +fn invalid_params(message: &str) -> CliError { + CliError::from_rpc(RpcError { + code: ErrorCode::InvalidParams, + message: message.to_string(), + data: None, + }) +} + +/// Validate the argument combination and build the wire params. Pure so +/// it can be unit-tested without a daemon. +/// +/// The preset (if any) forms the base, manual flags override individual +/// fields, and only the merged result is validated — so a preset can +/// supply the dimension a lone `--width`/`--height` lacks. +pub fn build_params(args: &EmulateArgs) -> Result { + let has_manual = args.width.is_some() + || args.height.is_some() + || args.dpr.is_some() + || args.mobile + || args.no_mobile + || args.ua.is_some() + || args.accept_language.is_some() + || args.touch + || args.no_touch + || args.max_touch_points.is_some(); + + if args.off { + if args.device.is_some() || has_manual { + return Err(invalid_params( + "--off cannot be combined with --device or manual emulation options", + )); + } + return Ok(EmulateParams { + session_id: args.session.clone(), + tab_id: args.tab_id, + off: Some(true), + overrides: None, + }); + } + + if args.device.is_none() && !has_manual { + return Err(invalid_params(&format!( + "nothing to do: pass --device ({}), manual options (--width/--height/--dpr/--mobile/--ua/--touch), or --off", + preset_names() + ))); + } + + let mut overrides = match args.device.as_deref() { + Some(name) => { + let preset = find_preset(name).ok_or_else(|| { + invalid_params(&format!( + "unknown device preset {name:?} (available: {})", + preset_names() + )) + })?; + EmulateOverrides { + width: Some(preset.width), + height: Some(preset.height), + device_scale_factor: Some(preset.device_scale_factor), + mobile: Some(preset.mobile), + user_agent: Some(preset.user_agent.to_string()), + accept_language: None, + user_agent_metadata: None, + touch: Some(true), + max_touch_points: Some(preset.max_touch_points), + } + } + None => EmulateOverrides::default(), + }; + + if let Some(w) = args.width { + overrides.width = Some(w); + } + if let Some(h) = args.height { + overrides.height = Some(h); + } + if let Some(dpr) = args.dpr { + overrides.device_scale_factor = Some(dpr); + } + if args.mobile { + overrides.mobile = Some(true); + } + if args.no_mobile { + overrides.mobile = Some(false); + } + if let Some(ua) = &args.ua { + overrides.user_agent = Some(ua.clone()); + } + if let Some(accept_language) = &args.accept_language { + overrides.accept_language = Some(accept_language.clone()); + } + if args.touch || args.max_touch_points.is_some() { + overrides.touch = Some(true); + } + if args.no_touch { + overrides.touch = Some(false); + } + if let Some(max_touch_points) = args.max_touch_points { + overrides.max_touch_points = Some(max_touch_points); + } + + // Cross-field checks run on the merged result. + match (overrides.width, overrides.height) { + (Some(_), None) | (None, Some(_)) => { + return Err(invalid_params( + "--width and --height must be provided together", + )); + } + _ => {} + } + + if overrides.width.is_none() + && (overrides.device_scale_factor.is_some() || overrides.mobile.is_some()) + { + return Err(invalid_params( + "--dpr/--mobile/--no-mobile require --width/--height (viewport metrics need dimensions)", + )); + } + + if overrides.accept_language.is_some() && overrides.user_agent.is_none() { + return Err(invalid_params("--accept-language requires --ua")); + } + + Ok(EmulateParams { + session_id: args.session.clone(), + tab_id: args.tab_id, + off: None, + overrides: Some(overrides), + }) +} + +pub fn dispatch(args: EmulateArgs, format: Format) -> Result<(), CliError> { + let device = args.device.clone(); + let params = build_params(&args)?; + let info = ensure_daemon().context("ensure daemon is running")?; + let reply = call(info.sock_path, params)?; + render(&reply, device.as_deref(), format) +} + +fn call(sock: PathBuf, params: EmulateParams) -> Result { + crate::cli::business_rpc::call::( + sock, + "emulate", + Method::ToolEmulate, + Some(params), + TOOL_IPC_TIMEOUT, + ) +} + +fn render(reply: &EmulateResult, device: Option<&str>, format: Format) -> Result<(), CliError> { + match format { + Format::Json => { + let json = serde_json::to_string_pretty(reply) + .map_err(|e| CliError::Local(anyhow::anyhow!(e)))?; + println!("{json}"); + } + Format::Human => { + if reply.cleared { + println!("tab={} emulation cleared", reply.tab_id); + } else { + print!("tab={} emulation applied", reply.tab_id); + if let Some(device) = device { + print!(" device={device}"); + } + if let Some(applied) = &reply.applied { + if let (Some(w), Some(h)) = (applied.width, applied.height) { + print!(" viewport={w}x{h}"); + if let Some(dpr) = applied.device_scale_factor { + print!("@{dpr}x"); + } + if applied.mobile == Some(true) { + print!(" mobile"); + } + } + if applied.user_agent.is_some() { + print!(" ua"); + } + if applied.touch == Some(true) { + print!(" touch"); + } + } + println!(); + } + if let Some(note) = &reply.note { + println!("note: {note}"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_args() -> EmulateArgs { + EmulateArgs { + session: "s1".into(), + tab_id: None, + device: None, + width: None, + height: None, + dpr: None, + mobile: false, + no_mobile: false, + ua: None, + accept_language: None, + touch: false, + no_touch: false, + max_touch_points: None, + off: false, + } + } + + fn build(args: &EmulateArgs) -> EmulateParams { + build_params(args).expect("build_params should succeed") + } + + #[test] + fn preset_alone_applies_full_environment() { + let args = EmulateArgs { + device: Some("iphone-14".into()), + ..base_args() + }; + let params = build(&args); + let o = params.overrides.unwrap(); + assert_eq!(o.width, Some(390)); + assert_eq!(o.height, Some(844)); + assert_eq!(o.device_scale_factor, Some(3.0)); + assert_eq!(o.mobile, Some(true)); + assert!(o.user_agent.unwrap().contains("iPhone")); + assert_eq!(o.touch, Some(true)); + assert_eq!(o.max_touch_points, Some(5)); + assert!(params.off.is_none()); + } + + #[test] + fn every_preset_is_applicable() { + assert_eq!(DEVICE_PRESETS.len(), 7); + for preset in DEVICE_PRESETS { + let args = EmulateArgs { + device: Some(preset.name.into()), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.width, Some(preset.width)); + assert_eq!(o.height, Some(preset.height)); + assert!(o.user_agent.is_some()); + } + } + + #[test] + fn manual_flags_override_preset_fields() { + let args = EmulateArgs { + device: Some("pixel-7".into()), + width: Some(500), + height: Some(900), + dpr: Some(2.0), + ua: Some("custom-ua".into()), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.width, Some(500)); + assert_eq!(o.height, Some(900)); + assert_eq!(o.device_scale_factor, Some(2.0)); + assert_eq!(o.user_agent.as_deref(), Some("custom-ua")); + // Untouched preset fields survive. + assert_eq!(o.mobile, Some(true)); + assert_eq!(o.touch, Some(true)); + } + + #[test] + fn manual_only_viewport_and_ua() { + let args = EmulateArgs { + width: Some(390), + height: Some(844), + dpr: Some(3.0), + mobile: true, + ua: Some("Mozilla/5.0 (iPhone…)".into()), + touch: true, + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.width, Some(390)); + assert_eq!(o.device_scale_factor, Some(3.0)); + assert_eq!(o.mobile, Some(true)); + assert_eq!(o.touch, Some(true)); + assert_eq!(o.max_touch_points, None); + } + + #[test] + fn touch_alone_is_valid() { + let args = EmulateArgs { + touch: true, + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.touch, Some(true)); + assert!(o.width.is_none()); + } + + #[test] + fn max_touch_points_implies_touch() { + let args = EmulateArgs { + max_touch_points: Some(5), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.touch, Some(true)); + assert_eq!(o.max_touch_points, Some(5)); + } + + #[test] + fn off_builds_clear_params() { + let args = EmulateArgs { + off: true, + ..base_args() + }; + let params = build(&args); + assert_eq!(params.off, Some(true)); + assert!(params.overrides.is_none()); + } + + #[test] + fn off_is_exclusive() { + for args in [ + EmulateArgs { + off: true, + device: Some("iphone-14".into()), + ..base_args() + }, + EmulateArgs { + off: true, + touch: true, + ..base_args() + }, + EmulateArgs { + off: true, + width: Some(390), + height: Some(844), + ..base_args() + }, + ] { + let err = build_params(&args).unwrap_err(); + assert!(err.to_string().contains("--off")); + } + } + + #[test] + fn empty_invocation_is_rejected_with_guidance() { + let err = build_params(&base_args()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--device")); + assert!(msg.contains("--off")); + assert!(msg.contains("iphone-14")); + } + + #[test] + fn unknown_preset_lists_available() { + let args = EmulateArgs { + device: Some("nokia-3310".into()), + ..base_args() + }; + let err = build_params(&args).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("nokia-3310")); + assert!(msg.contains("iphone-14")); + } + + #[test] + fn width_height_must_pair() { + for args in [ + EmulateArgs { + width: Some(390), + ..base_args() + }, + EmulateArgs { + height: Some(844), + ..base_args() + }, + ] { + let err = build_params(&args).unwrap_err(); + assert!(err.to_string().contains("together")); + } + } + + #[test] + fn dpr_and_mobile_require_dimensions_without_preset() { + for args in [ + EmulateArgs { + dpr: Some(3.0), + ..base_args() + }, + EmulateArgs { + mobile: true, + ..base_args() + }, + ] { + let err = build_params(&args).unwrap_err(); + assert!(err.to_string().contains("--width/--height")); + } + } + + #[test] + fn dpr_without_dimensions_is_fine_with_preset() { + let args = EmulateArgs { + device: Some("iphone-se".into()), + dpr: Some(2.5), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.device_scale_factor, Some(2.5)); + assert_eq!(o.width, Some(375)); + } + + #[test] + fn preset_supplies_the_missing_dimension() { + // Pairing is validated on the merged result, so a lone --width + // keeps the preset's height instead of erroring out. + let args = EmulateArgs { + device: Some("iphone-14".into()), + width: Some(500), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.width, Some(500)); + assert_eq!(o.height, Some(844)); + // Same for a lone --height. + let args = EmulateArgs { + device: Some("iphone-14".into()), + height: Some(900), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.width, Some(390)); + assert_eq!(o.height, Some(900)); + } + + #[test] + fn no_mobile_and_no_touch_override_preset_fields() { + let args = EmulateArgs { + device: Some("iphone-14".into()), + no_mobile: true, + no_touch: true, + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.mobile, Some(false)); + assert_eq!(o.touch, Some(false)); + // Untouched preset fields survive. + assert_eq!(o.width, Some(390)); + assert_eq!(o.max_touch_points, Some(5)); + } + + #[test] + fn no_mobile_without_dimensions_is_rejected() { + // --no-mobile still maps to the mobile field, which needs a + // viewport without a preset. + let args = EmulateArgs { + no_mobile: true, + ..base_args() + }; + let err = build_params(&args).unwrap_err(); + assert!(err.to_string().contains("--width/--height")); + } + + #[test] + fn no_touch_alone_is_valid() { + let args = EmulateArgs { + no_touch: true, + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.touch, Some(false)); + assert!(o.width.is_none()); + } + + #[test] + fn accept_language_requires_ua() { + let args = EmulateArgs { + accept_language: Some("zh-CN".into()), + ..base_args() + }; + let err = build_params(&args).unwrap_err(); + assert!(err.to_string().contains("--ua")); + // …but pairs fine with a preset (which carries a UA). + let args = EmulateArgs { + device: Some("iphone-14".into()), + accept_language: Some("zh-CN".into()), + ..base_args() + }; + let o = build(&args).overrides.unwrap(); + assert_eq!(o.accept_language.as_deref(), Some("zh-CN")); + } + + #[test] + fn preset_names_are_unique_and_listed() { + let names = preset_names(); + for preset in DEVICE_PRESETS { + assert!(names.contains(preset.name)); + assert_eq!(find_preset(preset.name), Some(preset)); + } + assert!(find_preset("IPHONE-14").is_none()); + } +} diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 9a49134..be9774d 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -9,6 +9,7 @@ pub mod console; pub mod daemon; pub mod dialogs; pub mod doctor; +pub mod emulate; pub mod ensure_daemon; pub mod error; pub mod evaluate; @@ -36,6 +37,7 @@ use clap::{Args, Parser, Subcommand}; use crate::cli::console::ConsoleArgs; use crate::cli::daemon::DaemonCmd; +use crate::cli::emulate::EmulateArgs; use crate::cli::evaluate::EvaluateArgs; use crate::cli::get_html::GetHtmlArgs; use crate::cli::human_loop::RequestHelpArgs; @@ -123,6 +125,9 @@ pub enum Command { /// Agent Window management commands. Window(WindowCmd), + /// Emulate a mobile device environment (viewport, UA, touch) on a tab. + Emulate(EmulateArgs), + /// Capture a PNG of the active tab or a snapshot ref element. Screenshot(ScreenshotArgs), diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index cabcc6d..3da8a11 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -230,6 +230,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolTabBorrow | Method::ToolTabReturn | Method::ToolWindowResize + | Method::ToolEmulate | Method::ToolScreenshot | Method::ToolConsole | Method::ToolNetwork diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 6237551..258ea00 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -81,6 +81,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Browsers => cli::browsers::dispatch(format), Command::Tab(cmd) => cli::tab::dispatch(cmd, format), Command::Window(cmd) => cli::window::dispatch(cmd, format), + Command::Emulate(args) => cli::emulate::dispatch(args, format), Command::Screenshot(args) => cli::screenshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format), Command::Observe(args) => cli::observe::dispatch(args, format), diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index 28d856b..b1ce84b 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -323,3 +323,162 @@ fn rejects_invalid_window_resize_dimensions() { // width/height are required for resize. assert!(Cli::try_parse_from(["bsk", "window", "resize", "--session", "s1"]).is_err()); } + +#[test] +fn parses_emulate_with_device_preset() { + use bsk::cli::emulate::EmulateArgs; + let cli = parse(&["bsk", "emulate", "--session", "s1", "--device", "iphone-14"]); + let Command::Emulate(EmulateArgs { + session, + device, + off, + .. + }) = cli.command + else { + panic!("expected emulate subcommand"); + }; + assert_eq!(session, "s1"); + assert_eq!(device.as_deref(), Some("iphone-14")); + assert!(!off); +} + +#[test] +fn parses_emulate_manual_overrides() { + let cli = parse(&[ + "bsk", + "emulate", + "--session", + "s1", + "--width", + "390", + "--height", + "844", + "--dpr", + "3", + "--mobile", + "--ua", + "Mozilla/5.0 (iPhone)", + "--accept-language", + "zh-CN", + "--touch", + "--max-touch-points", + "5", + "--tab-id", + "7", + ]); + let Command::Emulate(args) = cli.command else { + panic!("expected emulate subcommand"); + }; + assert_eq!(args.width, Some(390)); + assert_eq!(args.height, Some(844)); + assert_eq!(args.dpr, Some(3.0)); + assert!(args.mobile); + assert_eq!(args.ua.as_deref(), Some("Mozilla/5.0 (iPhone)")); + assert_eq!(args.accept_language.as_deref(), Some("zh-CN")); + assert!(args.touch); + assert_eq!(args.max_touch_points, Some(5)); + assert_eq!(args.tab_id, Some(7)); +} + +#[test] +fn parses_emulate_off() { + let cli = parse(&["bsk", "emulate", "--session", "s1", "--off"]); + let Command::Emulate(args) = cli.command else { + panic!("expected emulate subcommand"); + }; + assert!(args.off); +} + +#[test] +fn parses_emulate_no_mobile_no_touch() { + let cli = parse(&[ + "bsk", + "emulate", + "--session", + "s1", + "--device", + "iphone-14", + "--no-mobile", + "--no-touch", + ]); + let Command::Emulate(args) = cli.command else { + panic!("expected emulate subcommand"); + }; + assert!(args.no_mobile); + assert!(args.no_touch); + assert!(!args.mobile); + assert!(!args.touch); +} + +#[test] +fn rejects_conflicting_emulate_flags() { + for extra in [ + &["--mobile", "--no-mobile"][..], + &["--touch", "--no-touch"][..], + ] { + let mut argv = vec!["bsk", "emulate", "--session", "s1", "--device", "iphone-14"]; + argv.extend_from_slice(extra); + assert!(Cli::try_parse_from(argv).is_err()); + } +} + +#[test] +fn rejects_invalid_emulate_values() { + // Zero / out-of-range dimensions. + assert!( + Cli::try_parse_from([ + "bsk", + "emulate", + "--session", + "s1", + "--width", + "0", + "--height", + "844" + ]) + .is_err() + ); + // Non-positive dpr. + assert!( + Cli::try_parse_from([ + "bsk", + "emulate", + "--session", + "s1", + "--width", + "390", + "--height", + "844", + "--dpr", + "0" + ]) + .is_err() + ); + assert!( + Cli::try_parse_from([ + "bsk", + "emulate", + "--session", + "s1", + "--width", + "390", + "--height", + "844", + "--dpr", + "abc" + ]) + .is_err() + ); + // Zero touch points. + assert!( + Cli::try_parse_from([ + "bsk", + "emulate", + "--session", + "s1", + "--max-touch-points", + "0" + ]) + .is_err() + ); +} diff --git a/crates/bsk-protocol/schema/tool_emulate_overrides.json b/crates/bsk-protocol/schema/tool_emulate_overrides.json new file mode 100644 index 0000000..d765a70 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_emulate_overrides.json @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EmulateOverrides", + "description": "Concrete emulation overrides for one tab. Field presence drives the extension: only the overrides whose fields are set are touched, so a call may change just the UA, just the viewport, or any combination.", + "type": "object", + "properties": { + "accept_language": { + "description": "`Accept-Language` header override. Requires `user_agent`.", + "type": [ + "string", + "null" + ] + }, + "device_scale_factor": { + "description": "Device scale factor (device pixel ratio). Only meaningful with `width`/`height`; the extension maps `None` to CDP's `0` (\"use the display's native factor\").", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "height": { + "description": "Viewport height in CSS pixels. Requires `width`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "max_touch_points": { + "description": "Touch points reported while touch emulation is on (CDP defaults to 1 when omitted).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "mobile": { + "description": "Emulate a mobile viewport (meta-viewport handling, overlay scrollbars). Only meaningful with `width`/`height`.", + "type": [ + "boolean", + "null" + ] + }, + "touch": { + "description": "Enable/disable touch emulation (`Emulation.setTouchEmulationEnabled`). When omitted while `max_touch_points` is set, touch is enabled.", + "type": [ + "boolean", + "null" + ] + }, + "user_agent": { + "description": "UA string for `Emulation.setUserAgentOverride`.", + "type": [ + "string", + "null" + ] + }, + "user_agent_metadata": { + "description": "UA client-hints metadata. Requires `user_agent`.", + "anyOf": [ + { + "$ref": "#/definitions/UserAgentMetadata" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Viewport width in CSS pixels. Requires `height`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "definitions": { + "UserAgentBrandVersion": { + "description": "One entry of CDP `Emulation.UserAgentBrandVersion` (UA client hints).", + "type": "object", + "required": [ + "brand", + "version" + ], + "properties": { + "brand": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "UserAgentMetadata": { + "description": "Mirror of CDP `Emulation.UserAgentMetadata`; every field is optional and only sent when present.", + "type": "object", + "properties": { + "architecture": { + "type": [ + "string", + "null" + ] + }, + "brands": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/UserAgentBrandVersion" + } + }, + "full_version": { + "type": [ + "string", + "null" + ] + }, + "mobile": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "platform_version": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_emulate_params.json b/crates/bsk-protocol/schema/tool_emulate_params.json new file mode 100644 index 0000000..4e313fe --- /dev/null +++ b/crates/bsk-protocol/schema/tool_emulate_params.json @@ -0,0 +1,188 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EmulateParams", + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "off": { + "description": "Clear every emulation override on the tab and restore its real environment. Mutually exclusive with `overrides`.", + "type": [ + "boolean", + "null" + ] + }, + "overrides": { + "description": "Overrides to apply. Required unless `off` is set.", + "anyOf": [ + { + "$ref": "#/definitions/EmulateOverrides" + }, + { + "type": "null" + } + ] + }, + "session_id": { + "type": "string" + }, + "tab_id": { + "description": "Target tab. Defaults to the Agent Window's active tab.", + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + "definitions": { + "EmulateOverrides": { + "description": "Concrete emulation overrides for one tab. Field presence drives the extension: only the overrides whose fields are set are touched, so a call may change just the UA, just the viewport, or any combination.", + "type": "object", + "properties": { + "accept_language": { + "description": "`Accept-Language` header override. Requires `user_agent`.", + "type": [ + "string", + "null" + ] + }, + "device_scale_factor": { + "description": "Device scale factor (device pixel ratio). Only meaningful with `width`/`height`; the extension maps `None` to CDP's `0` (\"use the display's native factor\").", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "height": { + "description": "Viewport height in CSS pixels. Requires `width`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "max_touch_points": { + "description": "Touch points reported while touch emulation is on (CDP defaults to 1 when omitted).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "mobile": { + "description": "Emulate a mobile viewport (meta-viewport handling, overlay scrollbars). Only meaningful with `width`/`height`.", + "type": [ + "boolean", + "null" + ] + }, + "touch": { + "description": "Enable/disable touch emulation (`Emulation.setTouchEmulationEnabled`). When omitted while `max_touch_points` is set, touch is enabled.", + "type": [ + "boolean", + "null" + ] + }, + "user_agent": { + "description": "UA string for `Emulation.setUserAgentOverride`.", + "type": [ + "string", + "null" + ] + }, + "user_agent_metadata": { + "description": "UA client-hints metadata. Requires `user_agent`.", + "anyOf": [ + { + "$ref": "#/definitions/UserAgentMetadata" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Viewport width in CSS pixels. Requires `height`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + } + }, + "UserAgentBrandVersion": { + "description": "One entry of CDP `Emulation.UserAgentBrandVersion` (UA client hints).", + "type": "object", + "required": [ + "brand", + "version" + ], + "properties": { + "brand": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "UserAgentMetadata": { + "description": "Mirror of CDP `Emulation.UserAgentMetadata`; every field is optional and only sent when present.", + "type": "object", + "properties": { + "architecture": { + "type": [ + "string", + "null" + ] + }, + "brands": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/UserAgentBrandVersion" + } + }, + "full_version": { + "type": [ + "string", + "null" + ] + }, + "mobile": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "platform_version": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_emulate_result.json b/crates/bsk-protocol/schema/tool_emulate_result.json new file mode 100644 index 0000000..cb6124a --- /dev/null +++ b/crates/bsk-protocol/schema/tool_emulate_result.json @@ -0,0 +1,186 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EmulateResult", + "type": "object", + "required": [ + "cleared", + "tab_id" + ], + "properties": { + "applied": { + "description": "Echo of the overrides that were applied. Absent when cleared.", + "anyOf": [ + { + "$ref": "#/definitions/EmulateOverrides" + }, + { + "type": "null" + } + ] + }, + "cleared": { + "description": "True when overrides were cleared (`off`); false when applied.", + "type": "boolean" + }, + "note": { + "description": "Scope note: emulation overrides are per-tab (CDP target) and are not inherited by new tabs.", + "type": [ + "string", + "null" + ] + }, + "tab_id": { + "type": "integer", + "format": "int64" + } + }, + "definitions": { + "EmulateOverrides": { + "description": "Concrete emulation overrides for one tab. Field presence drives the extension: only the overrides whose fields are set are touched, so a call may change just the UA, just the viewport, or any combination.", + "type": "object", + "properties": { + "accept_language": { + "description": "`Accept-Language` header override. Requires `user_agent`.", + "type": [ + "string", + "null" + ] + }, + "device_scale_factor": { + "description": "Device scale factor (device pixel ratio). Only meaningful with `width`/`height`; the extension maps `None` to CDP's `0` (\"use the display's native factor\").", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "height": { + "description": "Viewport height in CSS pixels. Requires `width`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "max_touch_points": { + "description": "Touch points reported while touch emulation is on (CDP defaults to 1 when omitted).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "mobile": { + "description": "Emulate a mobile viewport (meta-viewport handling, overlay scrollbars). Only meaningful with `width`/`height`.", + "type": [ + "boolean", + "null" + ] + }, + "touch": { + "description": "Enable/disable touch emulation (`Emulation.setTouchEmulationEnabled`). When omitted while `max_touch_points` is set, touch is enabled.", + "type": [ + "boolean", + "null" + ] + }, + "user_agent": { + "description": "UA string for `Emulation.setUserAgentOverride`.", + "type": [ + "string", + "null" + ] + }, + "user_agent_metadata": { + "description": "UA client-hints metadata. Requires `user_agent`.", + "anyOf": [ + { + "$ref": "#/definitions/UserAgentMetadata" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Viewport width in CSS pixels. Requires `height`.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + } + }, + "UserAgentBrandVersion": { + "description": "One entry of CDP `Emulation.UserAgentBrandVersion` (UA client hints).", + "type": "object", + "required": [ + "brand", + "version" + ], + "properties": { + "brand": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "UserAgentMetadata": { + "description": "Mirror of CDP `Emulation.UserAgentMetadata`; every field is optional and only sent when present.", + "type": "object", + "properties": { + "architecture": { + "type": [ + "string", + "null" + ] + }, + "brands": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/UserAgentBrandVersion" + } + }, + "full_version": { + "type": [ + "string", + "null" + ] + }, + "mobile": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "platform_version": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_emulate_user_agent_metadata.json b/crates/bsk-protocol/schema/tool_emulate_user_agent_metadata.json new file mode 100644 index 0000000..d8d173c --- /dev/null +++ b/crates/bsk-protocol/schema/tool_emulate_user_agent_metadata.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UserAgentMetadata", + "description": "Mirror of CDP `Emulation.UserAgentMetadata`; every field is optional and only sent when present.", + "type": "object", + "properties": { + "architecture": { + "type": [ + "string", + "null" + ] + }, + "brands": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/UserAgentBrandVersion" + } + }, + "full_version": { + "type": [ + "string", + "null" + ] + }, + "mobile": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "platform": { + "type": [ + "string", + "null" + ] + }, + "platform_version": { + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "UserAgentBrandVersion": { + "description": "One entry of CDP `Emulation.UserAgentBrandVersion` (UA client hints).", + "type": "object", + "required": [ + "brand", + "version" + ], + "properties": { + "brand": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index 92e18f8..20eb7ba 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -48,6 +48,11 @@ fn main() { dump!(WindowResizeParams, "tool_window_resize_params"); dump!(WindowResizeResult, "tool_window_resize_result"); + dump!(EmulateParams, "tool_emulate_params"); + dump!(EmulateResult, "tool_emulate_result"); + dump!(EmulateOverrides, "tool_emulate_overrides"); + dump!(UserAgentMetadata, "tool_emulate_user_agent_metadata"); + dump!(TabListParams, "tool_tab_list_params"); dump!(TabListResult, "tool_tab_list_result"); dump!(TabCreateParams, "tool_tab_create_params"); diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 71bf07f..fbb2ba9 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -46,6 +46,8 @@ pub enum Method { ToolSessionStop, #[serde(rename = "tool.window_resize")] ToolWindowResize, + #[serde(rename = "tool.emulate")] + ToolEmulate, #[serde(rename = "tool.tab_list")] ToolTabList, #[serde(rename = "tool.tab_create")] @@ -143,6 +145,7 @@ impl Method { | Method::ToolTabReturn | Method::ToolTabSelect | Method::ToolWindowResize + | Method::ToolEmulate | Method::ToolNavigate | Method::ToolNavigateBack | Method::ToolNavigateForward @@ -234,6 +237,13 @@ mod tests { assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.network")); } + #[test] + fn emulate_method_round_trips() { + let method: Method = serde_json::from_value(json!("tool.emulate")).unwrap(); + assert_eq!(method, Method::ToolEmulate); + assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.emulate")); + } + #[test] fn cancel_params_and_result_round_trip() { let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap(); @@ -276,6 +286,7 @@ mod tests { assert!(Method::ToolEvaluate.is_mutating()); assert!(Method::ToolRecordStart.is_mutating()); assert!(Method::ToolWindowResize.is_mutating()); + assert!(Method::ToolEmulate.is_mutating()); } #[test] diff --git a/crates/bsk-protocol/src/tools/emulate.rs b/crates/bsk-protocol/src/tools/emulate.rs new file mode 100644 index 0000000..149a9c1 --- /dev/null +++ b/crates/bsk-protocol/src/tools/emulate.rs @@ -0,0 +1,218 @@ +//! Device-emulation tool (`tool.emulate`). +//! +//! Applies CDP Emulation-domain overrides (viewport metrics, user +//! agent, touch) to a single tab so an agent can debug mobile page +//! behaviour. Overrides are per-target: they do not propagate to new +//! tabs, and `off` restores the tab's real environment +//! (`Emulation.clearDeviceMetricsOverride` + touch disabled + UA +//! override cleared). +//! +//! Device presets are resolved CLI-side; the wire only carries the +//! concrete overrides the extension should execute. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// One entry of CDP `Emulation.UserAgentBrandVersion` (UA client hints). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct UserAgentBrandVersion { + pub brand: String, + pub version: String, +} + +/// Mirror of CDP `Emulation.UserAgentMetadata`; every field is optional +/// and only sent when present. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct UserAgentMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brands: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub architecture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mobile: Option, +} + +/// Concrete emulation overrides for one tab. Field presence drives the +/// extension: only the overrides whose fields are set are touched, so a +/// call may change just the UA, just the viewport, or any combination. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmulateOverrides { + /// Viewport width in CSS pixels. Requires `height`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + /// Viewport height in CSS pixels. Requires `width`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + /// Device scale factor (device pixel ratio). Only meaningful with + /// `width`/`height`; the extension maps `None` to CDP's `0` + /// ("use the display's native factor"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_scale_factor: Option, + /// Emulate a mobile viewport (meta-viewport handling, overlay + /// scrollbars). Only meaningful with `width`/`height`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mobile: Option, + /// UA string for `Emulation.setUserAgentOverride`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + /// `Accept-Language` header override. Requires `user_agent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accept_language: Option, + /// UA client-hints metadata. Requires `user_agent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_agent_metadata: Option, + /// Enable/disable touch emulation + /// (`Emulation.setTouchEmulationEnabled`). When omitted while + /// `max_touch_points` is set, touch is enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub touch: Option, + /// Touch points reported while touch emulation is on (CDP defaults + /// to 1 when omitted). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_touch_points: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmulateParams { + pub session_id: String, + /// Target tab. Defaults to the Agent Window's active tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + /// Clear every emulation override on the tab and restore its real + /// environment. Mutually exclusive with `overrides`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub off: Option, + /// Overrides to apply. Required unless `off` is set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overrides: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmulateResult { + pub tab_id: i64, + /// True when overrides were cleared (`off`); false when applied. + pub cleared: bool, + /// Echo of the overrides that were applied. Absent when cleared. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied: Option, + /// Scope note: emulation overrides are per-tab (CDP target) and are + /// not inherited by new tabs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn params_omit_optional_fields() { + let p = EmulateParams { + session_id: "abcd".into(), + tab_id: None, + off: None, + overrides: None, + }; + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v, json!({ "session_id": "abcd" })); + let round: EmulateParams = serde_json::from_value(v).unwrap(); + assert_eq!(round, p); + } + + #[test] + fn overrides_round_trip_with_all_fields() { + let o = EmulateOverrides { + width: Some(390), + height: Some(844), + device_scale_factor: Some(3.0), + mobile: Some(true), + user_agent: Some("Mozilla/5.0 (iPhone…)".into()), + accept_language: Some("zh-CN".into()), + user_agent_metadata: Some(UserAgentMetadata { + brands: Some(vec![UserAgentBrandVersion { + brand: "Safari".into(), + version: "17".into(), + }]), + full_version: None, + platform: Some("iOS".into()), + platform_version: None, + architecture: None, + model: Some("iPhone".into()), + mobile: Some(true), + }), + touch: Some(true), + max_touch_points: Some(5), + }; + let v = serde_json::to_value(&o).unwrap(); + assert_eq!(v["width"], json!(390)); + assert_eq!(v["device_scale_factor"], json!(3.0)); + assert_eq!( + v["user_agent_metadata"]["brands"][0]["brand"], + json!("Safari") + ); + // Absent metadata fields stay absent. + assert!(v["user_agent_metadata"].get("full_version").is_none()); + let round: EmulateOverrides = serde_json::from_value(v).unwrap(); + assert_eq!(round, o); + } + + #[test] + fn overrides_default_is_empty_object() { + let v = serde_json::to_value(EmulateOverrides::default()).unwrap(); + assert_eq!(v, json!({})); + let round: EmulateOverrides = serde_json::from_value(json!({})).unwrap(); + assert_eq!(round, EmulateOverrides::default()); + } + + #[test] + fn off_params_round_trip() { + let p = EmulateParams { + session_id: "abcd".into(), + tab_id: Some(7), + off: Some(true), + overrides: None, + }; + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v, json!({ "session_id": "abcd", "tab_id": 7, "off": true })); + let round: EmulateParams = serde_json::from_value(v).unwrap(); + assert_eq!(round, p); + } + + #[test] + fn result_round_trips() { + let r = EmulateResult { + tab_id: 7, + cleared: false, + applied: Some(EmulateOverrides { + width: Some(390), + height: Some(844), + ..EmulateOverrides::default() + }), + note: Some("per-tab".into()), + }; + let v = serde_json::to_value(&r).unwrap(); + let round: EmulateResult = serde_json::from_value(v).unwrap(); + assert_eq!(round, r); + } + + #[test] + fn cleared_result_omits_applied() { + let r = EmulateResult { + tab_id: 7, + cleared: true, + applied: None, + note: None, + }; + let v = serde_json::to_value(&r).unwrap(); + assert_eq!(v, json!({ "tab_id": 7, "cleared": true })); + } +} diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index b325f4d..a9c04db 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -2,6 +2,7 @@ pub mod console; pub mod dialog; +pub mod emulate; pub mod human_loop; pub mod interaction; pub mod navigation; @@ -16,6 +17,7 @@ pub mod window; pub use console::*; pub use dialog::*; +pub use emulate::*; pub use human_loop::*; pub use interaction::*; pub use navigation::*; diff --git a/skill/SKILL.md b/skill/SKILL.md index a5ddf77..3c36961 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -134,6 +134,22 @@ Details and flags: **`bsk --help`** |---------|---------| | `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | +### Device emulation — `bsk emulate` (requires `--session `) + +Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour: + +```bash +bsk emulate --session --device iphone-14 +bsk emulate --session --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch +bsk emulate --session --off +``` + +- Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off. +- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.) +- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment. +- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides). +- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams. + ### Tabs (require `--session `) | Command | Summary |