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:
BB-fat
2026-08-05 18:10:39 +08:00
committed by GitHub
parent 49378d70de
commit 0f264c5df1
21 changed files with 2579 additions and 1 deletions
@@ -85,6 +85,23 @@ export const CDP_PROTOCOL_VERSION = "1.3";
/** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */ /** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */
export type DialogCursor = number; 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_BUFFER = 32;
const MAX_DIALOG_FIELD_LENGTH = 4096; const MAX_DIALOG_FIELD_LENGTH = 4096;
const MAX_CONSOLE_BUFFER = 200; const MAX_CONSOLE_BUFFER = 200;
@@ -194,6 +211,33 @@ export class ChromiumCdp {
return this.dialogSequences.get(tabId) ?? 0; 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`. */ /** Dialogs observed on `tabId` with sequence strictly greater than `cursor`. */
dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] { dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] {
const buf = this.dialogBuffers.get(tabId) ?? []; const buf = this.dialogBuffers.get(tabId) ?? [];
@@ -124,6 +124,10 @@ describe("ToolDispatcher", () => {
truncated: false, truncated: false,
}) satisfies ConsoleResult, }) 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 }); const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start(); dispatcher.start();
@@ -163,6 +167,10 @@ describe("ToolDispatcher", () => {
next_since: 0, next_since: 0,
truncated: false, 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 }); const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start(); 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({});
});
});
+11 -1
View File
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
import type { import type {
ClickParams, ClickParams,
ConsoleParams, ConsoleParams,
EmulateParams,
EvaluateParams, EvaluateParams,
FillParams, FillParams,
GetHtmlParams, GetHtmlParams,
@@ -29,6 +30,7 @@ import type {
} from "@/transport/types"; } from "@/transport/types";
import { isRequestFrame } from "@/transport/types"; import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console"; import { handleConsole } from "./console";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { handleEvaluate } from "./evaluate"; import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop"; import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction"; import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
@@ -74,7 +76,8 @@ import { handleWaitForNavigation } from "./waits";
import { handleWindowResize, type WindowResizeParams } from "./window"; import { handleWindowResize, type WindowResizeParams } from "./window";
type DispatcherCdpRunner = CdpRunner & type DispatcherCdpRunner = CdpRunner &
NetworkCdpRunner & { NetworkCdpRunner &
EmulateCdpRunner & {
detachSession(sessionId: string): Promise<void>; detachSession(sessionId: string): Promise<void>;
}; };
@@ -289,6 +292,12 @@ export class ToolDispatcher {
return handleTabReturn(this.sessions, req.params as TabReturnParams); return handleTabReturn(this.sessions, req.params as TabReturnParams);
case "tool.window_resize": case "tool.window_resize":
return handleWindowResize(this.sessions, req.params as WindowResizeParams); 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": case "tool.screenshot":
return handleScreenshot( return handleScreenshot(
this.sessions, this.sessions,
@@ -482,6 +491,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
case "tool.tab_borrow": case "tool.tab_borrow":
case "tool.tab_return": case "tool.tab_return":
case "tool.window_resize": case "tool.window_resize":
case "tool.emulate":
case "tool.navigate": case "tool.navigate":
case "tool.navigate_back": case "tool.navigate_back":
case "tool.navigate_forward": case "tool.navigate_forward":
+285
View File
@@ -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,
};
}
+57
View File
@@ -565,6 +565,63 @@ export interface RequestHelpResult {
resolved_targets?: ResolvedTarget[]; 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) // Semantic record payloads — mirror bsk-protocol record.rs (Trace v2)
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
+16
View File
@@ -134,6 +134,22 @@ Details and flags: **`bsk <cmd> --help`**
|---------|---------| |---------|---------|
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | | `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |
### Device emulation — `bsk emulate` (requires `--session <id>`)
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 <id> --device iphone-14
bsk emulate --session <id> --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch
bsk emulate --session <id> --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 <id> --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 <id>`) ### Tabs (require `--session <id>`)
| Command | Summary | | Command | Summary |
+728
View File
@@ -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::<Vec<_>>()
.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<i64>,
/// 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<String>,
/// Viewport width in CSS pixels (requires --height unless --device
/// supplies it).
#[arg(long, value_parser = viewport_dimension)]
pub width: Option<u32>,
/// Viewport height in CSS pixels (requires --width unless --device
/// supplies it).
#[arg(long, value_parser = viewport_dimension)]
pub height: Option<u32>,
/// Device pixel ratio (requires --width/--height without --device).
#[arg(long, value_parser = device_pixel_ratio)]
pub dpr: Option<f64>,
/// 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<String>,
/// Accept-Language header override (requires --ua or --device).
#[arg(long = "accept-language")]
pub accept_language: Option<String>,
/// 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<u32>,
/// 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<u32, String> {
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<f64, String> {
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<u32, String> {
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<EmulateParams, CliError> {
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 <preset> ({}), 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<EmulateResult, CliError> {
crate::cli::business_rpc::call::<EmulateParams, EmulateResult>(
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());
}
}
+5
View File
@@ -9,6 +9,7 @@ pub mod console;
pub mod daemon; pub mod daemon;
pub mod dialogs; pub mod dialogs;
pub mod doctor; pub mod doctor;
pub mod emulate;
pub mod ensure_daemon; pub mod ensure_daemon;
pub mod error; pub mod error;
pub mod evaluate; pub mod evaluate;
@@ -36,6 +37,7 @@ use clap::{Args, Parser, Subcommand};
use crate::cli::console::ConsoleArgs; use crate::cli::console::ConsoleArgs;
use crate::cli::daemon::DaemonCmd; use crate::cli::daemon::DaemonCmd;
use crate::cli::emulate::EmulateArgs;
use crate::cli::evaluate::EvaluateArgs; use crate::cli::evaluate::EvaluateArgs;
use crate::cli::get_html::GetHtmlArgs; use crate::cli::get_html::GetHtmlArgs;
use crate::cli::human_loop::RequestHelpArgs; use crate::cli::human_loop::RequestHelpArgs;
@@ -123,6 +125,9 @@ pub enum Command {
/// Agent Window management commands. /// Agent Window management commands.
Window(WindowCmd), 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. /// Capture a PNG of the active tab or a snapshot ref element.
Screenshot(ScreenshotArgs), Screenshot(ScreenshotArgs),
+1
View File
@@ -230,6 +230,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolTabBorrow | Method::ToolTabBorrow
| Method::ToolTabReturn | Method::ToolTabReturn
| Method::ToolWindowResize | Method::ToolWindowResize
| Method::ToolEmulate
| Method::ToolScreenshot | Method::ToolScreenshot
| Method::ToolConsole | Method::ToolConsole
| Method::ToolNetwork | Method::ToolNetwork
+1
View File
@@ -81,6 +81,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
Command::Browsers => cli::browsers::dispatch(format), Command::Browsers => cli::browsers::dispatch(format),
Command::Tab(cmd) => cli::tab::dispatch(cmd, format), Command::Tab(cmd) => cli::tab::dispatch(cmd, format),
Command::Window(cmd) => cli::window::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::Screenshot(args) => cli::screenshot::dispatch(args, format),
Command::Snapshot(args) => cli::snapshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
Command::Observe(args) => cli::observe::dispatch(args, format), Command::Observe(args) => cli::observe::dispatch(args, format),
+159
View File
@@ -323,3 +323,162 @@ fn rejects_invalid_window_resize_dimensions() {
// width/height are required for resize. // width/height are required for resize.
assert!(Cli::try_parse_from(["bsk", "window", "resize", "--session", "s1"]).is_err()); 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()
);
}
@@ -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"
]
}
}
}
}
}
@@ -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"
]
}
}
}
}
}
@@ -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"
]
}
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -48,6 +48,11 @@ fn main() {
dump!(WindowResizeParams, "tool_window_resize_params"); dump!(WindowResizeParams, "tool_window_resize_params");
dump!(WindowResizeResult, "tool_window_resize_result"); 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!(TabListParams, "tool_tab_list_params");
dump!(TabListResult, "tool_tab_list_result"); dump!(TabListResult, "tool_tab_list_result");
dump!(TabCreateParams, "tool_tab_create_params"); dump!(TabCreateParams, "tool_tab_create_params");
+11
View File
@@ -46,6 +46,8 @@ pub enum Method {
ToolSessionStop, ToolSessionStop,
#[serde(rename = "tool.window_resize")] #[serde(rename = "tool.window_resize")]
ToolWindowResize, ToolWindowResize,
#[serde(rename = "tool.emulate")]
ToolEmulate,
#[serde(rename = "tool.tab_list")] #[serde(rename = "tool.tab_list")]
ToolTabList, ToolTabList,
#[serde(rename = "tool.tab_create")] #[serde(rename = "tool.tab_create")]
@@ -143,6 +145,7 @@ impl Method {
| Method::ToolTabReturn | Method::ToolTabReturn
| Method::ToolTabSelect | Method::ToolTabSelect
| Method::ToolWindowResize | Method::ToolWindowResize
| Method::ToolEmulate
| Method::ToolNavigate | Method::ToolNavigate
| Method::ToolNavigateBack | Method::ToolNavigateBack
| Method::ToolNavigateForward | Method::ToolNavigateForward
@@ -234,6 +237,13 @@ mod tests {
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.network")); 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] #[test]
fn cancel_params_and_result_round_trip() { fn cancel_params_and_result_round_trip() {
let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap(); 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::ToolEvaluate.is_mutating());
assert!(Method::ToolRecordStart.is_mutating()); assert!(Method::ToolRecordStart.is_mutating());
assert!(Method::ToolWindowResize.is_mutating()); assert!(Method::ToolWindowResize.is_mutating());
assert!(Method::ToolEmulate.is_mutating());
} }
#[test] #[test]
+218
View File
@@ -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<Vec<UserAgentBrandVersion>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub architecture: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mobile: Option<bool>,
}
/// 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<u32>,
/// Viewport height in CSS pixels. Requires `width`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
/// 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<f64>,
/// 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<bool>,
/// UA string for `Emulation.setUserAgentOverride`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
/// `Accept-Language` header override. Requires `user_agent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accept_language: Option<String>,
/// UA client-hints metadata. Requires `user_agent`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_agent_metadata: Option<UserAgentMetadata>,
/// 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<bool>,
/// 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<u32>,
}
#[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<i64>,
/// 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<bool>,
/// Overrides to apply. Required unless `off` is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overrides: Option<EmulateOverrides>,
}
#[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<EmulateOverrides>,
/// 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<String>,
}
#[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 }));
}
}
+2
View File
@@ -2,6 +2,7 @@
pub mod console; pub mod console;
pub mod dialog; pub mod dialog;
pub mod emulate;
pub mod human_loop; pub mod human_loop;
pub mod interaction; pub mod interaction;
pub mod navigation; pub mod navigation;
@@ -16,6 +17,7 @@ pub mod window;
pub use console::*; pub use console::*;
pub use dialog::*; pub use dialog::*;
pub use emulate::*;
pub use human_loop::*; pub use human_loop::*;
pub use interaction::*; pub use interaction::*;
pub use navigation::*; pub use navigation::*;
+16
View File
@@ -134,6 +134,22 @@ Details and flags: **`bsk <cmd> --help`**
|---------|---------| |---------|---------|
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | | `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |
### Device emulation — `bsk emulate` (requires `--session <id>`)
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 <id> --device iphone-14
bsk emulate --session <id> --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch
bsk emulate --session <id> --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 <id> --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 <id>`) ### Tabs (require `--session <id>`)
| Command | Summary | | Command | Summary |