cdp-toolkit 1.0.0 → 1.2.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,17 @@
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";
29
+ import { leaseFromArgs, withLeaseScope } from "./leases.ts";
26
30
 
27
- const USAGE = `cdp-toolkit — raw single-target CDP, 29-tool chrome-devtools-mcp parity.
31
+ const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
28
32
 
29
33
  Usage:
30
- bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
31
- bun run src/cli.ts --list
34
+ bun run src/cli.ts <tool> [--browser chrome|firefox] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
35
+ bun run src/cli.ts --list [--browser chrome|firefox]
36
+ bun run src/cli.ts --capabilities [--browser chrome|firefox]
32
37
 
33
38
  Examples:
34
39
  bun run src/cli.ts list_pages
@@ -36,9 +41,19 @@ Examples:
36
41
  bun run src/cli.ts click --target index:0 --uid 42
37
42
  bun run src/cli.ts evaluate_script --json '{"expression":"1+2"}'
38
43
  bun run src/cli.ts take_screenshot --target url:example --fullPage true
44
+ bun run src/cli.ts --browser firefox take_snapshot
45
+ bun run src/cli.ts --capabilities --browser firefox
46
+
47
+ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else "chrome" (default,
48
+ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
49
+ to), used for exactly one tool call, then disposed before the process exits.
39
50
 
40
51
  Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
41
- Run with --list to print every available tool name.`;
52
+ Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
53
+ Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
54
+ Run with --list to print every tool name available under the selected backend.
55
+ Run with --capabilities to see, per tool, whether it is available and (if not) which capability
56
+ the selected backend is missing.`;
42
57
 
43
58
  /** Coerce a raw CLI string into boolean / number / string. */
44
59
  function coerce(raw: string): boolean | number | string {
@@ -54,12 +69,13 @@ function coerce(raw: string): boolean | number | string {
54
69
  interface ParsedArgs {
55
70
  tool?: string;
56
71
  list: boolean;
72
+ capabilities: boolean;
57
73
  args: Record<string, unknown>;
58
74
  }
59
75
 
60
- /** Parse process argv (already sliced to drop the runtime + script). */
76
+ /** Parse process argv (already sliced to drop the runtime + script, and with --browser already stripped). */
61
77
  function parseArgv(argv: string[]): ParsedArgs {
62
- const out: ParsedArgs = { list: false, args: {} };
78
+ const out: ParsedArgs = { list: false, capabilities: false, args: {} };
63
79
  // Collect flag pairs separately so --json can be merged BEFORE individual
64
80
  // flags override it, regardless of token order.
65
81
  let jsonObj: Record<string, unknown> | undefined;
@@ -71,6 +87,10 @@ function parseArgv(argv: string[]): ParsedArgs {
71
87
  out.list = true;
72
88
  continue;
73
89
  }
90
+ if (token === "--capabilities") {
91
+ out.capabilities = true;
92
+ continue;
93
+ }
74
94
  if (token.startsWith("--")) {
75
95
  const key = token.slice(2);
76
96
  const next = argv[i + 1];
@@ -109,10 +129,23 @@ function isToolName(name: string): name is ToolName {
109
129
  }
110
130
 
111
131
  async function main(): Promise<number> {
112
- const parsed = parseArgv(process.argv.slice(2));
132
+ const rawArgv = process.argv.slice(2);
133
+ const browser = resolveBrowserKind(rawArgv);
134
+ const parsed = parseArgv(stripBrowserFlag(rawArgv));
135
+ const availability = toolAvailability(browser);
136
+ const availableSet = new Set(availability.available);
137
+
138
+ if (parsed.capabilities) {
139
+ const lines = [`backend: ${browser}`, "", "available:"];
140
+ for (const name of availability.available) lines.push(` ${name}`);
141
+ lines.push("", "unavailable:");
142
+ for (const u of availability.unavailable) lines.push(` ${u.name} (needs: ${u.missing.join(", ")})`);
143
+ process.stdout.write(`${lines.join("\n")}\n`);
144
+ return 0;
145
+ }
113
146
 
114
147
  if (parsed.list) {
115
- process.stdout.write(`${TOOL_NAMES.join("\n")}\n`);
148
+ process.stdout.write(`${availability.available.join("\n")}\n`);
116
149
  return 0;
117
150
  }
118
151
 
@@ -128,12 +161,62 @@ async function main(): Promise<number> {
128
161
  return 1;
129
162
  }
130
163
 
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;
164
+ if (!availableSet.has(parsed.tool)) {
165
+ const gap = availability.unavailable.find((u) => u.name === parsed.tool);
166
+ process.stderr.write(
167
+ `${JSON.stringify({
168
+ error: `tool '${parsed.tool}' is not available under --browser ${browser}${gap ? ` (needs: ${gap.missing.join(", ")})` : ""}. Run --capabilities --browser ${browser} to see the full list.`,
169
+ })}\n`,
170
+ );
171
+ return 1;
172
+ }
173
+
174
+ // Decision: the CLI cannot claim a lease. Per backend.ts, a CLI invocation is
175
+ // one process per call, so the claiming process is already dead by the next
176
+ // call and the dead-pid rule would make the lease reclaimable at once,
177
+ // regardless of ttlMs. A lease that is reclaimable on arrival is worse than
178
+ // no lease, because it reads as protection that is not there. Every OTHER
179
+ // CLI call still goes through assertLeaseOk and may present --lease.
180
+ if (parsed.tool === "claim_page") {
181
+ process.stderr.write(
182
+ `${JSON.stringify({
183
+ error:
184
+ "claim_page is not available from the CLI: a CLI invocation is one process per call, so the lease it claimed would be reclaimable immediately by the dead-pid rule. Claim from the cdp-toolkit MCP server, which is long lived, then pass the token to CLI calls with --lease <token>.",
185
+ })}\n`,
186
+ );
187
+ return 1;
188
+ }
189
+
190
+ // ONE lease scope per invocation, wrapping BOTH backend branches, exactly as
191
+ // mcp.ts does. `--lease <token>` lands in parsed.args like any other flag, so
192
+ // no tool signature changes and a tool added later is covered for free.
193
+ const tool = parsed.tool;
194
+ return withLeaseScope(leaseFromArgs(parsed.args), async () => {
195
+ if (browser === "chrome") {
196
+ const fn = TOOLS[tool];
197
+ // The registry's value type is the union of all tool signatures; each tool
198
+ // validates its own args at runtime, so we hand the parsed object through.
199
+ const result = await (fn as (args: unknown) => Promise<unknown>)(parsed.args);
200
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
201
+ return 0;
202
+ }
203
+
204
+ // Firefox: one process per invocation. Launch, run exactly one tool call, always dispose,
205
+ // even if the tool call itself throws, so the spawned Firefox process never leaks.
206
+ const neutralFn = FIREFOX_TOOLS[tool];
207
+ if (!neutralFn) {
208
+ process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}\n`);
209
+ return 1;
210
+ }
211
+ const session = await startFirefoxSession();
212
+ try {
213
+ const result = await neutralFn(session.driver, parsed.args as never);
214
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
215
+ return 0;
216
+ } finally {
217
+ await session.dispose();
218
+ }
219
+ });
137
220
  }
138
221
 
139
222
  main()
package/src/client.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Core raw-CDP client. Zero runtime dependencies — uses Node's global
2
+ * Core raw-CDP client. Zero runtime dependencies: uses Node's global
3
3
  * WebSocket (Node >= 22; verified on Node 25.9). Every tool module is built on
4
4
  * the primitives exported here. See CONTRACT.md.
5
5
  */
6
6
  import type { CdpResponse, Target, TargetSelector } from "./types.ts";
7
+ import { assertLeaseOk } from "./leases.ts";
7
8
 
8
9
  /** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
9
10
  export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -185,15 +186,32 @@ export function listTargets(): Promise<Target[]> {
185
186
  return httpJson<Target[]>("/json/list");
186
187
  }
187
188
 
188
- /** Browser-level WebSocket URL (GET /json/version) — for Target.* / Browser.*. */
189
+ /** Browser-level WebSocket URL (GET /json/version), for Target.* / Browser.*. */
189
190
  export async function browserWsUrl(): Promise<string> {
190
191
  const v = await httpJson<{ webSocketDebuggerUrl: string }>("/json/version");
191
192
  return v.webSocketDebuggerUrl;
192
193
  }
193
194
 
194
- /** Resolve a TargetSelector to a concrete page target. See types.ts for grammar. */
195
- export async function resolveTarget(selector: TargetSelector): Promise<Target> {
195
+ /** Resolve a TargetSelector to a concrete page target. See types.ts for grammar.
196
+ * opts.lease is for library callers; the MCP/CLI path supplies the token through
197
+ * the ambient lease scope instead (see leases.ts). */
198
+ export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
196
199
  const targets = await listTargets();
200
+ const hit = pickTarget(targets, selector);
201
+ // THE Chrome choke point. Every tool reaches a page through here, so a tool
202
+ // added after this shipped is protected with no action from its author.
203
+ //
204
+ // Deliberately NOT passing liveIds. It would be inert here: pickTarget
205
+ // returns an element of `targets`, so hit.id is always a member of that list
206
+ // and the "target-gone" staleness condition could never fire for the very
207
+ // target we just resolved. Passing it would only suggest a check is happening
208
+ // that is not. target-gone earns its keep on claim, and on a list_leases
209
+ // reap, where a record's id may genuinely be absent from the browser.
210
+ await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
211
+ return hit;
212
+ }
213
+
214
+ function pickTarget(targets: Target[], selector: TargetSelector): Target {
197
215
  const pages = targets.filter((t) => t.type === "page");
198
216
  if (!selector || selector === "active") {
199
217
  if (!pages.length) throw new CdpError("no page targets open");
@@ -230,9 +248,9 @@ export async function openBrowser(opts: { timeoutMs?: number } = {}): Promise<Cd
230
248
  /** Open a connection to a page target. Returns the connection and the target. */
231
249
  export async function openPage(
232
250
  selector: TargetSelector,
233
- opts: { timeoutMs?: number } = {},
251
+ opts: { timeoutMs?: number; lease?: string } = {},
234
252
  ): Promise<{ conn: CdpConnection; target: Target }> {
235
- const target = await resolveTarget(selector);
253
+ const target = await resolveTarget(selector, { lease: opts.lease });
236
254
  const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
237
255
  return { conn, target };
238
256
  }
@@ -244,7 +262,7 @@ export async function openPage(
244
262
  export async function withPage<T>(
245
263
  selector: TargetSelector,
246
264
  fn: (conn: CdpConnection, target: Target) => Promise<T>,
247
- opts: { timeoutMs?: number } = {},
265
+ opts: { timeoutMs?: number; lease?: string } = {},
248
266
  ): Promise<T> {
249
267
  const { conn, target } = await openPage(selector, opts);
250
268
  try {
package/src/driver.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * ADR-001: one browser-neutral Driver interface behind the 36 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,17 @@
1
+ /**
2
+ * The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts), the
3
+ * 7 Firefox-only console/network/mock tools (src/bidi-tools.ts), and the 3 lease tools
4
+ * (src/leases-tools.ts), which are backend-neutral by construction. This is what src/neutral.ts
5
+ * used to be; it now composes three files instead of reimplementing everything in one.
6
+ */
7
+ import type { BrowserDriver } from "./driver.ts";
8
+ import type { ToolName } from "./index.ts";
9
+ import { SHARED_TOOLS } from "./shared-tools.ts";
10
+ import { BIDI_ONLY_TOOLS } from "./bidi-tools.ts";
11
+ import { LEASE_TOOLS } from "./leases-tools.ts";
12
+
13
+ export const FIREFOX_TOOLS: Partial<Record<ToolName, (driver: BrowserDriver, args: never) => Promise<unknown>>> = {
14
+ ...SHARED_TOOLS,
15
+ ...BIDI_ONLY_TOOLS,
16
+ ...LEASE_TOOLS,
17
+ };