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/CHANGELOG.md +76 -9
- package/CONTRACT.md +7 -5
- package/README.md +99 -28
- package/dist/cli.js +3248 -1033
- package/dist/index.js +1418 -898
- package/dist/mcp.js +3447 -1064
- package/package.json +12 -6
- package/src/backend.ts +86 -0
- package/src/bidi/client.ts +319 -0
- package/src/bidi/driver.ts +851 -0
- package/src/bidi/launch.ts +192 -0
- package/src/bidi/protocol.ts +782 -0
- package/src/bidi/snapshot.ts +419 -0
- package/src/bidi-tools.ts +193 -0
- package/src/capabilities.ts +43 -0
- package/src/cdp/driver.ts +660 -0
- package/src/cli.ts +97 -14
- package/src/client.ts +25 -7
- package/src/driver.ts +424 -0
- package/src/firefox-tools.ts +17 -0
- package/src/index.ts +55 -42
- package/src/leases-tools.ts +101 -0
- package/src/leases.ts +533 -0
- package/src/manifest.ts +211 -13
- package/src/mcp.ts +73 -15
- package/src/shared-tools.ts +466 -0
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
- package/src/tools/dialogs.ts +0 -236
- package/src/tools/emulation.ts +0 -212
- package/src/tools/input.ts +0 -399
- package/src/tools/pages.ts +0 -159
- package/src/tools/screenshot.ts +0 -180
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
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
+
};
|