cdp-toolkit 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -23,12 +23,16 @@
23
23
  * - --list -> all tool names (one per line) to stdout, exit 0.
24
24
  */
25
25
  import { TOOLS, TOOL_NAMES, type ToolName } from "./index.ts";
26
+ import { resolveBrowserKind, stripBrowserFlag, startFirefoxSession } from "./backend.ts";
27
+ import { toolAvailability } from "./capabilities.ts";
28
+ import { FIREFOX_TOOLS } from "./firefox-tools.ts";
26
29
 
27
- const USAGE = `cdp-toolkit — raw single-target CDP, 29-tool chrome-devtools-mcp parity.
30
+ const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
28
31
 
29
32
  Usage:
30
- bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
31
- bun run src/cli.ts --list
33
+ bun run src/cli.ts <tool> [--browser chrome|firefox] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
34
+ bun run src/cli.ts --list [--browser chrome|firefox]
35
+ bun run src/cli.ts --capabilities [--browser chrome|firefox]
32
36
 
33
37
  Examples:
34
38
  bun run src/cli.ts list_pages
@@ -36,9 +40,17 @@ Examples:
36
40
  bun run src/cli.ts click --target index:0 --uid 42
37
41
  bun run src/cli.ts evaluate_script --json '{"expression":"1+2"}'
38
42
  bun run src/cli.ts take_screenshot --target url:example --fullPage true
43
+ bun run src/cli.ts --browser firefox take_snapshot
44
+ bun run src/cli.ts --capabilities --browser firefox
45
+
46
+ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else "chrome" (default,
47
+ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
48
+ to), used for exactly one tool call, then disposed before the process exits.
39
49
 
40
50
  Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
41
- Run with --list to print every available tool name.`;
51
+ Run with --list to print every tool name available under the selected backend.
52
+ Run with --capabilities to see, per tool, whether it is available and (if not) which capability
53
+ the selected backend is missing.`;
42
54
 
43
55
  /** Coerce a raw CLI string into boolean / number / string. */
44
56
  function coerce(raw: string): boolean | number | string {
@@ -54,12 +66,13 @@ function coerce(raw: string): boolean | number | string {
54
66
  interface ParsedArgs {
55
67
  tool?: string;
56
68
  list: boolean;
69
+ capabilities: boolean;
57
70
  args: Record<string, unknown>;
58
71
  }
59
72
 
60
- /** Parse process argv (already sliced to drop the runtime + script). */
73
+ /** Parse process argv (already sliced to drop the runtime + script, and with --browser already stripped). */
61
74
  function parseArgv(argv: string[]): ParsedArgs {
62
- const out: ParsedArgs = { list: false, args: {} };
75
+ const out: ParsedArgs = { list: false, capabilities: false, args: {} };
63
76
  // Collect flag pairs separately so --json can be merged BEFORE individual
64
77
  // flags override it, regardless of token order.
65
78
  let jsonObj: Record<string, unknown> | undefined;
@@ -71,6 +84,10 @@ function parseArgv(argv: string[]): ParsedArgs {
71
84
  out.list = true;
72
85
  continue;
73
86
  }
87
+ if (token === "--capabilities") {
88
+ out.capabilities = true;
89
+ continue;
90
+ }
74
91
  if (token.startsWith("--")) {
75
92
  const key = token.slice(2);
76
93
  const next = argv[i + 1];
@@ -109,10 +126,23 @@ function isToolName(name: string): name is ToolName {
109
126
  }
110
127
 
111
128
  async function main(): Promise<number> {
112
- const parsed = parseArgv(process.argv.slice(2));
129
+ const rawArgv = process.argv.slice(2);
130
+ const browser = resolveBrowserKind(rawArgv);
131
+ const parsed = parseArgv(stripBrowserFlag(rawArgv));
132
+ const availability = toolAvailability(browser);
133
+ const availableSet = new Set(availability.available);
134
+
135
+ if (parsed.capabilities) {
136
+ const lines = [`backend: ${browser}`, "", "available:"];
137
+ for (const name of availability.available) lines.push(` ${name}`);
138
+ lines.push("", "unavailable:");
139
+ for (const u of availability.unavailable) lines.push(` ${u.name} (needs: ${u.missing.join(", ")})`);
140
+ process.stdout.write(`${lines.join("\n")}\n`);
141
+ return 0;
142
+ }
113
143
 
114
144
  if (parsed.list) {
115
- process.stdout.write(`${TOOL_NAMES.join("\n")}\n`);
145
+ process.stdout.write(`${availability.available.join("\n")}\n`);
116
146
  return 0;
117
147
  }
118
148
 
@@ -128,12 +158,40 @@ async function main(): Promise<number> {
128
158
  return 1;
129
159
  }
130
160
 
131
- const fn = TOOLS[parsed.tool];
132
- // The registry's value type is the union of all tool signatures; each tool
133
- // validates its own args at runtime, so we hand the parsed object through.
134
- const result = await (fn as (args: unknown) => Promise<unknown>)(parsed.args);
135
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
136
- return 0;
161
+ if (!availableSet.has(parsed.tool)) {
162
+ const gap = availability.unavailable.find((u) => u.name === parsed.tool);
163
+ process.stderr.write(
164
+ `${JSON.stringify({
165
+ error: `tool '${parsed.tool}' is not available under --browser ${browser}${gap ? ` (needs: ${gap.missing.join(", ")})` : ""}. Run --capabilities --browser ${browser} to see the full list.`,
166
+ })}\n`,
167
+ );
168
+ return 1;
169
+ }
170
+
171
+ if (browser === "chrome") {
172
+ const fn = TOOLS[parsed.tool];
173
+ // The registry's value type is the union of all tool signatures; each tool
174
+ // validates its own args at runtime, so we hand the parsed object through.
175
+ const result = await (fn as (args: unknown) => Promise<unknown>)(parsed.args);
176
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
177
+ return 0;
178
+ }
179
+
180
+ // Firefox: one process per invocation. Launch, run exactly one tool call, always dispose,
181
+ // even if the tool call itself throws, so the spawned Firefox process never leaks.
182
+ const neutralFn = FIREFOX_TOOLS[parsed.tool];
183
+ if (!neutralFn) {
184
+ process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}\n`);
185
+ return 1;
186
+ }
187
+ const session = await startFirefoxSession();
188
+ try {
189
+ const result = await neutralFn(session.driver, parsed.args as never);
190
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
191
+ return 0;
192
+ } finally {
193
+ await session.dispose();
194
+ }
137
195
  }
138
196
 
139
197
  main()
package/src/driver.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * ADR-001: one browser-neutral Driver interface behind the 33 tools.
3
+ *
4
+ * Context. Every tool in src/tools/ is a stateless one-shot on withPage():
5
+ * open a socket, act, always close. That is only correct because a CDP `Uid`
6
+ * IS a backendDOMNodeId, a browser-global integer outliving the connection
7
+ * that made it, so take_snapshot in one process and click(uid) in the next
8
+ * interoperate. BiDi breaks both halves: a session is bound to ONE WebSocket
9
+ * (re-dialing gets `invalid session id`) and a `sharedId` is document-scoped,
10
+ * dying on any navigation with `no such node`. A literal port would break
11
+ * snapshot-then-click, the tool's core workflow.
12
+ *
13
+ * Decision.
14
+ * 1. `Uid` becomes an OPAQUE TAGGED STRING, `<scheme>:<payload>`. Tools never
15
+ * parse, build, or order uids; only the driver whose scheme tags a uid may
16
+ * resolve it. Byte-level codec: THE UID CODEC block below.
17
+ * 2. The CDP driver keeps its handle: `cdp:<backendDOMNodeId>`, and still
18
+ * accepts bare digits, so old snapshots and the MCP schema keep working.
19
+ * 3. The BiDi driver does NOT encode a `sharedId`. It STAMPS the page:
20
+ * snapshot writes `data-cdp-uid="<stamp>"` onto every emitted node and the
21
+ * uid is `bidi:<stamp>`, resolved by a locateNodes css lookup. Page state,
22
+ * not protocol state, so it survives a dropped socket, a fresh session,
23
+ * and a fresh CLI process, which is what the one-shot tools depend on.
24
+ * 4. Connection lifetime is declared, not implied: `lifetime` is "per-call"
25
+ * (CDP, release() closes) or "session" (BiDi, release() is a refcount
26
+ * decrement and the socket lives until dispose()). Tools call withDriver()
27
+ * and stay ignorant of which they got.
28
+ * 5. Anything on exactly one browser stays OUT of the interface and is named
29
+ * in `Capability`. tools/list filters on REQUIRED_CAPABILITIES, so
30
+ * tracing, heap, lighthouse, and CPU throttling are simply absent under
31
+ * Firefox rather than present and throwing.
32
+ *
33
+ * Rejected alternatives.
34
+ * A. `Uid = number` indexing a driver-side handle table. The table dies with
35
+ * the process, so the CLI (one process per call) could never resolve a uid
36
+ * it printed last call: works under MCP, fails under CLI, the worst split.
37
+ * B. Encode the `sharedId` (`bidi:<ctx>:<sharedId>`). It is meaningless
38
+ * outside the session that minted it and dies on every navigation. Both
39
+ * verified against Firefox 153.0.3.
40
+ * C. A long-lived module-scoped BiDi session as the WHOLE answer, no stamps.
41
+ * Insufficient, not wrong: it fixes the socket, and recorder.ts /
42
+ * network_mock.ts already prove the pattern, but navigation still voids
43
+ * every sharedId and the CLI gets a new session per process. Adopted as
44
+ * the transport (4) with stamps on top (3). Combination, not either/or.
45
+ * D. A computed unique css path instead of stamping, to avoid touching the
46
+ * DOM. Any sibling insertion silently resolves it to the WRONG node, while
47
+ * a stamp resolves to the right node or none. Loud miss over quiet mis-hit.
48
+ * E. One flat interface with `throw new Error("unsupported")` bodies under
49
+ * Firefox. Moves discovery from tools/list to runtime and makes an honest
50
+ * asymmetry look like a bug.
51
+ *
52
+ * Consequences. Stamping mutates the page (one attribute per snapshotted
53
+ * node), and a node the page re-renders from scratch loses its stamp, so uids
54
+ * expire on re-render rather than on navigation. Both are stated in
55
+ * `uidStability`. Do not "simplify" Uid back to a number: that is A, and it
56
+ * breaks the CLI.
57
+ */
58
+
59
+ import type { ToolName } from "./index.ts";
60
+ import type { TargetSelector } from "./types.ts";
61
+
62
+ /* ---------------------------------- uids ---------------------------------- */
63
+
64
+ /**
65
+ * An opaque element reference. NEVER parse, construct, or compare a DriverUid
66
+ * except by string equality. It is produced by Driver.snapshot / Driver.locate
67
+ * and consumed by Driver.resolve.
68
+ */
69
+ export type DriverUid = string;
70
+
71
+ /* THE UID CODEC, binding on every driver. Two implementers reading only this
72
+ * block must produce interoperable encodings.
73
+ *
74
+ * GRAMMAR. `uid := scheme ":" payload`, split on the FIRST colon only.
75
+ * scheme matches /^[a-z][a-z0-9]{0,7}$/ and equals the driver's `scheme`
76
+ * field; payload is 1..503 chars, no control chars, MAY contain ":"; total
77
+ * length <= 512. A driver MUST reject a uid whose scheme is not its own with
78
+ * code "foreign-uid" and MUST NOT guess at another driver's scheme.
79
+ *
80
+ * SCHEME "cdp". payload := decimal backendDOMNodeId, no sign, no leading
81
+ * zeros, no whitespace. Encode `cdp:${backendNodeId}`; decode Number(payload),
82
+ * which must be a positive safe integer. LEGACY: a uid matching
83
+ * LEGACY_NUMERIC_UID with no scheme prefix MUST be accepted by the cdp driver
84
+ * as `cdp:<digits>`, and by no other driver.
85
+ *
86
+ * SCHEME "bidi". payload := exactly 12 lowercase hex chars, /^[0-9a-f]{12}$/,
87
+ * from a cryptographically-uniform source, never reused within a document.
88
+ * The driver writes it into the live DOM as UID_STAMP_ATTR on the referenced
89
+ * element in the SAME call that mints the uid. Resolution is
90
+ * browsingContext.locateNodes `{type:"css", value:'[data-cdp-uid="<payload>"]'}`
91
+ * in the uid's context. Zero matches MUST raise "stale-uid". More than one
92
+ * match MUST take the first node in document order and MUST NOT throw, since
93
+ * cloning a stamped subtree is legal page behavior.
94
+ */
95
+
96
+ /** Attribute the bidi scheme stamps onto elements. Never change this string. */
97
+ export const UID_STAMP_ATTR = "data-cdp-uid" as const;
98
+
99
+ /** Matches the legacy bare-integer uid that the cdp driver must still accept. */
100
+ export const LEGACY_NUMERIC_UID = /^[0-9]+$/;
101
+
102
+ /** True when `u` carries `scheme` as its tag (or is a legacy bare integer for cdp). */
103
+ export function uidHasScheme(u: DriverUid, scheme: string): boolean {
104
+ return u.startsWith(`${scheme}:`) || (scheme === "cdp" && LEGACY_NUMERIC_UID.test(u));
105
+ }
106
+
107
+ /**
108
+ * How long a uid stays resolvable. Declared per driver so a caller can decide
109
+ * when to re-snapshot instead of learning it from a failure.
110
+ * - "browser-node": valid until the underlying DOM node is destroyed, across
111
+ * connections and processes (cdp).
112
+ * - "document-stamp": valid until the document unloads OR the node is
113
+ * re-created by the page, across connections and processes (bidi).
114
+ */
115
+ export type UidStability = "browser-node" | "document-stamp";
116
+
117
+ /* ------------------------------- capabilities ------------------------------- */
118
+
119
+ /**
120
+ * Capabilities that are NOT universal. Anything a driver can always do has no
121
+ * token here. A driver declares the subset it supports; tools/list filters on
122
+ * REQUIRED_CAPABILITIES so an unsupported tool is absent, never present and
123
+ * throwing.
124
+ */
125
+ export type Capability =
126
+ | "trace.performance" // Chrome tracing, behind every performance_* tool
127
+ | "heap.snapshot" // V8 heap snapshots
128
+ | "audit.lighthouse" // shelling out to lighthouse against a debug port
129
+ | "emulate.cpuThrottling" // CPU multiplier; absent from BiDi's emulation module
130
+ | "emulate.mediaFeatures" // media type + features, e.g. prefers-color-scheme
131
+ | "emulate.deviceMetrics" // past width/height: scale factor, mobile flag
132
+ | "emulate.networkConditions" // throttled or offline conditions
133
+ | "screenshot.fullPage" // one-shot full scrollable page, not just the viewport
134
+ | "screenshot.element" // capture clipped to a single element
135
+ | "network.intercept" // fulfill, fail, or continue a matched request
136
+ | "network.responseBodyPostHoc" // read a body for a request not armed in advance
137
+ | "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
138
+ | "input.insertTextAtomic" // atomic value commit, not synthesized keystrokes
139
+ | "locate.text" // find a node by visible text substring
140
+ | "locate.xpath"; // find a node by xpath
141
+
142
+ /**
143
+ * Tools whose availability depends on a capability. A tool absent from this
144
+ * map is universal and must be listed by every driver. Keyed by ToolName so
145
+ * the compiler catches a renamed tool.
146
+ */
147
+ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability[]>> = {
148
+ performance_start_trace: ["trace.performance"],
149
+ performance_stop_trace: ["trace.performance"],
150
+ performance_analyze_insight: ["trace.performance"],
151
+ performance_trace: ["trace.performance"],
152
+ take_heapsnapshot: ["heap.snapshot"],
153
+ lighthouse_audit: ["audit.lighthouse"],
154
+ mock_request: ["network.intercept"],
155
+ list_mocks: ["network.intercept"],
156
+ clear_mocks: ["network.intercept"],
157
+ } as const;
158
+
159
+ /* --------------------------------- errors --------------------------------- */
160
+
161
+ /** Machine-readable failure kinds every driver maps its protocol errors onto. */
162
+ export type DriverErrorCode =
163
+ | "foreign-uid" // the uid's scheme belongs to another driver
164
+ | "stale-uid" // ours, but no longer resolves: node gone or document unloaded
165
+ | "no-such-element" // a locator matched nothing
166
+ | "no-such-target" // no page/context matched the TargetSelector
167
+ | "unsupported" // operation not supported; check Capability first
168
+ | "timeout" // the per-command deadline elapsed
169
+ | "disconnected" // socket closed, session ended, or browser exited
170
+ | "page-error"; // page-side exception: evaluate threw, navigation rejected
171
+
172
+ export interface DriverError extends Error {
173
+ readonly code: DriverErrorCode;
174
+ readonly data?: unknown;
175
+ }
176
+
177
+ export function isDriverError(e: unknown): e is DriverError {
178
+ return e instanceof Error && typeof (e as { code?: unknown }).code === "string";
179
+ }
180
+
181
+ /* ------------------------------ neutral shapes ------------------------------ */
182
+
183
+ /** A page/tab, neutral over CDP targets and BiDi browsing contexts. */
184
+ export interface PageInfo {
185
+ id: string;
186
+ url: string;
187
+ title: string;
188
+ /** CDP target type ("page", "iframe", "worker", ...), when the driver has one. Additive: BiDi leaves it unset. */
189
+ type?: string;
190
+ }
191
+
192
+ /** How a caller points at an element. Exactly one field is set. */
193
+ export type ElementLocator =
194
+ | { uid: DriverUid }
195
+ | { css: string }
196
+ | { xpath: string } // requires Capability "locate.xpath"
197
+ | { text: string }; // requires Capability "locate.text"
198
+
199
+ /** One line of the snapshot tree, before it is rendered to text. */
200
+ export interface SnapshotNode {
201
+ uid: DriverUid;
202
+ role: string;
203
+ name?: string;
204
+ depth: number;
205
+ /** value / checked / expanded / disabled / url / focused, already stringified. */
206
+ extras?: Record<string, string>;
207
+ }
208
+
209
+ export interface NavigateOptions {
210
+ url?: string;
211
+ reload?: boolean;
212
+ ignoreCache?: boolean;
213
+ waitUntil?: "load" | "domcontentloaded";
214
+ timeoutMs?: number;
215
+ }
216
+
217
+ export interface NavigateResult {
218
+ url: string;
219
+ /** CDP frameId or BiDi context id. Opaque. */
220
+ contextId: string;
221
+ reloaded?: boolean;
222
+ waitedFor: "load" | "domcontentloaded" | "committed" | "timeout";
223
+ }
224
+
225
+ export interface MouseButtonOptions {
226
+ button?: "left" | "right" | "middle";
227
+ clickCount?: number;
228
+ }
229
+
230
+ export interface KeyPress {
231
+ /** "Enter", "Tab", "ArrowDown", or a single printable character. */
232
+ key: string;
233
+ /** "Control" | "Shift" | "Alt" | "Meta", case-insensitive. */
234
+ modifiers?: readonly string[];
235
+ }
236
+
237
+ export interface ScreenshotOptions {
238
+ format?: "png" | "jpeg";
239
+ quality?: number;
240
+ fullPage?: boolean; // requires "screenshot.fullPage"
241
+ clip?: ElementLocator; // requires "screenshot.element"
242
+ }
243
+
244
+ /** Each optional field names the Capability it requires, if any. */
245
+ export interface EmulationOptions {
246
+ width?: number;
247
+ height?: number;
248
+ deviceScaleFactor?: number; // "emulate.deviceMetrics"
249
+ mobile?: boolean; // "emulate.deviceMetrics"
250
+ userAgent?: string;
251
+ cpuThrottlingRate?: number; // "emulate.cpuThrottling"
252
+ media?: string; // "emulate.mediaFeatures"
253
+ mediaFeatures?: ReadonlyArray<{ name: string; value: string }>; // "emulate.mediaFeatures"
254
+ /** Requires "emulate.networkConditions". connectionType is additive: CDP's optional
255
+ * Network.ConnectionType hint ("wifi", "cellular4g", ...); drivers without a matching
256
+ * concept simply ignore it. */
257
+ networkConditions?: { offline?: boolean; latency?: number; downloadThroughput?: number; uploadThroughput?: number; connectionType?: string };
258
+ clearOverrides?: boolean;
259
+ }
260
+
261
+ /** Neutral event names. Payload shapes are the driver's normalized records. */
262
+ export type DriverEvent =
263
+ | "navigated"
264
+ | "console"
265
+ | "network.request"
266
+ | "network.response"
267
+ | "dialog"
268
+ | "page.closed";
269
+
270
+ export interface DialogInfo {
271
+ type: string;
272
+ message: string;
273
+ url: string;
274
+ defaultPrompt?: string;
275
+ }
276
+
277
+ /** One dialog the driver answered, echoing back how it was answered. */
278
+ export interface HandledDialogInfo extends DialogInfo {
279
+ accept: boolean;
280
+ promptText?: string;
281
+ handled: true;
282
+ }
283
+
284
+ export interface InterceptRule {
285
+ /** Glob: `*` any run, `?` one char. Both drivers normalize to this. */
286
+ urlPattern: string;
287
+ method?: string;
288
+ action: "fulfill" | "fail" | "continue";
289
+ status?: number;
290
+ body?: string;
291
+ contentType?: string;
292
+ headers?: Record<string, string>;
293
+ errorReason?: string;
294
+ delayMs?: number;
295
+ failRate?: number;
296
+ }
297
+
298
+ /* --------------------------------- drivers --------------------------------- */
299
+
300
+ /**
301
+ * Connection ownership, declared rather than discovered.
302
+ * - "per-call": acquiring opens a transport and release() closes it. Nothing
303
+ * survives between tool calls. This is CDP today.
304
+ * - "session": the transport carries a session that CANNOT be re-dialed, so
305
+ * release() only decrements a refcount and the transport lives until
306
+ * dispose(). This is BiDi. A "session" driver MUST be memoized at module
307
+ * scope (see recorder.ts / network_mock.ts for the existing pattern) and
308
+ * MUST tolerate concurrent holders. It does NOT transparently re-establish
309
+ * itself if the transport dies: a BiDi session cannot be resumed on a
310
+ * second socket (re-dialing gets `invalid session id`), so a dead
311
+ * transport surfaces as an error on every subsequent call instead, and a
312
+ * caller that hits one must dispose() and acquire a fresh driver.
313
+ * Tools MUST NOT branch on this value. It exists so that withDriver's contract
314
+ * is unambiguous and so a reader knows why release() is not a close().
315
+ */
316
+ export type LifetimeModel = "per-call" | "session";
317
+
318
+ /** Browser-scoped operations. One per browser process. */
319
+ export interface BrowserDriver {
320
+ /** Uid scheme tag owned by this driver, e.g. "cdp" or "bidi". */
321
+ readonly scheme: string;
322
+ readonly lifetime: LifetimeModel;
323
+ readonly capabilities: ReadonlySet<Capability>;
324
+ readonly uidStability: UidStability;
325
+ /** "accessibility-tree" when snapshots come from a native a11y dump. */
326
+ readonly snapshotFidelity: "accessibility-tree" | "dom-heuristic";
327
+
328
+ /** opts.all: include non-page targets (workers, iframes, ...) when the driver has that concept. */
329
+ listPages(opts?: { all?: boolean }): Promise<PageInfo[]>;
330
+ newPage(url?: string): Promise<PageInfo>;
331
+ /** success mirrors the underlying protocol's own success signal when it has one (CDP's
332
+ * Target.closeTarget); a driver without such a signal reports true once close resolves. */
333
+ closePage(id: string): Promise<{ success: boolean }>;
334
+ /** Bring a page to the front. Also writes the selected-page state file. */
335
+ activatePage(id: string): Promise<PageInfo>;
336
+
337
+ /** Acquire a page handle. The caller MUST release() it. Prefer withDriver. */
338
+ page(selector: TargetSelector): Promise<PageDriver>;
339
+
340
+ /** Tear down the transport: process exit (CLI) or shutdown (MCP server).
341
+ * Idempotent. MUST NOT be called by a tool. */
342
+ dispose(): Promise<void>;
343
+ }
344
+
345
+ /** Page-scoped operations. Every tool in src/tools/ maps onto this surface. */
346
+ export interface PageDriver {
347
+ readonly info: PageInfo;
348
+ readonly browser: BrowserDriver;
349
+
350
+ /* navigation */
351
+ navigate(opts: NavigateOptions): Promise<NavigateResult>;
352
+ /** Poll until `text` appears in the rendered body, or reject with "timeout". */
353
+ waitForText(text: string, timeoutMs?: number, pollMs?: number): Promise<{ found: true; elapsedMs: number }>;
354
+
355
+ /* script */
356
+ /** Evaluate an expression, or call it as a function when `args` is given. */
357
+ evaluate(expression: string, opts?: { args?: readonly unknown[]; awaitPromise?: boolean }): Promise<unknown>;
358
+ /** Call a function literal with an element as `this`, returning by value. */
359
+ callOnElement(loc: ElementLocator, functionDeclaration: string, args?: readonly unknown[]): Promise<unknown>;
360
+
361
+ /* elements */
362
+ /** Emit the snapshot tree. Mints (and, under "bidi", stamps) every uid. */
363
+ snapshot(opts?: { interactiveOnly?: boolean }): Promise<SnapshotNode[]>;
364
+ /** Mint a uid for the first node matching a non-uid locator. */
365
+ locate(loc: ElementLocator): Promise<DriverUid>;
366
+ /** Assert a uid still resolves. Rejects "stale-uid" / "foreign-uid". */
367
+ resolve(uid: DriverUid): Promise<void>;
368
+
369
+ /* input */
370
+ click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }>;
371
+ hover(loc: ElementLocator): Promise<{ x: number; y: number }>;
372
+ drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }>;
373
+ /** Overwrite an element's value. One atomic commit under
374
+ * "input.insertTextAtomic", else synthesized keystrokes, so per-key handlers
375
+ * on the page WILL fire. Callers must not depend on either behavior. */
376
+ setValue(loc: ElementLocator, value: string): Promise<void>;
377
+ /** Append text at the focus point without clearing first. */
378
+ typeText(loc: ElementLocator, text: string): Promise<void>;
379
+ pressKey(press: KeyPress): Promise<void>;
380
+ /** Attach absolute file paths to a file input. */
381
+ setFiles(loc: ElementLocator, files: readonly string[]): Promise<void>;
382
+
383
+ /* rendering */
384
+ /** Returns raw image bytes. Artifact naming and writing stay in the tool. */
385
+ screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }>;
386
+ /** Apply overrides. Returns the field names actually applied. */
387
+ emulate(opts: EmulationOptions): Promise<{ applied: string[] }>;
388
+
389
+ /* events */
390
+ /** Subscribe to a neutral event. Returns an unsubscribe fn. */
391
+ on(event: DriverEvent, handler: (payload: Record<string, unknown>) => void): () => void;
392
+ /** Resolve on the first matching event, or reject with "timeout". */
393
+ waitForEvent(event: DriverEvent, predicate?: (p: Record<string, unknown>) => boolean, timeoutMs?: number): Promise<Record<string, unknown>>;
394
+
395
+ /* dialogs */
396
+ /** Answer the next user prompt. Combine with waitForEvent("dialog") to arm first.
397
+ * opts.timeoutMs: how long to wait for a dialog to open (default 15000).
398
+ * opts.autoMs: instead of waiting for exactly one dialog, answer every dialog that
399
+ * opens during this window (ms) and resolve with the list, never throwing on none. */
400
+ handleDialog(
401
+ accept: boolean,
402
+ promptText?: string,
403
+ opts?: { timeoutMs?: number; autoMs?: number },
404
+ ): Promise<HandledDialogInfo | { handled: HandledDialogInfo[]; count: number }>;
405
+
406
+ /* network */
407
+ /** Begin retaining response bodies. REQUIRED before getResponseBody unless
408
+ * the driver declares "network.responseBodyPostHoc". Returns a stop fn. */
409
+ startBodyCapture(): Promise<() => Promise<void>>;
410
+ getResponseBody(requestId: string): Promise<{ body: string; base64Encoded: boolean }>;
411
+ /** Requires "network.intercept". Returns a fn that removes the rule. */
412
+ intercept(rules: readonly InterceptRule[]): Promise<() => Promise<void>>;
413
+
414
+ /** Give up this handle: closes the transport under "per-call", decrements a
415
+ * refcount under "session". Idempotent. Never assume it ends the session. */
416
+ release(): Promise<void>;
417
+ }
418
+
419
+ /** The neutral replacement for withPage(): acquire, run, always release. The
420
+ * ONLY acquisition path a tool should use, correct under both LifetimeModels. */
421
+ export type WithDriver = <T>(selector: TargetSelector, fn: (page: PageDriver) => Promise<T>) => Promise<T>;
422
+
423
+ /** Names a driver can be selected by, e.g. via a CDP_BROWSER env var. */
424
+ export type DriverKind = "chrome" | "firefox";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts) plus
3
+ * the 7 Firefox-only console/network/mock tools (src/bidi-tools.ts). This is what src/neutral.ts
4
+ * used to be; it now composes two files instead of reimplementing everything in one.
5
+ */
6
+ import type { BrowserDriver } from "./driver.ts";
7
+ import type { ToolName } from "./index.ts";
8
+ import { SHARED_TOOLS } from "./shared-tools.ts";
9
+ import { BIDI_ONLY_TOOLS } from "./bidi-tools.ts";
10
+
11
+ export const FIREFOX_TOOLS: Partial<Record<ToolName, (driver: BrowserDriver, args: never) => Promise<unknown>>> = {
12
+ ...SHARED_TOOLS,
13
+ ...BIDI_ONLY_TOOLS,
14
+ };