From 8884e5d9c7639dd0aa50e5c0dabff95c86e55896 Mon Sep 17 00:00:00 2001
From: Duy Nguyen
Date: Thu, 15 Oct 2026 01:53:05 +0700
Subject: [PATCH 1/7] feat: add an Advanced build that drives agent tabs
through the debugger
One codebase, two build profiles. Standard is unchanged and has no
debugger permission or code. DEWEE_PROFILE=advanced adds the debugger
permission and a CDP adapter behind the same page-driver interface:
accessibility-tree snapshots with the shared redaction rule, trusted
click/type/press input and viewport JPEG screenshots under 1.5 MiB.
The person turns the debugger on in the side panel, per browser session.
It attaches only to agent-owned tabs while a command runs, calls only a
fixed CDP method list, re-checks the live policy before every command
and detaches on Stop, Pause, revoke, expiry, disconnect and opt-in off.
When another debugger holds the tab it answers debugger_unavailable, or
reads the standard way when the person allowed that; screenshots never
fall back.
The side panel now shows agent tabs, site scope and TTL, and a timeline
of recent outcomes. Agent tabs get a badge, targets are highlighted
before a click, and the person's own input in an agent tab pauses
control.
Refs #18
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 2 +
eslint.config.js | 2 +-
package.json | 6 +-
scripts/build-advanced.mjs | 6 +
scripts/build-e2e.mjs | 11 +-
src/browser/adapters/cdp/cdp-adapter.ts | 427 ++++++++++++++++++
src/browser/adapters/cdp/cdp-client.ts | 186 ++++++++
src/browser/adapters/cdp/cdp-outline.ts | 205 +++++++++
src/browser/adapters/cdp/index.ts | 29 ++
src/browser/adapters/cdp/screenshot.ts | 93 ++++
src/browser/adapters/page-driver.ts | 52 +++
src/browser/chrome-api.ts | 41 +-
src/browser/command-runner.ts | 7 +-
src/browser/engine-router.ts | 156 +++++++
src/browser/executor.ts | 59 ++-
src/browser/panel-state.ts | 40 +-
src/browser/policy.ts | 9 +-
src/browser/sensitive.ts | 40 ++
src/browser/session.ts | 296 +++++++++++-
src/browser/snapshot.ts | 30 +-
src/browser/tab-marker.ts | 57 +++
src/env.d.ts | 3 +
src/protocol/messages.ts | 2 +
src/shared/build-profile.ts | 42 ++
src/shared/i18n.ts | 26 ++
src/sidepanel/browser/BrowserActivity.tsx | 36 +-
.../browser/BrowserControlDrawer.tsx | 107 ++++-
src/sidepanel/sidepanel.css | 43 ++
tests/unit/browser/cdp-adapter.test.ts | 346 ++++++++++++++
tests/unit/browser/cdp-client.test.ts | 140 ++++++
tests/unit/browser/engine-router.test.ts | 117 +++++
tests/unit/browser/executor.test.ts | 74 ++-
tests/unit/browser/screenshot.test.ts | 59 +++
tests/unit/browser/sensitive.test.ts | 68 +++
tests/unit/browser/session-advanced.test.ts | 315 +++++++++++++
tests/unit/browser/session.test.ts | 1 +
tests/unit/browser/tab-marker.test.ts | 44 ++
tests/unit/protocol/messages.test.ts | 2 +
tests/unit/shared/build-profile.test.ts | 26 ++
wxt.config.ts | 20 +-
40 files changed, 3132 insertions(+), 93 deletions(-)
create mode 100644 scripts/build-advanced.mjs
create mode 100644 src/browser/adapters/cdp/cdp-adapter.ts
create mode 100644 src/browser/adapters/cdp/cdp-client.ts
create mode 100644 src/browser/adapters/cdp/cdp-outline.ts
create mode 100644 src/browser/adapters/cdp/index.ts
create mode 100644 src/browser/adapters/cdp/screenshot.ts
create mode 100644 src/browser/adapters/page-driver.ts
create mode 100644 src/browser/engine-router.ts
create mode 100644 src/browser/sensitive.ts
create mode 100644 src/browser/tab-marker.ts
create mode 100644 src/shared/build-profile.ts
create mode 100644 tests/unit/browser/cdp-adapter.test.ts
create mode 100644 tests/unit/browser/cdp-client.test.ts
create mode 100644 tests/unit/browser/engine-router.test.ts
create mode 100644 tests/unit/browser/screenshot.test.ts
create mode 100644 tests/unit/browser/sensitive.test.ts
create mode 100644 tests/unit/browser/session-advanced.test.ts
create mode 100644 tests/unit/browser/tab-marker.test.ts
create mode 100644 tests/unit/shared/build-profile.test.ts
diff --git a/.gitignore b/.gitignore
index a9a9569..78f6d42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@ node_modules/
.output/
.output-e2e/
.output-store/
+.output-advanced/
+.output-e2e-advanced/
.wxt/
dist/
coverage/
diff --git a/eslint.config.js b/eslint.config.js
index 0c6c686..736ba67 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -4,7 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default tseslint.config(
- { ignores: ['.output/', '.output-e2e/', '.output-store/', '.wxt/', 'node_modules/', 'coverage/', 'test-results/', 'playwright-report/'] },
+ { ignores: ['.output/', '.output-e2e/', '.output-store/', '.output-advanced/', '.output-e2e-advanced/', '.wxt/', 'node_modules/', 'coverage/', 'test-results/', 'playwright-report/'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
diff --git a/package.json b/package.json
index 6ce5b01..e34533d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,9 @@
"scripts": {
"dev": "wxt",
"build": "wxt build",
+ "build:advanced": "node scripts/build-advanced.mjs",
"zip": "wxt zip",
+ "zip:advanced": "node scripts/build-advanced.mjs zip",
"zip:store": "node scripts/zip-store.mjs",
"postinstall": "wxt prepare",
"lint": "eslint .",
@@ -20,7 +22,9 @@
"test": "vitest run",
"test:watch": "vitest",
"build:e2e": "node scripts/build-e2e.mjs",
- "test:e2e": "node scripts/build-e2e.mjs && playwright test"
+ "build:e2e:advanced": "node scripts/build-e2e.mjs --profile advanced",
+ "test:e2e": "node scripts/build-e2e.mjs && playwright test",
+ "bench:browser": "node scripts/bench-browser-control.mjs"
},
"dependencies": {
"dompurify": "3.4.16",
diff --git a/scripts/build-advanced.mjs b/scripts/build-advanced.mjs
new file mode 100644
index 0000000..feedd3a
--- /dev/null
+++ b/scripts/build-advanced.mjs
@@ -0,0 +1,6 @@
+// Builds (or zips, with `zip`) the Advanced profile into .output-advanced: same code, plus the
+// `debugger` permission (see src/shared/build-profile.ts). Sideload only; it keeps the pinned key.
+import { execSync } from 'node:child_process';
+
+const command = process.argv[2] === 'zip' ? 'zip' : 'build';
+execSync(`pnpm exec wxt ${command}`, { stdio: 'inherit', env: { ...process.env, DEWEE_PROFILE: 'advanced' } });
diff --git a/scripts/build-e2e.mjs b/scripts/build-e2e.mjs
index 73496ea..b907f00 100644
--- a/scripts/build-e2e.mjs
+++ b/scripts/build-e2e.mjs
@@ -1,4 +1,11 @@
-// Builds the extension for Playwright into .output-e2e/chrome-mv3 (see wxt.config.ts).
+// Builds the extension for Playwright: Standard into .output-e2e/chrome-mv3 and Advanced into
+// .output-e2e-advanced/chrome-mv3 (see wxt.config.ts). `--profile standard|advanced` builds one only.
import { execSync } from 'node:child_process';
-execSync('pnpm exec wxt build', { stdio: 'inherit', env: { ...process.env, DEWEE_E2E: '1' } });
+const flag = process.argv.indexOf('--profile');
+const only = flag === -1 ? null : process.argv[flag + 1];
+if (only !== null && only !== 'standard' && only !== 'advanced') throw new Error(`Unknown profile "${only}"`);
+
+for (const profile of only ? [only] : ['standard', 'advanced']) {
+ execSync('pnpm exec wxt build', { stdio: 'inherit', env: { ...process.env, DEWEE_E2E: '1', DEWEE_PROFILE: profile } });
+}
diff --git a/src/browser/adapters/cdp/cdp-adapter.ts b/src/browser/adapters/cdp/cdp-adapter.ts
new file mode 100644
index 0000000..1ce578a
--- /dev/null
+++ b/src/browser/adapters/cdp/cdp-adapter.ts
@@ -0,0 +1,427 @@
+/**
+ * Advanced page adapter: the semantic page actions over the Chrome DevTools Protocol, on an
+ * agent-owned tab, while one command runs there. The executor has already applied the grant,
+ * approvals and redaction rules; this adapter only carries the action out, through `CdpClient`
+ * (whitelisted methods, policy check before every command).
+ *
+ * - snapshot: Chrome's accessibility tree, written in the Standard outline format (cdp-outline.ts).
+ * - refs: `eN` handles bound to a DOM backend node id, the frame it lives in, and the tab's
+ * snapshot generation. Navigation moves the generation; a node that no longer exists is
+ * `element_not_found`. Refs are never shared with the Standard adapter.
+ * - click/type/press: trusted input (`Input.*`) after a hit test at the element's centre.
+ * - screenshot: the visible viewport only, sensitive fields and ungranted frames blacked out.
+ * - frames: in-process frames whose origin is in the grant are read and driven; out-of-process
+ * (cross-site) frames are left out and their elements cannot be targeted.
+ */
+import type { PressKey } from '../../protocol';
+import { urlOrigin } from '../../policy';
+import type { DriverErrorCode, DriverRequest, DriverResponse, PageDriver } from '../page-driver';
+import { CdpFailure, type CdpClient } from './cdp-client';
+import { formatOutline, indexDom, isEditableNode, isSensitiveNode, nameOf, property, roleOf, type AXNode, type DomInfo, type DomNode } from './cdp-outline';
+import { bytesToBase64, fitScreenshot, quadsToRects, type DecodedImage, type ImageCodec, type Rect } from './screenshot';
+
+/** How long trusted input from the debugger is not taken for the person's own input. */
+export const DISPATCH_GRACE_MS = 600;
+/** Frames read per snapshot besides the main one. */
+const MAX_FRAMES = 10;
+
+interface RefTarget {
+ backend: number;
+ frame: string;
+}
+
+interface TabRefs {
+ generation: number;
+ next: number;
+ byRef: Map;
+ byBackend: Map;
+}
+
+interface FrameTreeNode {
+ frame: { id: string; url: string; urlFragment?: string };
+ childFrames?: FrameTreeNode[];
+}
+
+interface Frames {
+ main: string;
+ mainUrl: string;
+ origins: Map;
+}
+
+const KEYS: Record = {
+ Enter: { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
+ Tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
+ Escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
+ Backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
+ PageUp: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
+ PageDown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
+ Home: { key: 'Home', code: 'Home', keyCode: 36 },
+ End: { key: 'End', code: 'End', keyCode: 35 },
+};
+
+const fail = (code: DriverErrorCode): DriverResponse => ({ ok: false, code });
+
+/** Chrome's message for a node id that no longer resolves. */
+const GONE = /No node|Could not find node|does not exist|not found/i;
+
+export interface CdpAdapterDeps {
+ client: CdpClient;
+ codec: ImageCodec;
+ now?(): number;
+}
+
+export class CdpAdapter implements PageDriver {
+ private readonly refs = new Map();
+ private readonly dispatching = new Map();
+ private readonly overlay = new Set();
+ private readonly now: () => number;
+
+ constructor(private readonly deps: CdpAdapterDeps) {
+ this.now = deps.now ?? Date.now;
+ }
+
+ get attachedTabs(): number[] {
+ return this.deps.client.attachedTabs;
+ }
+
+ /** Attaches for the command about to run. Null when ready; otherwise why not (`debugger_unavailable` allows Standard to take over). */
+ async prepare(tabId: number): Promise {
+ try {
+ await this.deps.client.attach(tabId);
+ return null;
+ } catch (error) {
+ return error instanceof CdpFailure ? error.code : 'debugger_unavailable';
+ }
+ }
+
+ async release(tabId: number): Promise {
+ this.overlay.delete(tabId);
+ await this.deps.client.detach(tabId);
+ }
+
+ /** The tab is gone (closed, or no longer owned): its refs go too. */
+ forget(tabId: number): void {
+ this.refs.delete(tabId);
+ this.dispatching.delete(tabId);
+ this.overlay.delete(tabId);
+ }
+
+ async detachAll(): Promise {
+ this.overlay.clear();
+ await this.deps.client.detachAll();
+ }
+
+ /** Detaches a tab this worker may have attached before it restarted. */
+ async forceDetach(tabId: number): Promise {
+ await this.deps.client.detach(tabId, true);
+ }
+
+ /** True while (and shortly after) the adapter sends trusted input to the tab. */
+ isDispatching(tabId: number): boolean {
+ return (this.dispatching.get(tabId) ?? 0) > this.now();
+ }
+
+ async run(tabId: number, request: DriverRequest): Promise {
+ try {
+ await this.deps.client.attach(tabId);
+ switch (request.op) {
+ case 'snapshot':
+ return await this.snapshot(tabId, request.origins, request.generation, request.maxChars);
+ case 'describe': {
+ const target = await this.target(tabId, request.origins, request.generation, request.ref);
+ const ax = await this.axNode(tabId, target);
+ const dom = await this.describeDom(tabId, target);
+ return { ok: true, op: 'describe', role: roleOf(ax), name: nameOf(ax), sensitive: isSensitiveNode(dom), editable: isEditableNode(dom) };
+ }
+ case 'highlight': {
+ const target = await this.target(tabId, request.origins, request.generation, request.ref);
+ await this.deps.client.send(tabId, 'DOM.scrollIntoViewIfNeeded', { backendNodeId: target.backend });
+ if (!this.overlay.has(tabId)) {
+ await this.deps.client.send(tabId, 'DOM.enable');
+ await this.deps.client.send(tabId, 'Overlay.enable');
+ this.overlay.add(tabId);
+ }
+ await this.deps.client.send(tabId, 'Overlay.highlightNode', {
+ backendNodeId: target.backend,
+ highlightConfig: { showInfo: false, contentColor: { r: 124, g: 58, b: 237, a: 0.15 }, borderColor: { r: 124, g: 58, b: 237, a: 0.9 } },
+ });
+ return { ok: true, op: 'highlight' };
+ }
+ case 'click': {
+ const target = await this.target(tabId, request.origins, request.generation, request.ref);
+ await this.axNode(tabId, target);
+ const point = await this.reachable(tabId, target);
+ await this.hideHighlight(tabId);
+ await this.input(tabId, async () => {
+ const base = { x: point.x, y: point.y, button: 'left', clickCount: 1 };
+ await this.deps.client.send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y });
+ await this.deps.client.send(tabId, 'Input.dispatchMouseEvent', { ...base, type: 'mousePressed', buttons: 1 });
+ await this.deps.client.send(tabId, 'Input.dispatchMouseEvent', { ...base, type: 'mouseReleased', buttons: 0 });
+ });
+ return { ok: true, op: 'click' };
+ }
+ case 'type': {
+ const target = await this.target(tabId, request.origins, request.generation, request.ref);
+ await this.axNode(tabId, target);
+ const dom = await this.describeDom(tabId, target);
+ if (isSensitiveNode(dom)) return fail('sensitive_field');
+ if (!isEditableNode(dom)) return fail('element_not_interactable');
+ await this.reachable(tabId, target);
+ await this.input(tabId, async () => {
+ await this.deps.client.send(tabId, 'DOM.focus', { backendNodeId: target.backend });
+ // Replace what is there, like the Standard adapter: select all, then insert (or delete).
+ await this.key(tabId, { key: 'a', code: 'KeyA', keyCode: 65 }, ['selectAll']);
+ if (request.text) await this.deps.client.send(tabId, 'Input.insertText', { text: request.text });
+ else await this.key(tabId, { key: 'Delete', code: 'Delete', keyCode: 46 });
+ if (request.submit) await this.key(tabId, KEYS.Enter);
+ });
+ return { ok: true, op: 'type' };
+ }
+ case 'press': {
+ const frames = await this.frames(tabId);
+ if (!request.origins.includes(frames.origins.get(frames.main) ?? '')) return fail('origin_not_granted');
+ await this.input(tabId, () => this.key(tabId, KEYS[request.key]));
+ return { ok: true, op: 'press' };
+ }
+ case 'screenshot':
+ return await this.screenshot(tabId, request.origins);
+ default:
+ // Scrolling, waiting for text, the focused element and the input watcher stay with the page script.
+ return fail('internal');
+ }
+ } catch (error) {
+ if (error instanceof CdpFailure) return fail(error.code);
+ return fail('internal');
+ }
+ }
+
+ // ---------- Refs ----------
+
+ private refsFor(tabId: number, generation: number): TabRefs {
+ const existing = this.refs.get(tabId);
+ if (existing && existing.generation === generation) return existing;
+ const fresh: TabRefs = { generation, next: 1, byRef: new Map(), byBackend: new Map() };
+ this.refs.set(tabId, fresh);
+ return fresh;
+ }
+
+ /** Resolves a ref of this generation and checks the page and the element's frame are in the grant. */
+ private async target(tabId: number, origins: string[], generation: number, ref: string): Promise {
+ const state = this.refs.get(tabId);
+ if (!state || state.generation !== generation) throw new CdpFailure('stale_ref', false);
+ const target = state.byRef.get(ref);
+ if (!target) throw new CdpFailure('element_not_found', false);
+ const frames = await this.frames(tabId);
+ if (!origins.includes(frames.origins.get(frames.main) ?? '')) throw new CdpFailure('origin_not_granted', false);
+ // The frame the element lives in must still exist and still show a granted origin.
+ if (!origins.includes(frames.origins.get(target.frame) ?? '')) throw new CdpFailure('origin_not_granted', false);
+ return target;
+ }
+
+ private async frames(tabId: number): Promise {
+ const { frameTree } = await this.deps.client.send<{ frameTree: FrameTreeNode }>(tabId, 'Page.getFrameTree');
+ const origins = new Map();
+ const visit = (node: FrameTreeNode) => {
+ origins.set(node.frame.id, urlOrigin(node.frame.url));
+ for (const child of node.childFrames ?? []) visit(child);
+ };
+ visit(frameTree);
+ return { main: frameTree.frame.id, mainUrl: frameTree.frame.url + (frameTree.frame.urlFragment ?? ''), origins };
+ }
+
+ private async document(tabId: number, mainFrame: string): Promise
) : null}
- {state.control !== 'active' ? {state.control === 'paused' ? t('browser.control.paused') : t('browser.control.stopped')}
: null}
+ {state.control !== 'active' ? (
+
+ {controlNotice(t, state)}
+
+ ) : null}
+
+
{state.connection === 'connected' ? (
<>
{state.device ? (
<>
+
>
diff --git a/src/sidepanel/sidepanel.css b/src/sidepanel/sidepanel.css
index 5dd7571..91b5050 100644
--- a/src/sidepanel/sidepanel.css
+++ b/src/sidepanel/sidepanel.css
@@ -475,6 +475,49 @@
color: var(--ink-2);
overflow-wrap: anywhere;
}
+.browser-activity-scope {
+ margin: 0;
+}
+.browser-check {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ cursor: pointer;
+}
+.browser-check input {
+ margin-top: 3px;
+ flex: none;
+}
+/* The same "AI" mark the toolbar icon shows on a tab an agent controls. */
+.browser-tab-marker {
+ flex: none;
+ padding: 0 4px;
+ border-radius: var(--radius-1, 4px);
+ background: var(--accent);
+ color: var(--paper);
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 16px;
+}
+.browser-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 2px;
+ font-size: 12px;
+}
+.browser-timeline-item {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ min-width: 0;
+ padding: 2px 0 2px var(--space-2);
+ border-left: 2px solid var(--rule);
+}
+.browser-timeline-item.failed {
+ border-left-color: color-mix(in oklch, var(--danger, #c0392b) 60%, var(--rule));
+}
/* ---------- Transcript ---------- */
.chat-main {
diff --git a/tests/unit/browser/cdp-adapter.test.ts b/tests/unit/browser/cdp-adapter.test.ts
new file mode 100644
index 0000000..d22335a
--- /dev/null
+++ b/tests/unit/browser/cdp-adapter.test.ts
@@ -0,0 +1,346 @@
+import { CdpAdapter, DISPATCH_GRACE_MS } from '@/browser/adapters/cdp/cdp-adapter';
+import { CDP_METHODS, CdpClient, type DebuggerApi } from '@/browser/adapters/cdp/cdp-client';
+import type { AXNode, DomNode } from '@/browser/adapters/cdp/cdp-outline';
+import type { ImageCodec, Rect } from '@/browser/adapters/cdp/screenshot';
+import type { DriverErrorCode } from '@/browser/adapters/page-driver';
+import { ORIGIN } from './fixtures';
+
+const ADS = 'https://ads.example.test';
+const SECRET = 'hunter2-e2e-secret';
+
+/** A small page as Chrome would describe it: the E2E shop with a same-origin frame and a cross-site ad frame. */
+function page() {
+ const frames = { main: `${ORIGIN}/cart`, inner: `${ORIGIN}/frame`, ads: `${ADS}/banner` };
+ const el = (backendNodeId: number, nodeName: string, attributes: string[] = [], extra: Partial = {}): DomNode => ({ nodeId: backendNodeId, backendNodeId, nodeType: 1, nodeName, attributes, ...extra });
+ const dom = (): DomNode => ({
+ nodeId: 1,
+ backendNodeId: 1,
+ nodeType: 9,
+ nodeName: '#document',
+ children: [
+ el(2, 'HTML', [], {
+ children: [
+ el(3, 'BODY', [], {
+ children: [
+ el(4, 'H1'),
+ el(5, 'INPUT', ['type', 'email', 'name', 'email', 'id', 'email']),
+ el(6, 'INPUT', ['type', 'password', 'name', 'password']),
+ el(7, 'BUTTON', ['id', 'next']),
+ el(10, 'LABEL', ['for', 'email'], { children: [el(11, 'SPAN')] }),
+ el(8, 'IFRAME', ['title', 'Coupons'], { frameId: 'F2', contentDocument: { nodeId: 20, backendNodeId: 20, nodeType: 9, nodeName: '#document', children: [el(21, 'BUTTON')] } }),
+ el(9, 'IFRAME', ['title', 'Ad'], { frameId: 'F3' }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ });
+ const ax = (nodeId: string, role: string, name: string, backend?: number, extra: Partial = {}): AXNode => ({
+ nodeId,
+ ignored: false,
+ role: { type: 'role', value: role },
+ name: { type: 'computedString', value: name },
+ ...(backend !== undefined ? { backendDOMNodeId: backend } : {}),
+ ...extra,
+ });
+ const main: AXNode[] = [
+ ax('1', 'RootWebArea', 'E2E Shop', 1, { childIds: ['2', '3', '4', '5', '6', '7', '8'] }),
+ ax('2', 'heading', 'Cart', 4, { properties: [{ name: 'level', value: { type: 'integer', value: 1 } }], parentId: '1' }),
+ ax('3', 'textbox', 'Email', 5, { value: { type: 'string', value: 'a@b.test' }, parentId: '1' }),
+ ax('4', 'textbox', 'Password', 6, { value: { type: 'string', value: SECRET }, parentId: '1' }),
+ ax('5', 'button', 'Next', 7, { parentId: '1' }),
+ ax('6', 'Iframe', 'Coupons', 8, { parentId: '1' }),
+ ax('7', 'Iframe', 'Ad', 9, { parentId: '1' }),
+ { nodeId: '8', ignored: true, parentId: '1', childIds: ['9'] },
+ ax('9', 'StaticText', 'Free shipping', undefined, { parentId: '8' }),
+ ];
+ const inner: AXNode[] = [ax('100', 'RootWebArea', 'Coupons', 20, { childIds: ['101'] }), ax('101', 'button', 'Apply coupon', 21, { parentId: '100' })];
+ return { frames, dom, main, inner };
+}
+
+function setup(options: { refuse?: () => DriverErrorCode | null; hit?: (target: number) => number; attachError?: string; size?: (scale: number, quality: number) => number } = {}) {
+ const p = page();
+ const sent: Array<{ method: string; params?: Record }> = [];
+ let lastQuads = 0;
+ let afterCapture: (() => void) | null = null;
+ const quad = (n: number) => [10, n * 20, 110, n * 20, 110, n * 20 + 10, 10, n * 20 + 10];
+ const reply = (method: string, params: Record = {}): unknown => {
+ switch (method) {
+ case 'Page.getFrameTree':
+ return {
+ frameTree: {
+ frame: { id: 'F1', url: p.frames.main },
+ childFrames: [{ frame: { id: 'F2', url: p.frames.inner } }, { frame: { id: 'F3', url: p.frames.ads } }],
+ },
+ };
+ case 'DOM.getDocument':
+ return { root: p.dom() };
+ case 'Accessibility.getFullAXTree':
+ if (params.frameId === 'F2') return { nodes: p.inner };
+ if (params.frameId === 'F3') throw new Error('Frame with the given id was not found.');
+ return { nodes: p.main };
+ case 'Accessibility.getPartialAXTree': {
+ const all = [...p.main, ...p.inner];
+ const node = all.find((n) => n.backendDOMNodeId === params.backendNodeId);
+ if (!node) throw new Error('No node with given id found');
+ return { nodes: [node] };
+ }
+ case 'DOM.describeNode': {
+ const find = (node: DomNode): DomNode | undefined => {
+ if (node.backendNodeId === params.backendNodeId) return node;
+ for (const child of [...(node.children ?? []), ...(node.contentDocument ? [node.contentDocument] : [])]) {
+ const hit = find(child);
+ if (hit) return hit;
+ }
+ return undefined;
+ };
+ const node = find(p.dom());
+ if (!node) throw new Error('No node with given id found');
+ return { node: { ...node, children: undefined, contentDocument: undefined } };
+ }
+ case 'DOM.getContentQuads':
+ lastQuads = params.backendNodeId as number;
+ return { quads: [quad(lastQuads)] };
+ case 'DOM.getNodeForLocation':
+ return { backendNodeId: options.hit ? options.hit(lastQuads) : lastQuads };
+ case 'Page.getLayoutMetrics':
+ return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 }, cssVisualViewport: { clientWidth: 800, clientHeight: 600 } };
+ case 'Page.captureScreenshot': {
+ const run = afterCapture;
+ afterCapture = null;
+ run?.();
+ return { data: 'iVBORw0KGgo=' };
+ }
+ default:
+ return {};
+ }
+ };
+ const debuggerApi: DebuggerApi = {
+ attach: vi.fn(async () => {
+ if (options.attachError) throw new Error(options.attachError);
+ }),
+ detach: vi.fn(async () => undefined),
+ sendCommand: vi.fn(async (_target, method, params) => {
+ sent.push({ method, params });
+ return reply(method, params);
+ }),
+ onDetach: { addListener: () => undefined },
+ };
+ const encoded: Array<{ scale: number; quality: number; redact: Rect[] }> = [];
+ const codec: ImageCodec = {
+ decode: async () => ({ width: 1600, height: 1200 }),
+ encode: async (_image, step) => {
+ encoded.push(step);
+ return new Uint8Array(options.size ? options.size(step.scale, step.quality) : 1000);
+ },
+ };
+ let now = 1_000;
+ const client = new CdpClient({ debugger: debuggerApi, check: async () => options.refuse?.() ?? null });
+ const adapter = new CdpAdapter({ client, codec, now: () => now });
+ const methods = () => sent.map((s) => s.method);
+ return {
+ adapter,
+ sent,
+ methods,
+ encoded,
+ debuggerApi,
+ frames: p.frames,
+ setNow: (value: number) => (now = value),
+ afterCapture: (run: () => void) => (afterCapture = run),
+ };
+}
+
+const origins = [ORIGIN];
+
+async function snapshot(t: ReturnType, generation = 1) {
+ const response = await t.adapter.run(1, { op: 'snapshot', origins, generation, maxChars: 20_000 });
+ if (!response.ok || response.op !== 'snapshot') throw new Error(`snapshot failed: ${JSON.stringify(response)}`);
+ return response;
+}
+
+describe('CdpAdapter: snapshot', () => {
+ it('writes the accessibility tree in the Standard outline format, redacting sensitive values', async () => {
+ const t = setup();
+ const result = await snapshot(t);
+ expect(result).toMatchObject({ url: `${ORIGIN}/cart`, title: 'E2E Shop' });
+ expect(result.text.split('\n')).toEqual([
+ 'heading "Cart" level=1',
+ '[e1] textbox "Email" value="a@b.test"',
+ '[e2] textbox "Password" type=password value=[redacted]',
+ '[e3] button "Next"',
+ 'frame "Coupons"',
+ ' [e4] button "Apply coupon"',
+ 'text "Free shipping"',
+ ]);
+ expect(result.text).not.toContain(SECRET);
+ // The cross-site ad frame is neither read nor listed.
+ expect(result.text).not.toContain('"Ad"');
+ expect(t.sent.filter((s) => s.method === 'Accessibility.getFullAXTree').map((s) => s.params?.frameId)).toEqual([undefined, 'F2']);
+ });
+
+ it('refuses a page outside the grant', async () => {
+ const t = setup();
+ await expect(t.adapter.run(1, { op: 'snapshot', origins: ['https://other.example.test'], generation: 1, maxChars: 20_000 })).resolves.toEqual({ ok: false, code: 'origin_not_granted' });
+ expect(t.methods()).not.toContain('Accessibility.getFullAXTree');
+ });
+
+ it('keeps refs stable within a generation and starts over on the next', async () => {
+ const t = setup();
+ await snapshot(t);
+ const again = await snapshot(t);
+ expect(again.text).toContain('[e3] button "Next"');
+ await expect(t.adapter.run(1, { op: 'click', origins, generation: 2, ref: 'e3' })).resolves.toEqual({ ok: false, code: 'stale_ref' });
+ });
+
+ it('only sends whitelisted methods', async () => {
+ const t = setup();
+ await snapshot(t);
+ await t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e3' });
+ await t.adapter.run(1, { op: 'type', origins, generation: 1, ref: 'e1', text: 'x', submit: false });
+ await t.adapter.run(1, { op: 'screenshot', origins });
+ for (const method of t.methods()) expect(CDP_METHODS).toContain(method);
+ });
+});
+
+describe('CdpAdapter: click, type and press', () => {
+ it('clicks the centre of the element with trusted mouse events', async () => {
+ const t = setup();
+ await snapshot(t);
+ await expect(t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e3' })).resolves.toEqual({ ok: true, op: 'click' });
+ const mouse = t.sent.filter((s) => s.method === 'Input.dispatchMouseEvent');
+ expect(mouse.map((s) => s.params?.type)).toEqual(['mouseMoved', 'mousePressed', 'mouseReleased']);
+ expect(mouse[1]?.params).toMatchObject({ x: 60, y: 145, button: 'left', clickCount: 1 });
+ });
+
+ it('refuses an element covered by something else, and allows its own label', async () => {
+ const covered = setup({ hit: () => 4 });
+ await snapshot(covered);
+ await expect(covered.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e3' })).resolves.toEqual({ ok: false, code: 'element_not_interactable' });
+ expect(covered.methods()).not.toContain('Input.dispatchMouseEvent');
+
+ const label = setup({ hit: (target) => (target === 5 ? 11 : target) });
+ await snapshot(label);
+ await expect(label.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e1' })).resolves.toEqual({ ok: true, op: 'click' });
+ });
+
+ it('answers element_not_found for an unknown ref', async () => {
+ const t = setup();
+ await snapshot(t);
+ await expect(t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e99' })).resolves.toEqual({ ok: false, code: 'element_not_found' });
+ });
+
+ it('refuses an element in a frame that no longer shows a granted origin', async () => {
+ const t = setup();
+ await snapshot(t);
+ t.frames.inner = `${ADS}/swapped`;
+ await expect(t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e4' })).resolves.toEqual({ ok: false, code: 'origin_not_granted' });
+ expect(t.methods()).not.toContain('Input.dispatchMouseEvent');
+ });
+
+ it('never types into a password field', async () => {
+ const t = setup();
+ await snapshot(t);
+ await expect(t.adapter.run(1, { op: 'type', origins, generation: 1, ref: 'e2', text: 'guess', submit: false })).resolves.toEqual({ ok: false, code: 'sensitive_field' });
+ expect(t.methods()).not.toContain('Input.insertText');
+ expect(t.methods()).not.toContain('DOM.focus');
+ });
+
+ it('replaces the text of a field and submits with Enter when asked', async () => {
+ const t = setup();
+ await snapshot(t);
+ await expect(t.adapter.run(1, { op: 'type', origins, generation: 1, ref: 'e1', text: 'me@example.test', submit: true })).resolves.toEqual({ ok: true, op: 'type' });
+ const input = t.sent.filter((s) => s.method.startsWith('Input.') || s.method === 'DOM.focus');
+ expect(input.map((s) => `${s.method}:${String(s.params?.type ?? s.params?.text ?? '')}`)).toEqual([
+ 'DOM.focus:',
+ 'Input.dispatchKeyEvent:rawKeyDown',
+ 'Input.dispatchKeyEvent:keyUp',
+ 'Input.insertText:me@example.test',
+ 'Input.dispatchKeyEvent:keyDown',
+ 'Input.dispatchKeyEvent:keyUp',
+ ]);
+ expect(input[1]?.params).toMatchObject({ commands: ['selectAll'] });
+ });
+
+ it('describes an element from the accessibility tree and the DOM', async () => {
+ const t = setup();
+ await snapshot(t);
+ await expect(t.adapter.run(1, { op: 'describe', origins, generation: 1, ref: 'e2' })).resolves.toEqual({ ok: true, op: 'describe', role: 'textbox', name: 'Password', sensitive: true, editable: true });
+ });
+
+ it('presses a key on a granted page only', async () => {
+ const t = setup();
+ await expect(t.adapter.run(1, { op: 'press', origins, key: 'Tab' })).resolves.toEqual({ ok: true, op: 'press' });
+ await expect(t.adapter.run(1, { op: 'press', origins: ['https://other.example.test'], key: 'Tab' })).resolves.toEqual({ ok: false, code: 'origin_not_granted' });
+ });
+
+ it('counts its own input as not the person’s while it is sent and shortly after', async () => {
+ const t = setup();
+ await snapshot(t);
+ expect(t.adapter.isDispatching(1)).toBe(false);
+ await t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e3' });
+ expect(t.adapter.isDispatching(1)).toBe(true);
+ t.setNow(1_000 + DISPATCH_GRACE_MS + 1);
+ expect(t.adapter.isDispatching(1)).toBe(false);
+ });
+
+ it('stops before input when the policy check refuses mid-action (revoked while running)', async () => {
+ let refusal: DriverErrorCode | null = null;
+ const t = setup({ refuse: () => refusal });
+ await snapshot(t);
+ let calls = 0;
+ const original = t.debuggerApi.sendCommand;
+ t.debuggerApi.sendCommand = vi.fn(async (target, method, params) => {
+ calls += 1;
+ if (method === 'DOM.getContentQuads') refusal = 'grant_revoked';
+ return original(target, method, params);
+ });
+ await expect(t.adapter.run(1, { op: 'click', origins, generation: 1, ref: 'e3' })).resolves.toEqual({ ok: false, code: 'grant_revoked' });
+ expect(calls).toBeGreaterThan(0);
+ expect(t.methods()).not.toContain('Input.dispatchMouseEvent');
+ expect(t.debuggerApi.detach).toHaveBeenCalledWith({ tabId: 1 });
+ });
+
+ it('answers debugger_unavailable when Chrome will not attach', async () => {
+ const t = setup({ attachError: 'Another debugger is already attached' });
+ await expect(t.adapter.prepare(1)).resolves.toBe('debugger_unavailable');
+ await expect(t.adapter.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 1000 })).resolves.toEqual({ ok: false, code: 'debugger_unavailable' });
+ });
+});
+
+describe('CdpAdapter: screenshot', () => {
+ it('captures the visible viewport only and blacks out sensitive fields and ungranted frames', async () => {
+ const t = setup();
+ const response = await t.adapter.run(1, { op: 'screenshot', origins });
+ expect(response).toMatchObject({ ok: true, op: 'screenshot' });
+ expect(t.sent.find((s) => s.method === 'Page.captureScreenshot')?.params).toEqual({ format: 'png', fromSurface: true, captureBeyondViewport: false });
+ const redact = t.encoded[0]?.redact ?? [];
+ // Device pixel ratio 2 (1600 px image, 800 css px): the password field (node 6) and the ad frame (node 9).
+ expect(redact).toEqual([
+ { x: 16, y: 236, width: 208, height: 28 },
+ { x: 16, y: 356, width: 208, height: 28 },
+ ]);
+ });
+
+ it('lowers quality, then size, until it fits the Gateway limit', async () => {
+ const t = setup({ size: (scale, quality) => Math.round(4_000_000 * scale * scale * quality) });
+ const response = await t.adapter.run(1, { op: 'screenshot', origins });
+ expect(response).toMatchObject({ ok: true });
+ expect(t.encoded.map((s) => [s.scale, s.quality])).toEqual([
+ [1, 0.8],
+ [1, 0.6],
+ [0.75, 0.6],
+ ]);
+ });
+
+ it('refuses when the tab navigated to another site while it was captured', async () => {
+ const t = setup();
+ t.afterCapture(() => (t.frames.main = 'https://elsewhere.example.test/'));
+ await expect(t.adapter.run(1, { op: 'screenshot', origins })).resolves.toEqual({ ok: false, code: 'origin_not_granted' });
+ expect(t.encoded).toEqual([]);
+ });
+
+ it('fails rather than sending an image over the limit', async () => {
+ const t = setup({ size: () => 2_000_000 });
+ await expect(t.adapter.run(1, { op: 'screenshot', origins })).resolves.toEqual({ ok: false, code: 'internal' });
+ });
+});
diff --git a/tests/unit/browser/cdp-client.test.ts b/tests/unit/browser/cdp-client.test.ts
new file mode 100644
index 0000000..5f2e14a
--- /dev/null
+++ b/tests/unit/browser/cdp-client.test.ts
@@ -0,0 +1,140 @@
+import { CDP_METHODS, CdpClient, CdpFailure, isAllowedCdpMethod, type CdpMethod, type DebuggerApi } from '@/browser/adapters/cdp/cdp-client';
+import type { DriverErrorCode } from '@/browser/adapters/page-driver';
+
+function fakeDebugger(options: { attachError?: string; reply?: (method: string) => unknown } = {}) {
+ let detachListener: (source: { tabId?: number }, reason: string) => void = () => undefined;
+ const api = {
+ attach: vi.fn(async () => {
+ if (options.attachError) throw new Error(options.attachError);
+ }),
+ detach: vi.fn(async () => undefined),
+ sendCommand: vi.fn(async (_target: { tabId: number }, method: string) => options.reply?.(method) ?? {}),
+ onDetach: { addListener: (callback: typeof detachListener) => (detachListener = callback) },
+ } satisfies DebuggerApi;
+ return { api, chromeDetaches: (tabId: number, reason: string) => detachListener({ tabId }, reason) };
+}
+
+function client(options: { refuse?: DriverErrorCode | null; attachError?: string; reply?: (method: string) => unknown; timeoutMs?: number } = {}) {
+ const fake = fakeDebugger(options);
+ let refusal: DriverErrorCode | null = options.refuse ?? null;
+ const check = vi.fn(async () => refusal);
+ const onDetached = vi.fn();
+ const instance = new CdpClient({ debugger: fake.api, check, onDetached, timeoutMs: options.timeoutMs });
+ return { instance, fake, check, onDetached, refuse: (code: DriverErrorCode | null) => (refusal = code) };
+}
+
+describe('CDP method whitelist', () => {
+ it('is exactly the methods the adapter needs', () => {
+ expect([...CDP_METHODS].sort()).toEqual(
+ [
+ 'Accessibility.getFullAXTree',
+ 'Accessibility.getPartialAXTree',
+ 'DOM.describeNode',
+ 'DOM.enable',
+ 'DOM.focus',
+ 'DOM.getContentQuads',
+ 'DOM.getDocument',
+ 'DOM.getNodeForLocation',
+ 'DOM.scrollIntoViewIfNeeded',
+ 'Input.dispatchKeyEvent',
+ 'Input.dispatchMouseEvent',
+ 'Input.insertText',
+ 'Overlay.enable',
+ 'Overlay.hideHighlight',
+ 'Overlay.highlightNode',
+ 'Page.captureScreenshot',
+ 'Page.getFrameTree',
+ 'Page.getLayoutMetrics',
+ ].sort(),
+ );
+ });
+
+ it('has no script evaluation, network, storage, cookies, targets or navigation', () => {
+ for (const method of CDP_METHODS) expect(method).not.toMatch(/^(Runtime|Network|Storage|Fetch|Target|Browser|Debugger|IndexedDB|CacheStorage|ServiceWorker|Emulation|Security)\./);
+ for (const method of ['Runtime.evaluate', 'Runtime.callFunctionOn', 'Network.getCookies', 'Network.getAllCookies', 'Storage.getCookies', 'Page.navigate', 'Page.bringToFront', 'Target.attachToTarget', 'DOM.setAttributeValue', 'DOM.getOuterHTML', 'Input.dispatchDragEvent']) {
+ expect(isAllowedCdpMethod(method)).toBe(false);
+ }
+ });
+
+ it('refuses a method outside the list at runtime, even through a cast, without calling Chrome', async () => {
+ const t = client();
+ await t.instance.attach(1);
+ await expect(t.instance.send(1, 'Runtime.evaluate' as CdpMethod, { expression: '1' })).rejects.toMatchObject({ code: 'internal' });
+ expect(t.fake.api.sendCommand).not.toHaveBeenCalled();
+ });
+});
+
+describe('CdpClient', () => {
+ it('attaches to the tab target only after the policy check', async () => {
+ const t = client({ refuse: 'device_paused' });
+ await expect(t.instance.attach(1)).rejects.toMatchObject({ code: 'device_paused', beforeEffect: true });
+ expect(t.fake.api.attach).not.toHaveBeenCalled();
+ t.refuse(null);
+ await t.instance.attach(1);
+ expect(t.fake.api.attach).toHaveBeenCalledWith({ tabId: 1 }, '1.3');
+ expect(t.instance.attachedTabs).toEqual([1]);
+ });
+
+ it('answers debugger_unavailable when Chrome refuses to attach (DevTools or another debugger)', async () => {
+ const t = client({ attachError: 'Another debugger is already attached to the tab with id: 1.' });
+ const error = await t.instance.attach(1).catch((e: unknown) => e);
+ expect(error).toBeInstanceOf(CdpFailure);
+ expect(error).toMatchObject({ code: 'debugger_unavailable', beforeEffect: true });
+ expect(t.instance.isAttached(1)).toBe(false);
+ });
+
+ it('attaches once when two steps ask at the same time', async () => {
+ const t = client();
+ await Promise.all([t.instance.attach(1), t.instance.attach(1)]);
+ expect(t.fake.api.attach).toHaveBeenCalledTimes(1);
+ });
+
+ it('checks the policy before every command and detaches on a refusal', async () => {
+ const t = client({ reply: () => ({ nodes: [] }) });
+ await t.instance.attach(1);
+ await expect(t.instance.send(1, 'Accessibility.getFullAXTree')).resolves.toEqual({ nodes: [] });
+ t.refuse('grant_revoked');
+ await expect(t.instance.send(1, 'Accessibility.getFullAXTree')).rejects.toMatchObject({ code: 'grant_revoked' });
+ expect(t.fake.api.detach).toHaveBeenCalledWith({ tabId: 1 });
+ expect(t.instance.isAttached(1)).toBe(false);
+ expect(t.fake.api.sendCommand).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends nothing to a tab it is not attached to', async () => {
+ const t = client();
+ await expect(t.instance.send(1, 'Page.getFrameTree')).rejects.toMatchObject({ code: 'debugger_unavailable' });
+ expect(t.fake.api.sendCommand).not.toHaveBeenCalled();
+ });
+
+ it('forgets a session Chrome ended and reports why', async () => {
+ const t = client();
+ await t.instance.attach(1);
+ t.fake.chromeDetaches(1, 'canceled_by_user');
+ expect(t.instance.isAttached(1)).toBe(false);
+ expect(t.onDetached).toHaveBeenCalledWith(1, 'canceled_by_user');
+ // A session it never held is not reported.
+ t.fake.chromeDetaches(2, 'target_closed');
+ expect(t.onDetached).toHaveBeenCalledTimes(1);
+ });
+
+ it('times out a command that never answers', async () => {
+ const fake = fakeDebugger();
+ fake.api.sendCommand.mockImplementation(() => new Promise(() => undefined));
+ const instance = new CdpClient({ debugger: fake.api, check: async () => null, timeoutMs: 20 });
+ await instance.attach(1);
+ await expect(instance.send(1, 'Page.getFrameTree')).rejects.toMatchObject({ code: 'timeout' });
+ });
+
+ it('detaches every tab, and a forced detach asks Chrome even for a tab it does not remember', async () => {
+ const t = client();
+ await t.instance.attach(1);
+ await t.instance.attach(2);
+ await t.instance.detachAll();
+ expect(t.instance.attachedTabs).toEqual([]);
+ expect(t.fake.api.detach).toHaveBeenCalledTimes(2);
+ await t.instance.detach(3);
+ expect(t.fake.api.detach).toHaveBeenCalledTimes(2);
+ await t.instance.detach(3, true);
+ expect(t.fake.api.detach).toHaveBeenLastCalledWith({ tabId: 3 });
+ });
+});
diff --git a/tests/unit/browser/engine-router.test.ts b/tests/unit/browser/engine-router.test.ts
new file mode 100644
index 0000000..998aec1
--- /dev/null
+++ b/tests/unit/browser/engine-router.test.ts
@@ -0,0 +1,117 @@
+import type { DriverErrorCode, DriverRequest, DriverResponse } from '@/browser/adapters/page-driver';
+import type { BrowserApi } from '@/browser/chrome-api';
+import { EngineRouter, type AdvancedEngine, type DebuggerPrefs } from '@/browser/engine-router';
+import { ORIGIN } from './fixtures';
+
+const origins = [ORIGIN];
+
+function setup(options: { prefs?: DebuggerPrefs; attach?: DriverErrorCode | null; withCdp?: boolean } = {}) {
+ const log: string[] = [];
+ const answer = (engine: string, request: DriverRequest): DriverResponse => {
+ log.push(`${engine}:${request.op}`);
+ if (request.op === 'snapshot') return { ok: true, op: 'snapshot', text: engine, url: `${ORIGIN}/`, title: '' };
+ if (request.op === 'screenshot') return engine === 'cdp' ? { ok: true, op: 'screenshot', data: 'x' } : { ok: false, code: 'unsupported_capability' };
+ return { ok: true, op: request.op } as DriverResponse;
+ };
+ const standard: BrowserApi = {
+ createTab: async () => 1,
+ updateTab: async () => undefined,
+ removeTab: async () => undefined,
+ getTab: async () => null,
+ hasHostPermission: async () => true,
+ run: async (_tabId, request) => answer('standard', request),
+ };
+ let attach: DriverErrorCode | null = options.attach ?? null;
+ const cdp: AdvancedEngine = {
+ attachedTabs: [],
+ prepare: vi.fn(async () => attach),
+ run: vi.fn(async (_tabId: number, request: DriverRequest) => (attach ? { ok: false, code: attach } : answer('cdp', request)) as DriverResponse),
+ release: vi.fn(async () => undefined),
+ forget: vi.fn(),
+ forceDetach: vi.fn(async () => undefined),
+ detachAll: vi.fn(async () => undefined),
+ isDispatching: () => false,
+ };
+ let prefs: DebuggerPrefs = options.prefs ?? { enabled: true, fallback: true };
+ const router = new EngineRouter(standard, () => prefs, options.withCdp === false ? undefined : cdp);
+ return { router, log, cdp, setPrefs: (next: DebuggerPrefs) => (prefs = next), setAttach: (code: DriverErrorCode | null) => (attach = code) };
+}
+
+describe('EngineRouter', () => {
+ it('uses Standard for everything when the debugger is off, and never screenshots', async () => {
+ const t = setup({ prefs: { enabled: false, fallback: true } });
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ await t.router.run(1, { op: 'click', origins, generation: 1, ref: 'e1' });
+ await expect(t.router.run(1, { op: 'screenshot', origins })).resolves.toEqual({ ok: false, code: 'unsupported_capability' });
+ expect(t.log).toEqual(['standard:snapshot', 'standard:click']);
+ expect(t.cdp.prepare).not.toHaveBeenCalled();
+ });
+
+ it('has no Advanced engine in a Standard build', async () => {
+ const t = setup({ withCdp: false });
+ expect(t.router.advancedAvailable).toBe(false);
+ await expect(t.router.run(1, { op: 'screenshot', origins })).resolves.toEqual({ ok: false, code: 'unsupported_capability' });
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ expect(t.log).toEqual(['standard:snapshot']);
+ });
+
+ it('sends ref actions to the engine whose snapshot handed out the refs', async () => {
+ const t = setup();
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ await t.router.run(1, { op: 'click', origins, generation: 1, ref: 'e1' });
+ // Scrolling and waiting stay with the page script.
+ await t.router.run(1, { op: 'scroll', origins, direction: 'down', amount: 'page' });
+ await t.router.run(1, { op: 'findText', origins, text: 'x' });
+ expect(t.log).toEqual(['cdp:snapshot', 'cdp:click', 'standard:scroll', 'standard:findText']);
+ });
+
+ it('makes refs stale when the engine changes within a generation', async () => {
+ const t = setup({ prefs: { enabled: false, fallback: true } });
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ t.setPrefs({ enabled: true, fallback: true });
+ await expect(t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 })).resolves.toEqual({ ok: false, code: 'stale_ref' });
+ await expect(t.router.run(1, { op: 'snapshot', origins, generation: 2, maxChars: 100 })).resolves.toMatchObject({ ok: true, text: 'cdp' });
+ // Turned off again: debugger refs are not resolved by the page script.
+ t.setPrefs({ enabled: false, fallback: true });
+ await expect(t.router.run(1, { op: 'click', origins, generation: 2, ref: 'e1' })).resolves.toEqual({ ok: false, code: 'stale_ref' });
+ expect(t.log).not.toContain('standard:click');
+ });
+
+ it('falls back to Standard when the debugger cannot attach and fallback is allowed', async () => {
+ const t = setup({ attach: 'debugger_unavailable' });
+ await expect(t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 })).resolves.toMatchObject({ ok: true, text: 'standard' });
+ await t.router.run(1, { op: 'press', origins, key: 'Tab' });
+ expect(t.log).toEqual(['standard:snapshot', 'standard:press']);
+ });
+
+ it('answers debugger_unavailable without fallback, and for screenshots always', async () => {
+ const t = setup({ prefs: { enabled: true, fallback: false }, attach: 'debugger_unavailable' });
+ await expect(t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 })).resolves.toEqual({ ok: false, code: 'debugger_unavailable' });
+ await expect(t.router.run(1, { op: 'press', origins, key: 'Tab' })).resolves.toEqual({ ok: false, code: 'debugger_unavailable' });
+ t.setPrefs({ enabled: true, fallback: true });
+ await expect(t.router.run(1, { op: 'screenshot', origins })).resolves.toEqual({ ok: false, code: 'debugger_unavailable' });
+ expect(t.log).toEqual([]);
+ });
+
+ it('asks for a new snapshot when the debugger is lost before a click on its refs', async () => {
+ const t = setup();
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ t.setAttach('debugger_unavailable');
+ await expect(t.router.run(1, { op: 'describe', origins, generation: 1, ref: 'e1' })).resolves.toEqual({ ok: false, code: 'stale_ref' });
+ });
+
+ it('never falls back on a policy refusal', async () => {
+ const t = setup({ attach: 'grant_revoked' });
+ await expect(t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 })).resolves.toEqual({ ok: false, code: 'grant_revoked' });
+ expect(t.log).toEqual([]);
+ });
+
+ it('forgets a tab and lets the debugger go', async () => {
+ const t = setup();
+ await t.router.run(1, { op: 'snapshot', origins, generation: 1, maxChars: 100 });
+ await t.router.forget(1);
+ expect(t.cdp.forget).toHaveBeenCalledWith(1);
+ expect(t.cdp.release).toHaveBeenCalledWith(1);
+ expect(t.router.engineFor(1, 1)).toBe('standard');
+ });
+});
diff --git a/tests/unit/browser/executor.test.ts b/tests/unit/browser/executor.test.ts
index 5f111b3..6dd2631 100644
--- a/tests/unit/browser/executor.test.ts
+++ b/tests/unit/browser/executor.test.ts
@@ -2,9 +2,12 @@ import { ApprovalBroker } from '@/browser/approvals';
import type { BrowserApi, TabInfo } from '@/browser/chrome-api';
import { Executor, type ExecutionContext } from '@/browser/executor';
import { LocalGuard, type ControlState } from '@/browser/policy';
+import type { DriverRequest, DriverResponse } from '@/browser/adapters/page-driver';
import type { BrowserCommand, Grant } from '@/browser/protocol';
-import type { PageRequest, PageResponse } from '@/browser/snapshot';
import { TabRegistry } from '@/browser/tabs';
+
+type PageRequest = DriverRequest;
+type PageResponse = DriverResponse;
import { AGENT_ID, command, grant, GRANT_ID, NOW, ORIGIN } from './fixtures';
import { flush } from './fake-socket';
@@ -19,11 +22,12 @@ function pendingId(approvals: ApprovalBroker): string {
return first.id;
}
-function setup(options: { grant?: Grant; page?: (request: PageRequest) => PageResponse; redirect?: string } = {}) {
+function setup(options: { grant?: Grant; page?: (request: DriverRequest) => DriverResponse; redirect?: string; screenshots?: boolean } = {}) {
const browserTabs = new Map();
let nextTabId = 100;
- const pageCalls: PageRequest[] = [];
- const api: BrowserApi = {
+ const pageCalls: DriverRequest[] = [];
+ const api: BrowserApi & { release: ReturnType } = {
+ release: vi.fn(async () => undefined),
createTab: vi.fn(async (url: string) => {
const id = nextTabId++;
browserTabs.set(id, { id, url: options.redirect ?? url, title: 'Shop', status: 'complete', active: false });
@@ -40,14 +44,15 @@ function setup(options: { grant?: Grant; page?: (request: PageRequest) => PageRe
const tab = browserTabs.get(tabId);
return tab ? { ...tab } : null;
},
- run: vi.fn(async (_tabId: number, request: PageRequest) => {
+ run: vi.fn(async (_tabId: number, request: DriverRequest): Promise => {
pageCalls.push(request);
if (options.page) return options.page(request);
if (request.op === 'describe') return { ok: true, op: 'describe', role: 'button', name: 'Next', sensitive: false, editable: false } as const;
if (request.op === 'focused') return { ok: true, op: 'describe', role: 'textbox', name: 'Message', sensitive: false, editable: true } as const;
if (request.op === 'snapshot') return { ok: true, op: 'snapshot', text: '[e1] button "Next"', url: `${ORIGIN}/`, title: 'Shop' } as const;
if (request.op === 'findText') return { ok: true, op: 'findText', found: true } as const;
- return { ok: true, op: request.op } as PageResponse;
+ if (request.op === 'screenshot') return { ok: true, op: 'screenshot', data: '/9j/AAAA' } as const;
+ return { ok: true, op: request.op } as DriverResponse;
}),
hasHostPermission: vi.fn(async (origin: string) => origin === ORIGIN),
};
@@ -71,6 +76,7 @@ function setup(options: { grant?: Grant; page?: (request: PageRequest) => PageRe
approvals,
grant: (id) => (current?.grant_id === id ? current : undefined),
control: () => control,
+ screenshots: () => options.screenshots ?? false,
onActivity: (activity, running) => activities.push(`${running ? 'start' : 'end'}:${activity.action}`),
onTabsChanged: () => undefined,
now: () => NOW,
@@ -123,7 +129,7 @@ describe('Executor: open', () => {
await expect(promise).resolves.toEqual({ tab: 't1', url: `${ORIGIN}/products`, title: 'Shop' });
expect(context.sideEffect).toBe(true);
expect(t.tabs.get('t1')).toMatchObject({ tabId: 100, grantId: GRANT_ID });
- expect(t.activities).toEqual(['start:open', 'start:open', 'end:open']);
+ expect(t.activities).toEqual(['start:open', 'start:open', 'start:open', 'end:open']);
});
it('refuses a site outside the grant without opening anything', async () => {
@@ -165,10 +171,38 @@ describe('Executor: local policy', () => {
await expect(t.run(command('navigate', { tab: 't1', url: `${ORIGIN}/` })).promise).rejects.toMatchObject({ code: 'origin_not_granted' });
});
- it('refuses screenshots', async () => {
+ it('refuses screenshots unless the debugger is on', async () => {
const t = setup();
t.ownTab();
await expect(t.run(command('screenshot', { tab: 't1' })).promise).rejects.toMatchObject({ code: 'unsupported_capability' });
+ expect(opsOf(t.pageCalls)).toEqual([]);
+ });
+
+ it('takes a screenshot of a granted page and lets go of the tab after', async () => {
+ const t = setup({ screenshots: true });
+ t.ownTab();
+ await expect(t.run(command('screenshot', { tab: 't1' })).promise).resolves.toEqual({ tab: 't1', url: `${ORIGIN}/cart`, mime: 'image/jpeg', data_base64: '/9j/AAAA' });
+ expect(t.api.release).toHaveBeenCalledWith(7);
+ });
+
+ it('refuses a screenshot of a page that left the grant, before taking it', async () => {
+ const t = setup({ screenshots: true });
+ t.ownTab('https://elsewhere.example.test/');
+ await expect(t.run(command('screenshot', { tab: 't1' })).promise).rejects.toMatchObject({ code: 'origin_not_granted' });
+ expect(opsOf(t.pageCalls)).toEqual([]);
+ });
+
+ it('passes on the adapter refusing a screenshot (debugger busy)', async () => {
+ const t = setup({ screenshots: true, page: () => ({ ok: false, code: 'debugger_unavailable' }) });
+ t.ownTab();
+ await expect(t.run(command('screenshot', { tab: 't1' })).promise).rejects.toMatchObject({ code: 'debugger_unavailable' });
+ expect(t.api.release).toHaveBeenCalledWith(7);
+ });
+
+ it('refuses a screenshot above the Gateway limit', async () => {
+ const t = setup({ screenshots: true, page: () => ({ ok: true, op: 'screenshot', data: 'A'.repeat(1536 * 1024 * 2) }) });
+ t.ownTab();
+ await expect(t.run(command('screenshot', { tab: 't1' })).promise).rejects.toMatchObject({ code: 'internal' });
});
it('allows one command per tab at a time', async () => {
@@ -202,7 +236,22 @@ describe('Executor: click and type', () => {
const t = setup();
t.ownTab();
await expect(t.run(command('click', { tab: 't1', ref: 'e1', generation: 1 })).promise).resolves.toEqual({ tab: 't1', url: `${ORIGIN}/cart`, title: 'Cart' });
- expect(opsOf(t.pageCalls)).toEqual(['watch', 'describe', 'click']);
+ expect(opsOf(t.pageCalls)).toEqual(['watch', 'describe', 'highlight', 'click']);
+ expect(t.api.release).toHaveBeenCalledWith(7);
+ });
+
+ it('still clicks when the highlight cannot be shown', async () => {
+ const t = setup({
+ page: (request) =>
+ request.op === 'highlight'
+ ? { ok: false, code: 'internal' }
+ : request.op === 'describe'
+ ? { ok: true, op: 'describe', role: 'button', name: 'Next', sensitive: false, editable: false }
+ : ({ ok: true, op: request.op } as PageResponse),
+ });
+ t.ownTab();
+ await expect(t.run(command('click', { tab: 't1', ref: 'e1', generation: 1 })).promise).resolves.toMatchObject({ tab: 't1' });
+ expect(opsOf(t.pageCalls)).toEqual(['watch', 'describe', 'highlight', 'click']);
});
it('denies a high-risk click when no side panel is open', async () => {
@@ -308,6 +357,13 @@ describe('Executor: approvals', () => {
expect(opsOf(t.pageCalls)).toEqual(['watch', 'press']);
});
+ it('refuses Backspace in a password field', async () => {
+ const t = setup({ page: describing(() => ({ role: 'textbox', name: 'Password', sensitive: true, editable: true })) });
+ t.ownTab();
+ await expect(t.run(command('press', { tab: 't1', key: 'Backspace' })).promise).rejects.toMatchObject({ code: 'sensitive_field' });
+ expect(opsOf(t.pageCalls)).not.toContain('press');
+ });
+
it('refuses Enter when focus moved while the person decided', async () => {
const names = ['Search', 'Send payment'];
const t = setup({ page: describing(() => ({ role: 'textbox', name: names.shift() ?? 'Send payment', editable: true })) });
diff --git a/tests/unit/browser/screenshot.test.ts b/tests/unit/browser/screenshot.test.ts
new file mode 100644
index 0000000..59a0332
--- /dev/null
+++ b/tests/unit/browser/screenshot.test.ts
@@ -0,0 +1,59 @@
+import { bytesToBase64, fitScreenshot, quadsToRects, SCREENSHOT_STEPS, type ImageCodec } from '@/browser/adapters/cdp/screenshot';
+import { MAX_SCREENSHOT_BYTES } from '@/browser/adapters/page-driver';
+
+function codec(size: (scale: number, quality: number) => number) {
+ const steps: Array<[number, number]> = [];
+ const value: ImageCodec = {
+ decode: async () => ({ width: 2000, height: 1000 }),
+ encode: async (_image, { scale, quality }) => {
+ steps.push([scale, quality]);
+ return new Uint8Array(size(scale, quality));
+ },
+ };
+ return { value, steps };
+}
+
+describe('screenshot sizing', () => {
+ it('keeps the first encoding that fits the 1.5 MiB limit', async () => {
+ const c = codec(() => 1_000_000);
+ const bytes = await fitScreenshot(c.value, { width: 2000, height: 1000 }, []);
+ expect(bytes?.length).toBe(1_000_000);
+ expect(c.steps).toEqual([[1, 0.8]]);
+ });
+
+ it('lowers quality before size, and stops at the first fit', async () => {
+ const c = codec((scale, quality) => Math.round(5_000_000 * scale * scale * quality));
+ const bytes = await fitScreenshot(c.value, { width: 2000, height: 1000 }, []);
+ expect(bytes?.length).toBeLessThanOrEqual(MAX_SCREENSHOT_BYTES);
+ expect(c.steps).toEqual([
+ [1, 0.8],
+ [1, 0.6],
+ [0.75, 0.6],
+ [0.5, 0.6],
+ ]);
+ });
+
+ it('gives up after the smallest step instead of sending too much', async () => {
+ const c = codec(() => MAX_SCREENSHOT_BYTES + 1);
+ await expect(fitScreenshot(c.value, { width: 2000, height: 1000 }, [])).resolves.toBeNull();
+ expect(c.steps).toHaveLength(SCREENSHOT_STEPS.length);
+ });
+
+ it('accepts exactly the limit', async () => {
+ const c = codec(() => MAX_SCREENSHOT_BYTES);
+ await expect(fitScreenshot(c.value, { width: 1, height: 1 }, [])).resolves.toHaveLength(MAX_SCREENSHOT_BYTES);
+ });
+
+ it('turns CSS quads into padded image rectangles clipped to the image', () => {
+ expect(quadsToRects([[10, 10, 50, 10, 50, 30, 10, 30]], 2, { width: 1000, height: 1000 })).toEqual([{ x: 16, y: 16, width: 88, height: 48 }]);
+ expect(quadsToRects([[-20, -20, 5, -20, 5, 5, -20, 5]], 1, { width: 100, height: 100 })).toEqual([{ x: 0, y: 0, width: 7, height: 7 }]);
+ expect(quadsToRects([[500, 500, 600, 500, 600, 600, 500, 600]], 1, { width: 100, height: 100 })).toEqual([]);
+ expect(quadsToRects([[1, 2, 3]], 1, { width: 100, height: 100 })).toEqual([]);
+ });
+
+ it('encodes bytes as base64, in chunks for large images', () => {
+ expect(bytesToBase64(new Uint8Array([0xff, 0xd8, 0xff]))).toBe('/9j/');
+ const large = new Uint8Array(100_000).fill(65);
+ expect(atob(bytesToBase64(large))).toHaveLength(100_000);
+ });
+});
diff --git a/tests/unit/browser/sensitive.test.ts b/tests/unit/browser/sensitive.test.ts
new file mode 100644
index 0000000..f1f37f3
--- /dev/null
+++ b/tests/unit/browser/sensitive.test.ts
@@ -0,0 +1,68 @@
+import { isEditableField, isSensitiveField, type FieldAttributes } from '@/browser/sensitive';
+import { pageRun } from '@/browser/snapshot';
+
+/** Fields covering every rule: type, autocomplete, name/id words, payment patterns, and look-alikes that are not secrets. */
+const FIELDS: Array = [
+ { label: 'pw', tag: 'INPUT', type: 'password' },
+ { label: 'otp-ac', tag: 'INPUT', type: 'text', autocomplete: 'one-time-code' },
+ { label: 'cc-ac', tag: 'INPUT', type: 'text', autocomplete: 'cc-number' },
+ { label: 'cur-pw-ac', tag: 'INPUT', type: 'text', autocomplete: 'current-password' },
+ { label: 'pin-name', tag: 'INPUT', type: 'text', name: 'userPin' },
+ { label: 'otp-id', tag: 'INPUT', type: 'text', id: 'otp_code' },
+ { label: 'cvv', tag: 'INPUT', type: 'text', name: 'cvv' },
+ { label: 'card', tag: 'INPUT', type: 'text', name: 'cardNumber' },
+ { label: 'iban', tag: 'INPUT', type: 'text', id: 'iban' },
+ { label: 'passwordConfirm', tag: 'INPUT', type: 'text', name: 'passwordConfirm' },
+ { label: 'area-secret', tag: 'TEXTAREA', name: 'mfa' },
+ { label: 'shipping', tag: 'INPUT', type: 'text', name: 'shipping' },
+ { label: 'spinner', tag: 'INPUT', type: 'text', id: 'spinner' },
+ { label: 'email', tag: 'INPUT', type: 'email', autocomplete: 'email' },
+ { label: 'notes', tag: 'TEXTAREA', name: 'notes' },
+ { label: 'passenger', tag: 'INPUT', type: 'text', name: 'passenger' },
+];
+
+function html(field: FieldAttributes & { label: string }): string {
+ const attrs = [
+ field.type ? `type="${field.type}"` : '',
+ field.autocomplete ? `autocomplete="${field.autocomplete}"` : '',
+ field.name ? `name="${field.name}"` : '',
+ field.id ? `id="${field.id}"` : '',
+ `aria-label="${field.label}"`,
+ ].join(' ');
+ return field.tag === 'TEXTAREA' ? `` : ``;
+}
+
+beforeEach(() => {
+ delete (globalThis as Record).__deweeBrowserControl;
+ vi.spyOn(Element.prototype, 'getClientRects').mockImplementation(() => [{}] as unknown as DOMRectList);
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20, toJSON: () => ({}) }) as DOMRect);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('sensitive field rule', () => {
+ it('matches the page script, field by field', () => {
+ document.body.innerHTML = FIELDS.map(html).join('\n');
+ const response = pageRun({ op: 'snapshot', origins: [location.origin], generation: 1, maxChars: 20_000 });
+ if (!response.ok || response.op !== 'snapshot') throw new Error('snapshot failed');
+ for (const field of FIELDS) {
+ const line = response.text.split('\n').find((l) => l.includes(`"${field.label}"`));
+ expect(line, field.label).toBeDefined();
+ expect(line?.includes('value=[redacted]'), field.label).toBe(isSensitiveField(field));
+ }
+ // The rule really splits both ways in this list.
+ expect(FIELDS.filter((f) => isSensitiveField(f)).length).toBe(11);
+ });
+
+ it('knows which fields take typed text', () => {
+ expect(isEditableField({ tag: 'INPUT', type: 'email' })).toBe(true);
+ expect(isEditableField({ tag: 'INPUT', type: 'checkbox' })).toBe(false);
+ expect(isEditableField({ tag: 'INPUT', type: 'text', readonly: true })).toBe(false);
+ expect(isEditableField({ tag: 'TEXTAREA' })).toBe(true);
+ expect(isEditableField({ tag: 'DIV', contenteditable: '' })).toBe(true);
+ expect(isEditableField({ tag: 'DIV', contenteditable: 'false' })).toBe(false);
+ expect(isEditableField({ tag: 'DIV', contenteditable: null })).toBe(false);
+ });
+});
diff --git a/tests/unit/browser/session-advanced.test.ts b/tests/unit/browser/session-advanced.test.ts
new file mode 100644
index 0000000..57f115d
--- /dev/null
+++ b/tests/unit/browser/session-advanced.test.ts
@@ -0,0 +1,315 @@
+import { fakeBrowser } from 'wxt/testing/fake-browser';
+import { writeCredential } from '@/auth/credential-store';
+import type { CdpAdapterOptions } from '@/browser/adapters/cdp';
+import type { DriverRequest, DriverResponse } from '@/browser/adapters/page-driver';
+import type { BrowserApi, TabInfo } from '@/browser/chrome-api';
+import type { AdvancedEngine } from '@/browser/engine-router';
+import { BrowserSession, debuggerKey, deviceKey, grantsKey } from '@/browser/session';
+import type { ActionApi } from '@/browser/tab-marker';
+import { defaultSettings, saveSettings, type Profile, type Settings } from '@/shared/settings';
+import type { Grant } from '@/browser/protocol';
+import { DEVICE_ID, grant, GRANT_ID, NOW, ORIGIN, payload } from './fixtures';
+import { FakeSocket, flush } from './fake-socket';
+
+const gw: Profile = { id: 'p_gw', name: 'Gateway', kind: 'gateway', origin: 'https://gw.example.test', remember: false };
+
+function settings(): Settings {
+ const base = defaultSettings();
+ return { ...base, profiles: [gw], activeProfileId: gw.id, prefs: { ...base.prefs, connectedBrowser: true } };
+}
+
+/** A debugger adapter double: records calls; `prepare` answers from `attachResult`. */
+class FakeEngine implements AdvancedEngine {
+ attached = new Set();
+ attachResult: DriverResponse | null = null;
+ dispatching = false;
+ readonly calls: string[] = [];
+ constructor(readonly options: CdpAdapterOptions) {}
+ get attachedTabs() {
+ return [...this.attached];
+ }
+ async prepare(tabId: number) {
+ this.calls.push(`prepare:${tabId}`);
+ const refused = await this.options.check(tabId);
+ if (refused) return refused;
+ this.attached.add(tabId);
+ this.options.onChange();
+ return null;
+ }
+ async run(tabId: number, request: DriverRequest): Promise {
+ this.calls.push(`run:${request.op}`);
+ const refused = await this.options.check(tabId);
+ if (refused) return { ok: false, code: refused };
+ this.attached.add(tabId);
+ if (request.op === 'screenshot') return { ok: true, op: 'screenshot', data: '/9j/AAAA' };
+ if (request.op === 'snapshot') return { ok: true, op: 'snapshot', text: '[e1] button "Next"', url: `${ORIGIN}/`, title: 'Shop' };
+ return { ok: true, op: request.op } as DriverResponse;
+ }
+ async release(tabId: number) {
+ this.calls.push(`release:${tabId}`);
+ this.attached.delete(tabId);
+ }
+ forget(tabId: number) {
+ this.calls.push(`forget:${tabId}`);
+ }
+ async forceDetach(tabId: number) {
+ this.calls.push(`forceDetach:${tabId}`);
+ }
+ async detachAll() {
+ this.calls.push('detachAll');
+ this.attached.clear();
+ }
+ isDispatching() {
+ return this.dispatching;
+ }
+}
+
+function setup(options: { advanced?: boolean; grant?: Grant } = {}) {
+ const clock = { now: NOW };
+ const liveGrant = options.grant ?? grant();
+ const tabs = new Map();
+ let next = 40;
+ const api: BrowserApi = {
+ createTab: async (url) => {
+ const id = next++;
+ tabs.set(id, { url, title: 'Shop', status: 'complete', active: false });
+ return id;
+ },
+ updateTab: async (tabId, url) => {
+ const tab = tabs.get(tabId);
+ if (tab) tab.url = url;
+ },
+ removeTab: async (tabId) => {
+ tabs.delete(tabId);
+ },
+ getTab: async (tabId) => tabs.get(tabId) ?? null,
+ run: async (_tabId, request) => {
+ if (request.op === 'snapshot') return { ok: true, op: 'snapshot', text: '[e1] button "Standard"', url: `${ORIGIN}/`, title: 'Shop' };
+ if (request.op === 'screenshot') return { ok: false, code: 'unsupported_capability' };
+ return { ok: true, op: request.op } as DriverResponse;
+ },
+ hasHostPermission: async (origin) => origin === ORIGIN,
+ };
+ let engine: FakeEngine | null = null;
+ const badges: string[] = [];
+ const action: ActionApi = {
+ setBadgeText: async ({ tabId, text }) => void badges.push(`${tabId}:${text ?? 'reset'}`),
+ setBadgeBackgroundColor: async () => undefined,
+ setTitle: async () => undefined,
+ };
+ const sockets: FakeSocket[] = [];
+ const session = new BrowserSession({
+ api,
+ action,
+ cdp: options.advanced === false ? null : (opts) => (engine = new FakeEngine(opts)),
+ token: async () => 'key',
+ random: () => 0.5,
+ now: () => clock.now,
+ createSocket: (url) => {
+ const socket = new FakeSocket(url);
+ sockets.push(socket);
+ return socket;
+ },
+ });
+ const socket = (): FakeSocket => {
+ const current = sockets.at(-1);
+ if (!current) throw new Error('no socket');
+ return current;
+ };
+ const ready = async () => {
+ await session.sync();
+ socket().open();
+ await flush();
+ socket().reply(socket().last('connect'));
+ await flush();
+ socket().reply(socket().last('browser.device.attach'), { device_id: DEVICE_ID, grants: [liveGrant] });
+ await flush();
+ };
+ /** Sends one command and waits for its result frame. */
+ const command = async (action: Parameters[0], params: Record) => {
+ const frame = payload(action, params);
+ const before = socket().methods().filter((m) => m === 'browser.command.result').length;
+ socket().event('browser.command', frame);
+ await vi.waitFor(() => expect(socket().methods().filter((m) => m === 'browser.command.result').length).toBe(before + 1), { timeout: 3000 });
+ return socket().last('browser.command.result').params as { ok: boolean; result?: Record; error?: { code: string } };
+ };
+ /** Turns the debugger on and answers the re-attach that announces the new capabilities. */
+ const enable = async (fallback = true) => {
+ const done = session.handleRequest({ type: 'browser.advanced', enabled: true, fallback });
+ await flush();
+ socket().reply(socket().last('browser.device.attach'), { device_id: DEVICE_ID, grants: [liveGrant] });
+ await expect(done).resolves.toEqual({ ok: true });
+ };
+ return { session, tabs, api, socket, ready, command, enable, badges, clock, engine: () => engine as FakeEngine | null };
+}
+
+beforeEach(async () => {
+ fakeBrowser.runtime.getManifest = (() => ({ manifest_version: 3, name: 'Dewee', version: '0.1.0' })) as typeof fakeBrowser.runtime.getManifest;
+ await writeCredential(gw.id, { kind: 'api_key', apiKey: 'k1' }, false);
+ await fakeBrowser.storage.local.set({ [deviceKey(gw.id)]: { device_id: DEVICE_ID, name: 'Chrome' }, [grantsKey(gw.id)]: [GRANT_ID] });
+ await saveSettings(settings());
+});
+
+describe('BrowserSession: Standard build', () => {
+ it('has no debugger: refuses the opt-in and announces only standard', async () => {
+ const t = setup({ advanced: false });
+ await t.ready();
+ expect(t.socket().last('browser.device.attach').params).toEqual({ device_id: DEVICE_ID, capabilities: ['standard'] });
+ expect(t.session.state().advanced).toEqual({ available: false, enabled: false, fallback: true, attached: 0 });
+ await expect(t.session.handleRequest({ type: 'browser.advanced', enabled: true, fallback: true })).resolves.toEqual({ ok: false, code: 'unsupported_capability' });
+ const open = await t.command('open', { url: `${ORIGIN}/` });
+ expect(open.ok).toBe(true);
+ await expect(t.command('screenshot', { tab: 't1' })).resolves.toMatchObject({ ok: false, error: { code: 'unsupported_capability' } });
+ });
+});
+
+describe('BrowserSession: Advanced build', () => {
+ it('is off by default, and announces advanced only while the person has it on', async () => {
+ const t = setup();
+ await t.ready();
+ expect(t.socket().last('browser.device.attach').params).toEqual({ device_id: DEVICE_ID, capabilities: ['standard'] });
+ expect(t.session.state().advanced).toMatchObject({ available: true, enabled: false });
+
+ const on = t.session.handleRequest({ type: 'browser.advanced', enabled: true, fallback: false });
+ await flush();
+ expect(t.socket().last('browser.device.attach').params).toEqual({ device_id: DEVICE_ID, capabilities: ['standard', 'advanced'] });
+ t.socket().reply(t.socket().last('browser.device.attach'), { device_id: DEVICE_ID, grants: [grant()] });
+ await expect(on).resolves.toEqual({ ok: true });
+ expect(t.session.state().advanced).toMatchObject({ enabled: true, fallback: false });
+ // Only for this browser session.
+ expect((await fakeBrowser.storage.session.get(debuggerKey(gw.id)))[debuggerKey(gw.id)]).toEqual({ enabled: true, fallback: false });
+ expect(await fakeBrowser.storage.local.get(debuggerKey(gw.id))).toEqual({});
+
+ const off = t.session.handleRequest({ type: 'browser.advanced', enabled: false, fallback: false });
+ await flush();
+ expect(t.socket().last('browser.device.attach').params).toEqual({ device_id: DEVICE_ID, capabilities: ['standard'] });
+ t.socket().reply(t.socket().last('browser.device.attach'), { device_id: DEVICE_ID, grants: [grant()] });
+ await off;
+ expect(t.engine()?.calls).toContain('detachAll');
+ });
+
+ it('enrolls a new device with what the build can do', async () => {
+ await fakeBrowser.storage.local.remove([deviceKey(gw.id)]);
+ const t = setup();
+ await t.session.sync();
+ t.socket().open();
+ await flush();
+ t.socket().reply(t.socket().last('connect'));
+ await flush();
+ void t.session.handleRequest({ type: 'browser.enroll', name: 'Laptop' });
+ await flush();
+ expect(t.socket().last('browser.device.enroll').params).toMatchObject({ capabilities: ['standard', 'advanced'] });
+ });
+
+ it('reads and screenshots with the debugger once on, and lets go after each command', async () => {
+ const t = setup();
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ await t.enable();
+ const snapshot = await t.command('snapshot', { tab: 't1' });
+ expect(snapshot.result).toMatchObject({ text: '[e1] button "Next"' });
+ const shot = await t.command('screenshot', { tab: 't1' });
+ expect(shot).toMatchObject({ ok: true, result: { tab: 't1', mime: 'image/jpeg', data_base64: '/9j/AAAA' } });
+ const calls = t.engine()?.calls ?? [];
+ expect(calls.filter((c) => c.startsWith('release:'))).toHaveLength(2);
+ expect(t.session.state().advanced.attached).toBe(0);
+ });
+
+ it('checks the live policy before every debugger command', async () => {
+ const t = setup();
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ const check = (tabId: number) => t.engine()?.options.check(tabId);
+ // Off by default.
+ await expect(check(40)).resolves.toBe('debugger_unavailable');
+ await t.enable();
+ await expect(check(40)).resolves.toBeNull();
+ // Not an agent's tab.
+ await expect(check(99)).resolves.toBe('tab_not_owned');
+ // The tab moved to a restricted page or another site.
+ const tab = t.tabs.get(40);
+ if (!tab) throw new Error('no tab');
+ tab.url = 'https://chromewebstore.google.com/detail/x';
+ await expect(check(40)).resolves.toBe('restricted_page');
+ tab.url = 'https://elsewhere.example.test/';
+ await expect(check(40)).resolves.toBe('origin_not_granted');
+ tab.url = `${ORIGIN}/`;
+ // Paused.
+ void t.session.handleRequest({ type: 'browser.control', state: 'paused' });
+ await flush();
+ await expect(check(40)).resolves.toBe('device_paused');
+ });
+
+ it('lets go of the tab at once when the grant is revoked', async () => {
+ const t = setup();
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ await t.enable();
+ t.socket().event('browser.grant.revoked', { grant_id: GRANT_ID });
+ await flush();
+ expect(t.engine()?.calls).toEqual(expect.arrayContaining(['forget:40', 'release:40']));
+ await expect(t.engine()?.options.check(40)).resolves.toBe('tab_not_owned');
+ });
+
+ it('turns itself off when the person cancels Chrome’s debugging bar', async () => {
+ const t = setup();
+ await t.ready();
+ await t.enable();
+ t.engine()?.options.onDetached(40, 'canceled_by_user');
+ await flush();
+ expect(t.session.state().advanced.enabled).toBe(false);
+ });
+
+ it('pauses when the person uses an agent tab, but not for the debugger’s own input', async () => {
+ const t = setup();
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ const engine = t.engine();
+ if (!engine) throw new Error('no engine');
+ engine.dispatching = true;
+ t.session.onUserInput(40);
+ expect(t.session.state().control).toBe('active');
+ engine.dispatching = false;
+ t.session.onUserInput(40);
+ await flush();
+ expect(t.session.state()).toMatchObject({ control: 'paused', pauseReason: 'takeover' });
+ expect(t.socket().last('browser.control.state').params).toEqual({ device_id: DEVICE_ID, state: 'paused' });
+ // A tab the agent does not own is the person's own business.
+ void t.session.handleRequest({ type: 'browser.control', state: 'active' });
+ await flush();
+ t.session.onUserInput(7);
+ expect(t.session.state().control).toBe('active');
+ expect(t.session.state().pauseReason).toBeUndefined();
+ });
+
+ it('marks agent tabs, lists them, and keeps a timeline of outcomes without page data', async () => {
+ const t = setup();
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ await t.command('screenshot', { tab: 't1' });
+ const state = t.session.state();
+ expect(t.badges).toContain('40:AI');
+ expect(state.tabs).toEqual([{ handle: 't1', agentName: 'Researcher', domain: 'shop.example.test', expiresAt: grant().expires_at }]);
+ expect(state.timeline.map(({ action, ok, code }) => ({ action, ok, code }))).toEqual([
+ { action: 'screenshot', ok: false, code: 'unsupported_capability' },
+ { action: 'open', ok: true, code: undefined },
+ ]);
+ expect(JSON.stringify(state.timeline)).not.toContain('/9j/');
+
+ t.socket().event('browser.grant.revoked', { grant_id: GRANT_ID });
+ await flush();
+ expect(t.badges).toContain('40:reset');
+ expect(t.session.state().tabs).toEqual([]);
+ });
+
+ it('detaches from the grant’s tabs when it expires, without waiting for the next command', async () => {
+ const t = setup({ grant: grant({ expires_at: new Date(NOW + 800).toISOString() }) });
+ await t.ready();
+ await t.command('open', { url: `${ORIGIN}/` });
+ expect(t.badges).toContain('40:AI');
+ t.clock.now = NOW + 1_000;
+ await vi.waitFor(() => expect(t.engine()?.calls).toContain('forget:40'), { timeout: 2000 });
+ expect(t.badges).toContain('40:reset');
+ await expect(t.command('snapshot', { tab: 't1' })).resolves.toMatchObject({ ok: false, error: { code: 'grant_expired' } });
+ });
+});
diff --git a/tests/unit/browser/session.test.ts b/tests/unit/browser/session.test.ts
index 5f0693f..755fca1 100644
--- a/tests/unit/browser/session.test.ts
+++ b/tests/unit/browser/session.test.ts
@@ -36,6 +36,7 @@ function setup() {
const token = vi.fn(async (profileId: string) => `key-for-${profileId}`);
const session = new BrowserSession({
api,
+ action: null,
token,
random: () => 0.5,
now: () => NOW,
diff --git a/tests/unit/browser/tab-marker.test.ts b/tests/unit/browser/tab-marker.test.ts
new file mode 100644
index 0000000..c462a49
--- /dev/null
+++ b/tests/unit/browser/tab-marker.test.ts
@@ -0,0 +1,44 @@
+import { markerTitle, prefersVietnamese, TabMarker, type ActionApi } from '@/browser/tab-marker';
+
+function fakeAction() {
+ const calls: string[] = [];
+ const action: ActionApi = {
+ setBadgeText: async ({ tabId, text }) => void calls.push(`text:${tabId}:${text ?? 'null'}`),
+ setBadgeBackgroundColor: async ({ tabId, color }) => void calls.push(`color:${tabId}:${color}`),
+ setTitle: async ({ tabId, title }) => void calls.push(`title:${tabId}:${title}`),
+ };
+ return { action, calls };
+}
+
+describe('TabMarker', () => {
+ it('marks agent tabs and resets them when the agent lets go', () => {
+ const f = fakeAction();
+ const marker = new TabMarker(f.action, 'Open Dewee', () => false);
+ marker.sync(new Map([[3, 'Researcher']]));
+ expect(f.calls).toEqual(['text:3:AI', 'color:3:#7c3aed', 'title:3:Dewee: Researcher is controlling this tab']);
+ f.calls.length = 0;
+ // Nothing changed: nothing is sent again.
+ marker.sync(new Map([[3, 'Researcher']]));
+ expect(f.calls).toEqual([]);
+ marker.sync(new Map());
+ expect(f.calls).toEqual(['text:3:null', 'title:3:Open Dewee']);
+ });
+
+ it('ignores a tab that closed before the badge was set', async () => {
+ const action: ActionApi = {
+ setBadgeText: () => Promise.reject(new Error('No tab with id: 3')),
+ setBadgeBackgroundColor: () => Promise.reject(new Error('No tab with id: 3')),
+ setTitle: () => Promise.reject(new Error('No tab with id: 3')),
+ };
+ expect(() => new TabMarker(action, 'Open Dewee', () => false).sync(new Map([[3, '']]))).not.toThrow();
+ });
+
+ it('speaks Vietnamese when chosen, or when the browser is Vietnamese on auto', () => {
+ expect(prefersVietnamese('vi', 'en-US')).toBe(true);
+ expect(prefersVietnamese('auto', 'vi-VN')).toBe(true);
+ expect(prefersVietnamese('auto', 'en-US')).toBe(false);
+ expect(prefersVietnamese('en', 'vi-VN')).toBe(false);
+ expect(markerTitle('', true)).toBe('Dewee: Một agent đang điều khiển tab này');
+ expect(markerTitle('Researcher', false)).toBe('Dewee: Researcher is controlling this tab');
+ });
+});
diff --git a/tests/unit/protocol/messages.test.ts b/tests/unit/protocol/messages.test.ts
index e10f7d2..4200463 100644
--- a/tests/unit/protocol/messages.test.ts
+++ b/tests/unit/protocol/messages.test.ts
@@ -43,6 +43,8 @@ describe('requestSchema', () => {
'approve',
'capabilities',
'deviceId',
+ 'enabled',
+ 'fallback',
'grantId',
'id',
'name',
diff --git a/tests/unit/shared/build-profile.test.ts b/tests/unit/shared/build-profile.test.ts
new file mode 100644
index 0000000..7a33fef
--- /dev/null
+++ b/tests/unit/shared/build-profile.test.ts
@@ -0,0 +1,26 @@
+import { ADVANCED_BUILD, BASE_PERMISSIONS, deviceCapabilities, permissionsFor, profileFromEnv } from '@/shared/build-profile';
+
+describe('build profiles', () => {
+ it('defaults to Standard and refuses an unknown profile', () => {
+ expect(profileFromEnv(undefined)).toBe('standard');
+ expect(profileFromEnv('')).toBe('standard');
+ expect(profileFromEnv(' Advanced ')).toBe('advanced');
+ expect(() => profileFromEnv('pro')).toThrow(/Unknown DEWEE_PROFILE/);
+ });
+
+ it('gives Standard no debugger permission, and Advanced exactly one more', () => {
+ expect(permissionsFor('standard')).not.toContain('debugger');
+ expect(permissionsFor('standard')).toEqual([...BASE_PERMISSIONS]);
+ expect(permissionsFor('advanced')).toEqual([...BASE_PERMISSIONS, 'debugger']);
+ });
+
+ it('announces advanced only from an Advanced build with the debugger on', () => {
+ expect(deviceCapabilities(false, true)).toEqual(['standard']);
+ expect(deviceCapabilities(true, false)).toEqual(['standard']);
+ expect(deviceCapabilities(true, true)).toEqual(['standard', 'advanced']);
+ });
+
+ it('counts unit tests as a Standard build', () => {
+ expect(ADVANCED_BUILD).toBe(false);
+ });
+});
diff --git a/wxt.config.ts b/wxt.config.ts
index 9c1a211..8733077 100644
--- a/wxt.config.ts
+++ b/wxt.config.ts
@@ -1,17 +1,27 @@
import { defineConfig } from 'wxt';
import extensionKey from './extension-public-key.json' with { type: 'json' };
+import { permissionsFor, profileFromEnv } from './src/shared/build-profile';
// End-to-end builds pre-grant loopback hosts so Playwright never meets Chrome's permission prompt,
// and go to their own folder so they cannot be zipped or shipped by mistake.
const e2e = process.env.DEWEE_E2E === '1';
// The Chrome Web Store refuses a manifest with `key` and assigns its own ID, so store builds drop it.
const store = process.env.DEWEE_STORE === '1';
+// Standard (default) or Advanced (adds the `debugger` permission); see src/shared/build-profile.ts.
+const profile = profileFromEnv(process.env.DEWEE_PROFILE);
+const advanced = profile === 'advanced';
+// The Advanced build has no Web Store listing yet: it ships only as a sideload zip.
+if (advanced && store) throw new Error('The Advanced profile has no Chrome Web Store build yet; use `pnpm zip:advanced` to sideload it.');
+
+const outDir = `${e2e ? '.output-e2e' : store ? '.output-store' : '.output'}${advanced ? '-advanced' : ''}`;
// Thin entrypoints live in src/entrypoints; feature code lives in src/{background,sidepanel,options,...}.
export default defineConfig({
srcDir: 'src',
- outDir: e2e ? '.output-e2e' : store ? '.output-store' : '.output',
+ outDir,
modules: ['@wxt-dev/module-react'],
+ // Lets the bundler drop the debugger adapter from Standard builds.
+ vite: () => ({ define: { __DEWEE_ADVANCED__: JSON.stringify(advanced) } }),
hooks: {
// WXT copies a runtime-registered content script's `matches` into required `host_permissions`. The
// "Ask Dewee" script must stay optional (asked for in Settings), so drop that copy again.
@@ -22,15 +32,17 @@ export default defineConfig({
},
},
manifest: {
- name: 'Dewee',
+ name: advanced ? 'Dewee (Advanced)' : 'Dewee',
// Public key only: pins the unpacked/dev extension ID (poicgajeapmbcilfbeobdfedibiffbbm) so OAuth redirect URIs and
- // Gateway origin allowlists stay stable. The Chrome Web Store build gets its own store-assigned ID.
+ // Gateway origin allowlists stay stable. Both profiles share it, so only one of them can be installed at a time.
+ // The Chrome Web Store build gets its own store-assigned ID.
...(store ? {} : { key: extensionKey.key }),
description:
'Chat with your Dewee agents and Super Agent in a side panel, and answer credential requests without exposing secrets.',
minimum_chrome_version: '116',
// scripting registers the opt-in "Ask Dewee" page script only after the person turns it on.
- permissions: ['sidePanel', 'storage', 'identity', 'alarms', 'scripting'],
+ // debugger (Advanced only) is used only after the person turns it on in the side panel, and only on agent-owned tabs.
+ permissions: permissionsFor(profile),
// Gateway origins are requested at runtime, one origin at a time, after the user adds a profile.
// "Ask Dewee" on selected text asks for https://*/* when it is turned on in Settings.
optional_host_permissions: ['https://*/*', 'http://localhost/*', 'http://127.0.0.1/*'],
From ecc541a95719245e50c33fb5bddce3df4aa59ae2 Mon Sep 17 00:00:00 2001
From: Duy Nguyen
Date: Thu, 15 Oct 2026 01:53:05 +0700
Subject: [PATCH 2/7] test: cover the Advanced build end to end and benchmark
both engines
The e2e fixture can load either build. The Advanced spec drives the opt-in,
AX snapshot redaction, screenshots, frames, a debugger conflict and
detaching mid-action against the loopback relay. The Standard spec now
checks the manifest has no debugger permission. bench:browser compares
snapshot and click latency of both engines.
Refs #18
Co-Authored-By: Claude Opus 5.5
---
scripts/bench-browser-control.mjs | 6 +
tests/e2e/bench-browser-control.spec.ts | 61 ++++++
tests/e2e/browser-control-advanced.spec.ts | 210 +++++++++++++++++++++
tests/e2e/browser-control.spec.ts | 77 ++------
tests/e2e/fixtures.ts | 15 +-
tests/e2e/support/browser-relay.ts | 28 ++-
tests/e2e/support/connect-browser.ts | 69 +++++++
7 files changed, 394 insertions(+), 72 deletions(-)
create mode 100644 scripts/bench-browser-control.mjs
create mode 100644 tests/e2e/bench-browser-control.spec.ts
create mode 100644 tests/e2e/browser-control-advanced.spec.ts
create mode 100644 tests/e2e/support/connect-browser.ts
diff --git a/scripts/bench-browser-control.mjs b/scripts/bench-browser-control.mjs
new file mode 100644
index 0000000..fcd5e2e
--- /dev/null
+++ b/scripts/bench-browser-control.mjs
@@ -0,0 +1,6 @@
+// Builds the Advanced e2e extension and runs the Standard vs Advanced latency benchmark
+// (tests/e2e/bench-browser-control.spec.ts). Rounds: DEWEE_BENCH_ROUNDS (default 20).
+import { execSync } from 'node:child_process';
+
+execSync('node scripts/build-e2e.mjs --profile advanced', { stdio: 'inherit' });
+execSync('pnpm exec playwright test tests/e2e/bench-browser-control.spec.ts', { stdio: 'inherit', env: { ...process.env, DEWEE_BENCH: '1' } });
diff --git a/tests/e2e/bench-browser-control.spec.ts b/tests/e2e/bench-browser-control.spec.ts
new file mode 100644
index 0000000..9ea2db1
--- /dev/null
+++ b/tests/e2e/bench-browser-control.spec.ts
@@ -0,0 +1,61 @@
+/**
+ * Small latency benchmark: snapshot and click through the relay, the standard way and with the
+ * debugger, on the same Advanced build and page. Runs only via `pnpm bench:browser`
+ * (DEWEE_BENCH=1). Timings include the relay round trip, policy checks and, for the debugger,
+ * the attach and detach around every command.
+ */
+import type { Page, Worker } from '@playwright/test';
+import { expect, test } from './fixtures';
+import { BrowserRelay, startPageServer } from './support/browser-relay';
+import { connectedBrowser, refOf } from './support/connect-browser';
+
+test.use({ buildProfile: 'advanced' });
+test.skip(!process.env.DEWEE_BENCH, 'benchmark: run with pnpm bench:browser');
+
+const ROUNDS = Number(process.env.DEWEE_BENCH_ROUNDS ?? 20);
+
+function summary(values: number[]): string {
+ const sorted = [...values].sort((a, b) => a - b);
+ const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!.toFixed(1);
+ return `median ${at(0.5)} ms, p90 ${at(0.9)} ms, min ${sorted[0]!.toFixed(1)} ms (n=${sorted.length})`;
+}
+
+test('Standard vs Advanced snapshot and click latency', async ({ serviceWorker, openPanel }) => {
+ test.setTimeout(10 * 60_000);
+ const relay = new BrowserRelay();
+ await relay.start();
+ const site = await startPageServer();
+ const siteOrigin = `http://127.0.0.1:${site.port}`;
+ try {
+ const { connectBrowser, run } = connectedBrowser(relay, () => siteOrigin);
+ const panel: Page = await connectBrowser(serviceWorker as Worker, openPanel);
+ expect(await run('open', { url: `${siteOrigin}/account` })).toMatchObject({ ok: true });
+
+ const measure = async (label: string) => {
+ const snapshots: number[] = [];
+ const clicks: number[] = [];
+ for (let i = 0; i < ROUNDS; i += 1) {
+ let start = performance.now();
+ const snap = await run('snapshot', { tab: 't1' });
+ snapshots.push(performance.now() - start);
+ expect(snap.ok).toBe(true);
+ const { text, generation } = snap.result as { text: string; generation: number };
+ start = performance.now();
+ expect(await run('click', { tab: 't1', ref: refOf(text, 'Next'), generation })).toMatchObject({ ok: true });
+ clicks.push(performance.now() - start);
+ }
+ process.stdout.write(`[bench] ${label} snapshot: ${summary(snapshots)}\n[bench] ${label} click: ${summary(clicks)}\n`);
+ };
+
+ await measure('standard');
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ const enable = drawer.getByRole('checkbox', { name: /Let agents use Chrome’s debugger/ });
+ await enable.click();
+ await expect(enable).toBeChecked();
+ await expect.poll(() => relay.capabilities.at(-1)).toEqual(['standard', 'advanced']);
+ await measure('advanced');
+ } finally {
+ await relay.stop();
+ await site.stop();
+ }
+});
diff --git a/tests/e2e/browser-control-advanced.spec.ts b/tests/e2e/browser-control-advanced.spec.ts
new file mode 100644
index 0000000..b63c4b3
--- /dev/null
+++ b/tests/e2e/browser-control-advanced.spec.ts
@@ -0,0 +1,210 @@
+/**
+ * Connected browser on the Advanced build: the same loopback relay and site as
+ * browser-control.spec.ts, with the person turning on the debugger in the side panel.
+ *
+ * A debugger conflict is simulated by attaching chrome.debugger to the agent's tab from the
+ * extension's own service worker first. Chrome allows one client per extension per tab, so the
+ * adapter's attach fails exactly as it does when DevTools or another debugging extension holds
+ * the tab. Opening real DevTools is not scriptable from Playwright.
+ */
+import type { Page, Worker } from '@playwright/test';
+import { dumpStorage, expect, test } from './fixtures';
+import { BrowserRelay, startPageServer } from './support/browser-relay';
+import { connectedBrowser, refOf } from './support/connect-browser';
+
+test.use({ buildProfile: 'advanced' });
+
+const MAX_SCREENSHOT_BYTES = 1536 * 1024;
+
+let relay: BrowserRelay;
+let site: Awaited>;
+let siteOrigin: string;
+let connectBrowser: ReturnType['connectBrowser'];
+let run: ReturnType['run'];
+let openSite: ReturnType['openSite'];
+
+test.beforeEach(async () => {
+ relay = new BrowserRelay();
+ await relay.start();
+ site = await startPageServer();
+ siteOrigin = `http://127.0.0.1:${site.port}`;
+ ({ connectBrowser, run, openSite } = connectedBrowser(relay, () => siteOrigin));
+});
+
+test.afterEach(async () => {
+ await relay.stop();
+ await site.stop();
+});
+
+/** Connects, then turns on the debugger opt-in in the drawer and waits for the relay to hear it. */
+async function connectAdvanced(worker: Worker, openPanel: () => Promise): Promise {
+ const panel = await connectBrowser(worker, openPanel);
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ const enable = drawer.getByRole('checkbox', { name: /Let agents use Chrome’s debugger/ });
+ // Off by default. Enroll says what the build can do; the attach right after it says what is usable now.
+ await expect(enable).not.toBeChecked();
+ expect(relay.capabilities[0]).toEqual(['standard', 'advanced']);
+ expect(relay.capabilities.at(-1)).toEqual(['standard']);
+ // The box shows the worker's state, so it turns checked once the worker has applied the change.
+ await enable.click();
+ await expect(enable).toBeChecked();
+ await expect.poll(() => relay.capabilities.at(-1)).toEqual(['standard', 'advanced']);
+ await expect(drawer.getByRole('checkbox', { name: /When the debugger is busy/ })).toBeChecked();
+ return panel;
+}
+
+/**
+ * Whether the extension's debugger holds the site tab, as Chrome enforces it: Chrome allows one
+ * debugger client per extension per tab, so a probe attach from the same worker fails while the
+ * adapter is attached (and is detached at once otherwise). `getTargets().attached` cannot tell,
+ * because Playwright's own CDP session counts as attached on every page.
+ */
+async function siteAttached(worker: Worker): Promise {
+ const tabId = await siteTabId(worker);
+ return worker.evaluate(async (id) => {
+ try {
+ await chrome.debugger.attach({ tabId: id }, '1.3');
+ } catch (error) {
+ if (String(error).includes('Another debugger')) return true;
+ throw error;
+ }
+ await chrome.debugger.detach({ tabId: id });
+ return false;
+ }, tabId);
+}
+
+async function siteTabId(worker: Worker): Promise {
+ const id = await worker.evaluate(async (origin) => (await chrome.tabs.query({})).find((t) => t.url?.startsWith(origin))?.id ?? -1, siteOrigin);
+ if (id < 0) throw new Error('no site tab');
+ return id;
+}
+
+test('the Advanced build asks for the debugger and keeps it off until the person turns it on', async ({ serviceWorker, openPanel }) => {
+ const manifest = await serviceWorker.evaluate(() => chrome.runtime.getManifest());
+ expect(manifest.permissions).toContain('debugger');
+ await connectBrowser(serviceWorker, openPanel);
+ expect(relay.capabilities).toEqual([
+ ['standard', 'advanced'],
+ ['standard'],
+ ]);
+ // Without the opt-in, screenshots are refused and reads go the standard way.
+ expect(await run('open', { url: `${siteOrigin}/account` })).toMatchObject({ ok: true });
+ expect(await run('screenshot', { tab: 't1' })).toMatchObject({ ok: false, error: { code: 'unsupported_capability' } });
+ expect(await siteAttached(serviceWorker)).toBe(false);
+});
+
+test('reads the accessibility tree with the same redaction, acts, and lets go after each command', async ({ serviceWorker, openPanel, context }) => {
+ await connectAdvanced(serviceWorker, openPanel);
+ const { text, generation } = await openSite();
+
+ expect(text).toMatch(/heading "Account"/);
+ expect(text).toMatch(/\[e\d+\] textbox "Email"/);
+ expect(text).toMatch(/textbox "Password" type=password value=\[redacted\]/);
+ for (const secret of ['hunter2-e2e-secret', 'hidden-token-e2e', 'Hidden text e2e']) expect(text).not.toContain(secret);
+ await expect.poll(() => siteAttached(serviceWorker)).toBe(false);
+
+ expect(await run('type', { tab: 't1', ref: refOf(text, 'Email'), generation, text: 'ada@example.test' })).toMatchObject({ ok: true });
+ expect(await run('type', { tab: 't1', ref: refOf(text, 'Password'), generation, text: 'guess' })).toMatchObject({ ok: false, error: { code: 'sensitive_field' } });
+ expect(await run('click', { tab: 't1', ref: refOf(text, 'Next'), generation })).toMatchObject({ ok: true, result: { tab: 't1' } });
+ const page = context.pages().find((p) => p.url().startsWith(siteOrigin));
+ await expect(page!.locator('#status')).toHaveText('Clicked next');
+ await expect(page!.locator('#email')).toHaveValue('ada@example.test');
+ await expect(page!.locator('#pw')).toHaveValue('hunter2-e2e-secret');
+ expect(await run('press', { tab: 't1', key: 'Tab' })).toMatchObject({ ok: true });
+
+ // Old refs are refused, not guessed.
+ expect(await run('click', { tab: 't1', ref: refOf(text, 'Next'), generation: generation + 5 })).toMatchObject({ ok: false, error: { code: 'stale_ref' } });
+ await expect.poll(() => siteAttached(serviceWorker)).toBe(false);
+
+ const stored = await dumpStorage(serviceWorker);
+ for (const pageData of ['ada@example.test', 'Clicked next', 'hunter2', 'E2E Shop', 'textbox']) expect(stored).not.toContain(pageData);
+});
+
+test('takes a JPEG screenshot of the viewport under the size limit and keeps none of it', async ({ serviceWorker, openPanel }) => {
+ const panel = await connectAdvanced(serviceWorker, openPanel);
+ await openSite();
+ const shot = await run('screenshot', { tab: 't1' });
+ expect(shot).toMatchObject({ ok: true, result: { tab: 't1', url: `${siteOrigin}/account`, mime: 'image/jpeg' } });
+ const data = (shot.result as { data_base64: string }).data_base64;
+ const bytes = Buffer.from(data, 'base64');
+ expect([...bytes.subarray(0, 3)]).toEqual([0xff, 0xd8, 0xff]);
+ expect(bytes.length).toBeLessThanOrEqual(MAX_SCREENSHOT_BYTES);
+ await expect.poll(() => siteAttached(serviceWorker)).toBe(false);
+
+ // The image is sent once: not in storage, not in the timeline.
+ expect(await dumpStorage(serviceWorker)).not.toContain(data.slice(0, 64));
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ await expect(drawer.getByTestId('browser-timeline')).toContainText('screenshot');
+ await expect(drawer.getByTestId('browser-tabs')).toContainText('127.0.0.1');
+});
+
+test('reads and acts inside a same-site frame, and leaves a cross-site frame out', async ({ serviceWorker, openPanel, context }) => {
+ await connectAdvanced(serviceWorker, openPanel);
+ const { text, generation } = await openSite('/frames', 'E2E Frames');
+ expect(text).toMatch(/button "Inner button"/);
+ expect(text).not.toContain('Foreign');
+ expect(await run('click', { tab: 't1', ref: refOf(text, 'Inner button'), generation })).toMatchObject({ ok: true });
+ const page = context.pages().find((p) => p.url().startsWith(siteOrigin));
+ await expect(page!.frameLocator('iframe[title="Same site"]').getByRole('button')).toHaveText('Inner clicked');
+ expect(await run('screenshot', { tab: 't1' })).toMatchObject({ ok: true });
+});
+
+test('answers debugger_unavailable when another debugger holds the tab, and falls back only when allowed', async ({ serviceWorker, openPanel }) => {
+ const panel = await connectAdvanced(serviceWorker, openPanel);
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ expect(await run('open', { url: `${siteOrigin}/account` })).toMatchObject({ ok: true });
+ const tabId = await siteTabId(serviceWorker);
+ await serviceWorker.evaluate((id) => chrome.debugger.attach({ tabId: id }, '1.3'), tabId);
+ try {
+ // Fallback on (the default): reads and keys go the standard way; screenshots never do.
+ const read = await run('snapshot', { tab: 't1' });
+ expect(read).toMatchObject({ ok: true });
+ expect((read.result as { text: string }).text).toMatch(/textbox "Password" type=password value=\[redacted\]/);
+ expect(await run('screenshot', { tab: 't1' })).toMatchObject({ ok: false, error: { code: 'debugger_unavailable' } });
+
+ const fallback = drawer.getByRole('checkbox', { name: /When the debugger is busy/ });
+ await fallback.click();
+ await expect(fallback).not.toBeChecked();
+ expect(await run('snapshot', { tab: 't1' })).toMatchObject({ ok: false, error: { code: 'debugger_unavailable' } });
+ expect(await run('press', { tab: 't1', key: 'Tab' })).toMatchObject({ ok: false, error: { code: 'debugger_unavailable' } });
+ } finally {
+ await serviceWorker.evaluate((id) => chrome.debugger.detach({ tabId: id }), tabId);
+ }
+ // Free again: the debugger is used once more.
+ expect(await run('screenshot', { tab: 't1' })).toMatchObject({ ok: true });
+});
+
+test('revoking the grant mid-action detaches the debugger at once', async ({ serviceWorker, openPanel }) => {
+ const panel = await connectAdvanced(serviceWorker, openPanel);
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ const { text, generation } = await openSite();
+
+ // A risky click holds the debugger while it waits for the person.
+ const pending = relay.command('click', { tab: 't1', ref: refOf(text, 'Delete account'), generation });
+ await expect(drawer.getByTestId('browser-approval')).toBeVisible();
+ await expect(drawer.getByTestId('browser-advanced-attached')).toContainText('1 tab');
+ expect(await siteAttached(serviceWorker)).toBe(true);
+
+ relay.revokeGrant(relay.activeGrant()!.grant_id);
+ expect(await pending.outcome).toMatchObject({ ok: false, error: { code: 'grant_revoked' } });
+ await expect.poll(() => siteAttached(serviceWorker)).toBe(false);
+ await expect(drawer.getByTestId('browser-advanced-attached')).toHaveCount(0);
+ await expect(drawer.getByTestId('browser-approval')).toHaveCount(0);
+});
+
+test('turning the opt-in off mid-action detaches and stops announcing advanced', async ({ serviceWorker, openPanel }) => {
+ const panel = await connectAdvanced(serviceWorker, openPanel);
+ const drawer = panel.getByRole('dialog', { name: 'Browser control' });
+ const { text, generation } = await openSite();
+
+ const pending = relay.command('click', { tab: 't1', ref: refOf(text, 'Delete account'), generation });
+ await expect(drawer.getByTestId('browser-approval')).toBeVisible();
+ expect(await siteAttached(serviceWorker)).toBe(true);
+ const enable = drawer.getByRole('checkbox', { name: /Let agents use Chrome’s debugger/ });
+ await enable.click();
+ await expect(enable).not.toBeChecked();
+ expect(await pending.outcome).toMatchObject({ ok: false });
+ await expect.poll(() => siteAttached(serviceWorker)).toBe(false);
+ await expect.poll(() => relay.capabilities.at(-1)).toEqual(['standard']);
+ expect(await run('screenshot', { tab: 't1' })).toMatchObject({ ok: false, error: { code: 'unsupported_capability' } });
+});
diff --git a/tests/e2e/browser-control.spec.ts b/tests/e2e/browser-control.spec.ts
index 6063b55..7c3a4dc 100644
--- a/tests/e2e/browser-control.spec.ts
+++ b/tests/e2e/browser-control.spec.ts
@@ -3,19 +3,23 @@
* enroll, attach, grant.create, browser.command) and a loopback page server plays the site.
* The person's side (enroll, allow, approve, Stop, revoke) goes through the real side panel.
*/
-import type { Page, Worker } from '@playwright/test';
import { dumpStorage, expect, test } from './fixtures';
-import { AGENT, BrowserRelay, RELAY_API_KEY, startPageServer, type CommandOutcome } from './support/browser-relay';
+import { BrowserRelay, startPageServer } from './support/browser-relay';
+import { connectedBrowser, refOf } from './support/connect-browser';
let relay: BrowserRelay;
let site: Awaited>;
let siteOrigin: string;
+let connectBrowser: ReturnType['connectBrowser'];
+let run: ReturnType['run'];
+let openSite: ReturnType['openSite'];
test.beforeEach(async () => {
relay = new BrowserRelay();
await relay.start();
site = await startPageServer();
siteOrigin = `http://127.0.0.1:${site.port}`;
+ ({ connectBrowser, run, openSite } = connectedBrowser(relay, () => siteOrigin));
});
test.afterEach(async () => {
@@ -23,66 +27,15 @@ test.afterEach(async () => {
await site.stop();
});
-/** A self-hosted profile with its API key and "Connected browser" on, written where the extension keeps them. */
-async function seedProfile(worker: Worker): Promise {
- await worker.evaluate(
- async ({ origin, key }) => {
- await chrome.storage.session.set({ 'credential:p_e2e': { kind: 'api_key', apiKey: key } });
- await chrome.storage.local.set({
- settings: {
- version: 1,
- profiles: [{ id: 'p_e2e', name: 'Local gateway', kind: 'gateway', origin, remember: false }],
- activeProfileId: 'p_e2e',
- selections: {},
- prefs: { theme: 'system', locale: 'en', showReasoning: false, showToolDetails: false, askOnSelection: false, connectedBrowser: true },
- },
- });
- },
- { origin: relay.origin, key: RELAY_API_KEY },
- );
-}
-
-/** Registers this browser and allows the test agent on the loopback site, through the side panel. */
-async function connectBrowser(worker: Worker, openPanel: () => Promise, capability: 'read' | 'control' = 'control'): Promise {
- await seedProfile(worker);
- const panel = await openPanel();
- await panel.getByTestId('browser-toggle').click();
- const drawer = panel.getByRole('dialog', { name: 'Browser control' });
- await expect(drawer.getByTestId('browser-status')).toHaveText('Connected');
- await expect(drawer.getByLabel('Device name')).not.toHaveValue('');
- await drawer.getByRole('button', { name: 'Register this browser' }).click();
- await expect(drawer.getByTestId('browser-device-name')).toBeVisible();
- await drawer.getByRole('checkbox', { name: AGENT.name }).check();
- await drawer.getByLabel('Sites, one per line').fill(siteOrigin);
- if (capability === 'control') await drawer.getByRole('radio', { name: /Read and act/ }).check();
- await drawer.getByRole('button', { name: 'Allow', exact: true }).click();
- await expect(drawer.getByTestId('browser-grants')).toContainText(siteOrigin);
- expect(relay.activeGrant()).toMatchObject({ origins: [siteOrigin], agent_ids: [AGENT.id] });
- return panel;
-}
-
-async function run(action: string, params: Record, options?: Parameters[2]): Promise {
- const { commandId, outcome } = relay.command(action, params, options);
- const result = await outcome;
- // Acknowledged after validation, before the result.
- expect(relay.acks).toContain(commandId);
- return result;
-}
-
-function refOf(text: string, name: string): string {
- const ref = text.split('\n').find((line) => /\[e\d+\]/.test(line) && line.includes(`"${name}"`))?.match(/\[(e\d+)\]/)?.[1];
- if (!ref) throw new Error(`no ref for ${name} in:\n${text}`);
- return ref;
-}
-
-async function openSite(): Promise<{ tab: string; text: string; generation: number }> {
- const opened = await run('open', { url: `${siteOrigin}/account` });
- expect(opened).toMatchObject({ ok: true, result: { tab: 't1', url: `${siteOrigin}/account`, title: 'E2E Shop' } });
- const snap = await run('snapshot', { tab: 't1' });
- expect(snap.ok).toBe(true);
- const result = snap.result as { text: string; generation: number };
- return { tab: 't1', text: result.text, generation: result.generation };
-}
+test('the Standard build has no debugger permission and announces only standard', async ({ serviceWorker, openPanel }) => {
+ const manifest = await serviceWorker.evaluate(() => chrome.runtime.getManifest());
+ expect(manifest.permissions ?? []).not.toContain('debugger');
+ expect(manifest.optional_permissions ?? []).not.toContain('debugger');
+ const panel = await connectBrowser(serviceWorker, openPanel);
+ expect(relay.capabilities.length).toBeGreaterThan(0);
+ for (const announced of relay.capabilities) expect(announced).toEqual(['standard']);
+ await expect(panel.getByTestId('browser-advanced')).toHaveCount(0);
+});
test('an allowed agent opens, reads, types and clicks on the granted site', async ({ serviceWorker, openPanel }) => {
await connectBrowser(serviceWorker, openPanel);
diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts
index 3453267..c25e7ef 100644
--- a/tests/e2e/fixtures.ts
+++ b/tests/e2e/fixtures.ts
@@ -5,9 +5,15 @@ import path from 'node:path';
/** Pinned by the manifest `key` (extension-public-key.json). */
export const EXTENSION_ID = 'poicgajeapmbcilfbeobdfedibiffbbm';
-const EXTENSION_PATH = path.resolve('.output-e2e/chrome-mv3');
+/** Unpacked builds from scripts/build-e2e.mjs, one per build profile. */
+const EXTENSION_PATHS = {
+ standard: path.resolve('.output-e2e/chrome-mv3'),
+ advanced: path.resolve('.output-e2e-advanced/chrome-mv3'),
+} as const;
export interface ExtensionFixtures {
+ /** Which build to load; a spec picks Advanced with `test.use({ buildProfile: 'advanced' })`. */
+ buildProfile: keyof typeof EXTENSION_PATHS;
context: BrowserContext;
serviceWorker: Worker;
openPanel(): Promise;
@@ -15,13 +21,14 @@ export interface ExtensionFixtures {
}
export const test = base.extend({
- // eslint-disable-next-line no-empty-pattern
- context: async ({}, use) => {
+ buildProfile: ['standard', { option: true }],
+ context: async ({ buildProfile }, use) => {
+ const extensionPath = EXTENSION_PATHS[buildProfile];
const userDataDir = mkdtempSync(path.join(tmpdir(), 'dewee-e2e-'));
const context = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
locale: 'en-US',
- args: [`--disable-extensions-except=${EXTENSION_PATH}`, `--load-extension=${EXTENSION_PATH}`],
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
});
await use(context);
await context.close();
diff --git a/tests/e2e/support/browser-relay.ts b/tests/e2e/support/browser-relay.ts
index 922c4ef..73c642b 100644
--- a/tests/e2e/support/browser-relay.ts
+++ b/tests/e2e/support/browser-relay.ts
@@ -113,6 +113,8 @@ export class BrowserRelay {
readonly outcomes = new Map();
readonly controlStates: string[] = [];
readonly methods: string[] = [];
+ /** Capabilities the device announced, per enroll or attach, in order. */
+ readonly capabilities: string[][] = [];
private readonly connections = new Set();
private readonly waiters = new Map void>();
private server: Server | null = null;
@@ -271,6 +273,7 @@ export class BrowserRelay {
}
if (!connection.ready) return fail('UNAUTHORIZED', 'connect first');
const deviceId = typeof params.device_id === 'string' ? params.device_id : '';
+ if ((method === 'browser.device.enroll' || method === 'browser.device.attach') && Array.isArray(params.capabilities)) this.capabilities.push(params.capabilities.map(String));
switch (method) {
case 'browser.device.enroll': {
const device: Device = { device_id: randomUUID(), name: String(params.name), platform: String(params.platform), online: true, revoked_at: null };
@@ -363,15 +366,28 @@ export async function startPageServer(): Promise<{ port: number; stop(): Promise
Hidden text e2e
Ready