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/CONTRACT.md +1 -1
- package/README.md +34 -0
- package/dist/cli.js +3156 -1290
- package/dist/index.js +1116 -927
- package/dist/mcp.js +4472 -2626
- package/package.json +12 -6
- package/src/backend.ts +86 -0
- package/src/bidi/client.ts +319 -0
- package/src/bidi/driver.ts +823 -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 +72 -14
- package/src/driver.ts +424 -0
- package/src/firefox-tools.ts +14 -0
- package/src/index.ts +49 -41
- package/src/mcp.ts +59 -9
- package/src/shared-tools.ts +420 -0
- 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/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* cdp-toolkit public surface.
|
|
3
3
|
*
|
|
4
4
|
* Two exports:
|
|
5
|
-
* 1. `TOOLS`
|
|
5
|
+
* 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
|
|
6
6
|
* (snake_case string, all 29) to its raw-CDP implementation function. The
|
|
7
7
|
* CLI and any embedder dispatch through this single table.
|
|
8
8
|
* 2. The client primitives, re-exported so consumers can build their own
|
|
@@ -12,21 +12,14 @@
|
|
|
12
12
|
* value (or throws). See CONTRACT.md for the design rules each module follows.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
// ---
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
// --- input / interaction ---
|
|
24
|
-
import { click, hover, drag, fill, fillForm, typeText, pressKey, uploadFile } from "./tools/input.ts";
|
|
25
|
-
// --- screenshot + emulation ---
|
|
26
|
-
import { takeScreenshot } from "./tools/screenshot.ts";
|
|
27
|
-
import { emulate, resizePage } from "./tools/emulation.ts";
|
|
28
|
-
// --- dialogs ---
|
|
29
|
-
import { handleDialog } from "./tools/dialogs.ts";
|
|
15
|
+
// --- the 20 tools unified with Firefox onto one Driver-based implementation ---
|
|
16
|
+
// (list_pages/new_page/close_page/select_page, navigate_page/wait_for, evaluate_script,
|
|
17
|
+
// take_snapshot, click/hover/drag/fill/fill_form/type_text/press_key/upload_file,
|
|
18
|
+
// take_screenshot/emulate/resize_page, handle_dialog). See shared-tools.ts's file header for
|
|
19
|
+
// the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
|
|
20
|
+
// two implementations (console/network/mock, immediately below, unchanged from before).
|
|
21
|
+
import { createCdpDriver } from "./cdp/driver.ts";
|
|
22
|
+
import { SHARED_TOOLS } from "./shared-tools.ts";
|
|
30
23
|
// --- console + network (read the recorder buffer) ---
|
|
31
24
|
import { listConsoleMessages, getConsoleMessage } from "./tools/console.ts";
|
|
32
25
|
import { listNetworkRequests, getNetworkRequest } from "./tools/network.ts";
|
|
@@ -41,49 +34,64 @@ import {
|
|
|
41
34
|
import { takeHeapsnapshot } from "./tools/heap.ts";
|
|
42
35
|
// --- lighthouse (the sole non-CDP tool) ---
|
|
43
36
|
import { lighthouseAudit } from "./tools/lighthouse.ts";
|
|
44
|
-
// --- network mocking (Fetch domain)
|
|
37
|
+
// --- network mocking (Fetch domain) : toolkit addition beyond the 29 parity tools ---
|
|
45
38
|
import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
|
|
46
39
|
|
|
47
40
|
/** A toolkit tool: a single typed-args function returning JSON-serializable data. */
|
|
48
41
|
export type ToolFn = (args: never) => Promise<unknown>;
|
|
49
42
|
|
|
43
|
+
// One stateless CDP driver, reused across calls (lifetime "per-call": every page() acquisition
|
|
44
|
+
// still opens its own throwaway connection, same as the pre-migration withPage per-call model).
|
|
45
|
+
// Constructing it does not dial a browser (capabilities.ts already relies on this).
|
|
46
|
+
const cdpDriver = createCdpDriver();
|
|
47
|
+
|
|
48
|
+
/** Bind a shared-tools.ts function to the module-level CDP driver, args-typed for the registry. */
|
|
49
|
+
function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => Promise<unknown>): (args: A) => Promise<unknown> {
|
|
50
|
+
return (args: A) => fn(cdpDriver, args);
|
|
51
|
+
}
|
|
52
|
+
|
|
50
53
|
/**
|
|
51
54
|
* The complete chrome-devtools-mcp parity surface: all 29 MCP tools keyed by
|
|
52
55
|
* their canonical MCP (snake_case) name, plus one convenience superset tool
|
|
53
|
-
* (`performance_trace`, a robust single-call trace
|
|
56
|
+
* (`performance_trace`, a robust single-call trace : the start/stop pair cannot
|
|
54
57
|
* span two stateless CLI processes), plus a 3-tool network-mocking group
|
|
55
|
-
* (mock_request/list_mocks/clear_mocks
|
|
58
|
+
* (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend).
|
|
56
59
|
* 33 entries total. Listed explicitly so the mapping is auditable at a glance and
|
|
57
60
|
* the CLI can `--list` it.
|
|
61
|
+
*
|
|
62
|
+
* 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
|
|
63
|
+
* screenshot+emulation/dialogs) now route through shared-tools.ts's single Driver-based
|
|
64
|
+
* implementation via the CDP driver, instead of a Chrome-only copy in src/tools/*.ts.
|
|
65
|
+
* See shared-tools.ts's file header for the shape/behavior contract this preserves.
|
|
58
66
|
*/
|
|
59
67
|
export const TOOLS = {
|
|
60
68
|
// pages (4)
|
|
61
|
-
list_pages:
|
|
62
|
-
new_page:
|
|
63
|
-
close_page:
|
|
64
|
-
select_page:
|
|
69
|
+
list_pages: onCdp(SHARED_TOOLS.list_pages),
|
|
70
|
+
new_page: onCdp(SHARED_TOOLS.new_page),
|
|
71
|
+
close_page: onCdp(SHARED_TOOLS.close_page),
|
|
72
|
+
select_page: onCdp(SHARED_TOOLS.select_page),
|
|
65
73
|
// navigation (2)
|
|
66
|
-
navigate_page:
|
|
67
|
-
wait_for:
|
|
74
|
+
navigate_page: onCdp(SHARED_TOOLS.navigate_page),
|
|
75
|
+
wait_for: onCdp(SHARED_TOOLS.wait_for),
|
|
68
76
|
// evaluate (1)
|
|
69
|
-
evaluate_script:
|
|
77
|
+
evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
|
|
70
78
|
// snapshot (1)
|
|
71
|
-
take_snapshot:
|
|
79
|
+
take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
|
|
72
80
|
// interaction (8)
|
|
73
|
-
click: click,
|
|
74
|
-
hover: hover,
|
|
75
|
-
drag: drag,
|
|
76
|
-
fill: fill,
|
|
77
|
-
fill_form:
|
|
78
|
-
type_text:
|
|
79
|
-
press_key:
|
|
80
|
-
upload_file:
|
|
81
|
+
click: onCdp(SHARED_TOOLS.click),
|
|
82
|
+
hover: onCdp(SHARED_TOOLS.hover),
|
|
83
|
+
drag: onCdp(SHARED_TOOLS.drag),
|
|
84
|
+
fill: onCdp(SHARED_TOOLS.fill),
|
|
85
|
+
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
86
|
+
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
87
|
+
press_key: onCdp(SHARED_TOOLS.press_key),
|
|
88
|
+
upload_file: onCdp(SHARED_TOOLS.upload_file),
|
|
81
89
|
// screenshot + emulation (3)
|
|
82
|
-
take_screenshot:
|
|
83
|
-
emulate: emulate,
|
|
84
|
-
resize_page:
|
|
90
|
+
take_screenshot: onCdp(SHARED_TOOLS.take_screenshot),
|
|
91
|
+
emulate: onCdp(SHARED_TOOLS.emulate),
|
|
92
|
+
resize_page: onCdp(SHARED_TOOLS.resize_page),
|
|
85
93
|
// dialogs (1)
|
|
86
|
-
handle_dialog:
|
|
94
|
+
handle_dialog: onCdp(SHARED_TOOLS.handle_dialog),
|
|
87
95
|
// console + network (4)
|
|
88
96
|
list_console_messages: listConsoleMessages,
|
|
89
97
|
get_console_message: getConsoleMessage,
|
|
@@ -96,9 +104,9 @@ export const TOOLS = {
|
|
|
96
104
|
performance_trace: performanceTrace,
|
|
97
105
|
// heap (1)
|
|
98
106
|
take_heapsnapshot: takeHeapsnapshot,
|
|
99
|
-
// lighthouse (1)
|
|
107
|
+
// lighthouse (1) : the only non-CDP tool
|
|
100
108
|
lighthouse_audit: lighthouseAudit,
|
|
101
|
-
// network mocking (3)
|
|
109
|
+
// network mocking (3) : persistent per-target fake backend (toolkit addition, Fetch domain)
|
|
102
110
|
mock_request: mockRequest,
|
|
103
111
|
list_mocks: listMocks,
|
|
104
112
|
clear_mocks: clearMocks,
|
package/src/mcp.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* cdp-toolkit MCP server (stdio).
|
|
4
4
|
*
|
|
5
5
|
* Exposes the toolkit's raw-CDP tools to any MCP client (Claude Code, etc.) over
|
|
6
|
-
* the standard stdio transport. It does NOT connect to Chrome at startup
|
|
6
|
+
* the standard stdio transport. It does NOT connect to Chrome at startup: each
|
|
7
7
|
* tool call lazily opens a single-target CDP connection (with its own timeout),
|
|
8
8
|
* so the server loads cleanly even when Chrome isn't running; individual calls
|
|
9
9
|
* then fail with a clear error if the browser is unreachable.
|
|
@@ -11,18 +11,28 @@
|
|
|
11
11
|
* Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
|
|
12
12
|
* Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR.
|
|
13
13
|
*
|
|
14
|
-
* stdout is the JSON-RPC channel
|
|
14
|
+
* stdout is the JSON-RPC channel, all diagnostics go to stderr only.
|
|
15
15
|
*/
|
|
16
16
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
17
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
18
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
19
19
|
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
20
20
|
import { MANIFEST } from "./manifest.ts";
|
|
21
|
+
import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
22
|
+
import { toolAvailability } from "./capabilities.ts";
|
|
23
|
+
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
21
24
|
|
|
22
|
-
const VERSION = "1.
|
|
25
|
+
const VERSION = "1.1.0";
|
|
26
|
+
|
|
27
|
+
// --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
|
|
28
|
+
// else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
|
|
29
|
+
const BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
|
|
30
|
+
const AVAILABILITY = toolAvailability(BROWSER);
|
|
31
|
+
const AVAILABLE_NAMES = new Set<string>(AVAILABILITY.available);
|
|
23
32
|
|
|
24
33
|
/** Loose dispatch view of the strongly-typed TOOLS registry. */
|
|
25
34
|
const dispatch = TOOLS as Record<string, (args: unknown) => Promise<unknown>>;
|
|
35
|
+
const neutralDispatch = FIREFOX_TOOLS as Record<string, (driver: import("./driver.ts").BrowserDriver, args: unknown) => Promise<unknown>>;
|
|
26
36
|
|
|
27
37
|
/** Warn (to stderr) about any registry/manifest drift, but don't fail startup. */
|
|
28
38
|
function auditCoverage(): void {
|
|
@@ -37,18 +47,43 @@ function auditCoverage(): void {
|
|
|
37
47
|
const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: {} } });
|
|
38
48
|
|
|
39
49
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
40
|
-
|
|
50
|
+
// Filtered to whatever the selected backend can actually run, per REQUIRED_CAPABILITIES:
|
|
51
|
+
// under Chrome this is byte-identical to the unfiltered MANIFEST (every capability CDP needs
|
|
52
|
+
// is declared); under Firefox the performance/heap/lighthouse tools are simply absent.
|
|
53
|
+
tools: MANIFEST.filter((s) => AVAILABLE_NAMES.has(s.name)).map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
|
|
41
54
|
}));
|
|
42
55
|
|
|
43
56
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
44
57
|
const { name } = request.params;
|
|
45
58
|
const args = (request.params.arguments ?? {}) as unknown;
|
|
46
|
-
|
|
47
|
-
if (!
|
|
48
|
-
|
|
59
|
+
|
|
60
|
+
if (!AVAILABLE_NAMES.has(name)) {
|
|
61
|
+
const gap = AVAILABILITY.unavailable.find((u) => u.name === name);
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text" as const, text: `unknown tool: ${name}${gap ? ` (not available under --browser ${BROWSER}, needs: ${gap.missing.join(", ")})` : ""}` }],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (BROWSER === "chrome") {
|
|
69
|
+
const fn = dispatch[name];
|
|
70
|
+
if (!fn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
71
|
+
try {
|
|
72
|
+
const result = await fn(args);
|
|
73
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
77
|
+
}
|
|
49
78
|
}
|
|
79
|
+
|
|
80
|
+
// Firefox: one BiDi session memoized for the life of this server process (lifetime "session",
|
|
81
|
+
// ADR-001); launched lazily on the first Firefox tool call, torn down on shutdown below.
|
|
82
|
+
const neutralFn = neutralDispatch[name];
|
|
83
|
+
if (!neutralFn) return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
50
84
|
try {
|
|
51
|
-
const
|
|
85
|
+
const session = await getOrCreateFirefoxSession();
|
|
86
|
+
const result = await neutralFn(session.driver, args);
|
|
52
87
|
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
53
88
|
} catch (err) {
|
|
54
89
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -58,4 +93,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
58
93
|
|
|
59
94
|
auditCoverage();
|
|
60
95
|
await server.connect(new StdioServerTransport());
|
|
61
|
-
console.error(`[cdp-toolkit] MCP server v${VERSION} ready
|
|
96
|
+
console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
|
|
97
|
+
|
|
98
|
+
// Firefox owns a real OS process (see bidi/launch.ts): it must be reaped on every shutdown path,
|
|
99
|
+
// not just a clean exit. SIGINT/SIGTERM cover ctrl-C and a supervising client killing the server;
|
|
100
|
+
// stdin 'close' covers the normal MCP shutdown (the client closes the stdio pipe). All three are
|
|
101
|
+
// idempotent through disposeFirefoxSession(), and a no-op entirely when Firefox was never launched.
|
|
102
|
+
let shuttingDown = false;
|
|
103
|
+
async function shutdown(): Promise<void> {
|
|
104
|
+
if (shuttingDown) return;
|
|
105
|
+
shuttingDown = true;
|
|
106
|
+
await disposeFirefoxSession();
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
process.on("SIGINT", () => void shutdown());
|
|
110
|
+
process.on("SIGTERM", () => void shutdown());
|
|
111
|
+
process.stdin.on("close", () => void shutdown());
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE implementation per tool, built only on the browser-neutral Driver contract in driver.ts
|
|
3
|
+
* (ADR-001), used by BOTH Chrome (via cdp/driver.ts's createCdpDriver()) and Firefox (via
|
|
4
|
+
* bidi/driver.ts's createFirefoxDriver()). This file replaces the split that used to exist
|
|
5
|
+
* between src/tools/*.ts (raw CDP, Chrome only) and src/neutral.ts (Driver-based, Firefox only)
|
|
6
|
+
* for the 20 tools below.
|
|
7
|
+
*
|
|
8
|
+
* THE HARD REQUIREMENT this file exists to satisfy: Chrome's return shape, timing, and quirks
|
|
9
|
+
* stay byte-identical to what src/tools/*.ts produced before this migration: that already
|
|
10
|
+
* shipped to npm. Where the clean Driver abstraction and that requirement conflicted, the
|
|
11
|
+
* requirement won; every such place is called out in a comment at the call site. The most
|
|
12
|
+
* significant ones:
|
|
13
|
+
* - take_snapshot's uid is rendered as a bare digit for the "cdp" scheme (matching the legacy
|
|
14
|
+
* backendDOMNodeId-as-uid contract) rather than the driver's own tagged "cdp:N" form, and its
|
|
15
|
+
* extras (checked/expanded/disabled/url/focused) keep the legacy per-key quoting AND the
|
|
16
|
+
* "expanded" quirk (included even when literally "false") rather than a clean uniform rule.
|
|
17
|
+
* - navigate_page's field names (frameId, not contextId) and waitedFor vocabulary (load /
|
|
18
|
+
* domcontentloaded / frameStoppedLoading / navigate-only) are the legacy CDP tool's, not the
|
|
19
|
+
* Driver's own (committed / timeout).
|
|
20
|
+
* - Every nested `target` object in a result is the legacy 3-field {id,url,title}: PageInfo's
|
|
21
|
+
* additive `type` field is deliberately dropped there (list_pages is the one place `type` is
|
|
22
|
+
* part of the contract).
|
|
23
|
+
*
|
|
24
|
+
* NOT here: list_console_messages / get_console_message / list_network_requests /
|
|
25
|
+
* get_network_request / mock_request / list_mocks / clear_mocks. Chrome's versions of those 7
|
|
26
|
+
* tools are built on src/tools/recorder.ts, a disk-persisted, cross-process capture keyed by
|
|
27
|
+
* target id (rec-<targetId>.jsonl) with a materially richer shape (ConsoleEntry/NetworkRequest
|
|
28
|
+
* correlation, per-rule hit counts, dedup-by-pattern+method). Firefox's existing implementation
|
|
29
|
+
* (now in src/bidi-tools.ts) is a same-process, in-memory event buffer with a different shape.
|
|
30
|
+
* Unifying them safely would mean either dropping Chrome's cross-process persistence (a real
|
|
31
|
+
* behavior change to something that ships today) or teaching Firefox to write the same JSONL
|
|
32
|
+
* format (new feature work, not a safety-preserving migration). Per the explicit instruction that
|
|
33
|
+
* behavior outranks the abstraction, these 7 stay two implementations; see the migration report.
|
|
34
|
+
*/
|
|
35
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import type {
|
|
38
|
+
BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
|
+
} from "./driver.ts";
|
|
40
|
+
import type { TargetSelector } from "./types.ts";
|
|
41
|
+
|
|
42
|
+
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
43
|
+
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
44
|
+
|
|
45
|
+
class SharedToolError extends Error {}
|
|
46
|
+
|
|
47
|
+
/** Trim a PageInfo down to the legacy 3-field {id,url,title} target shape used everywhere except list_pages. */
|
|
48
|
+
function target3(p: PageInfo): { id: string; url: string; title: string } {
|
|
49
|
+
return { id: p.id, url: p.url, title: p.title };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* -------------------------------- page acquisition -------------------------------- */
|
|
53
|
+
|
|
54
|
+
/** Acquire, run, always release: the Driver counterpart of client.ts's withPage. */
|
|
55
|
+
async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undefined, fn: (page: PageDriver) => Promise<T>): Promise<T> {
|
|
56
|
+
const page = await driver.page(target);
|
|
57
|
+
try {
|
|
58
|
+
return await fn(page);
|
|
59
|
+
} finally {
|
|
60
|
+
await page.release();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Same TargetSelector grammar as client.ts's resolveTarget. Matches its one asymmetry exactly:
|
|
66
|
+
* every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
|
|
67
|
+
* legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
|
|
68
|
+
* exact non-page targetId (a worker, an iframe) still resolves.
|
|
69
|
+
*/
|
|
70
|
+
async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
|
|
71
|
+
const pages = await driver.listPages();
|
|
72
|
+
if (selector === undefined || selector === "active") {
|
|
73
|
+
if (!pages.length) throw new SharedToolError("no page targets open");
|
|
74
|
+
return pages[0]!;
|
|
75
|
+
}
|
|
76
|
+
if (selector.startsWith("index:")) {
|
|
77
|
+
const p = pages[Number(selector.slice(6))];
|
|
78
|
+
if (!p) throw new SharedToolError(`no page target at index ${selector.slice(6)} (have ${pages.length})`);
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
if (selector.startsWith("url:")) {
|
|
82
|
+
const needle = selector.slice(4);
|
|
83
|
+
const p = pages.find((x) => x.url.includes(needle));
|
|
84
|
+
if (!p) throw new SharedToolError(`no page url containing '${needle}'`);
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
if (selector.startsWith("title:")) {
|
|
88
|
+
const needle = selector.slice(6);
|
|
89
|
+
const p = pages.find((x) => x.title.includes(needle));
|
|
90
|
+
if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
const exactPage = pages.find((x) => x.id === selector);
|
|
94
|
+
if (exactPage) return exactPage;
|
|
95
|
+
const all = await driver.listPages({ all: true });
|
|
96
|
+
const exact = all.find((x) => x.id === selector);
|
|
97
|
+
if (exact) return exact;
|
|
98
|
+
throw new SharedToolError(`no target with id '${selector}'`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* -------------------------------- element locators -------------------------------- */
|
|
102
|
+
|
|
103
|
+
function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
|
|
104
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== ("" as unknown);
|
|
105
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
106
|
+
if (hasUid === hasSelector) throw new SharedToolError("provide exactly one of { uid } or { selector }");
|
|
107
|
+
return hasUid ? { uid: args.uid as DriverUid } : { css: args.selector as string };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* -------------------------------- pages (4) -------------------------------- */
|
|
111
|
+
|
|
112
|
+
export async function listPages(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: PageInfo[]; count: number }> {
|
|
113
|
+
const pages = await driver.listPages({ all: args.all });
|
|
114
|
+
return { pages, count: pages.length };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function newPage(driver: BrowserDriver, args: { url?: string } = {}): Promise<{ targetId: string; url: string }> {
|
|
118
|
+
const p = await driver.newPage(args.url);
|
|
119
|
+
return { targetId: p.id, url: p.url };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function closePage(driver: BrowserDriver, args: { target?: TargetSelector }): Promise<{ closed: string; success: boolean }> {
|
|
123
|
+
if (args.target === undefined || args.target === "") throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
124
|
+
const p = await resolvePage(driver, args.target);
|
|
125
|
+
const res = await driver.closePage(p.id);
|
|
126
|
+
return { closed: p.id, success: res.success };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writeSelectedFile(id: string): Promise<void> {
|
|
130
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
131
|
+
await writeFile(join(STATE_DIR, "selected"), id, "utf8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function selectPage(driver: BrowserDriver, args: { target?: TargetSelector }): Promise<{ selected: string }> {
|
|
135
|
+
if (args.target === undefined || args.target === "") throw new SharedToolError("select_page requires an explicit target");
|
|
136
|
+
const p = await resolvePage(driver, args.target);
|
|
137
|
+
const activated = await driver.activatePage(p.id);
|
|
138
|
+
await writeSelectedFile(activated.id);
|
|
139
|
+
return { selected: activated.id };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ------------------------------ navigation (2) ------------------------------ */
|
|
143
|
+
|
|
144
|
+
/** Legacy tools/navigation.ts vocabulary, not the Driver's own (see file header). */
|
|
145
|
+
function toLegacyWaitedFor(w: NavigateResult["waitedFor"]): "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only" {
|
|
146
|
+
if (w === "committed") return "frameStoppedLoading";
|
|
147
|
+
if (w === "timeout") return "navigate-only";
|
|
148
|
+
return w;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface LegacyNavigateResult {
|
|
152
|
+
url: string;
|
|
153
|
+
frameId: string;
|
|
154
|
+
reloaded?: boolean;
|
|
155
|
+
waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function navigatePage(
|
|
159
|
+
driver: BrowserDriver,
|
|
160
|
+
args: { target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number },
|
|
161
|
+
): Promise<LegacyNavigateResult> {
|
|
162
|
+
const reload = args.reload === true;
|
|
163
|
+
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
164
|
+
throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
165
|
+
}
|
|
166
|
+
return withPage(driver, args.target, async (page) => {
|
|
167
|
+
const r = await page.navigate(args);
|
|
168
|
+
return {
|
|
169
|
+
url: r.url,
|
|
170
|
+
frameId: r.contextId,
|
|
171
|
+
...(r.reloaded ? { reloaded: r.reloaded } : {}),
|
|
172
|
+
waitedFor: toLegacyWaitedFor(r.waitedFor),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function waitForText(driver: BrowserDriver, args: { target?: TargetSelector; text: string; timeoutMs?: number; pollMs?: number }): Promise<{ found: true; waitedMs: number }> {
|
|
178
|
+
if (typeof args.text !== "string" || args.text.length === 0) throw new SharedToolError("wait_for: 'text' is required");
|
|
179
|
+
return withPage(driver, args.target, async (page) => {
|
|
180
|
+
const r = await page.waitForText(args.text, args.timeoutMs ?? 15_000, args.pollMs ?? 250);
|
|
181
|
+
return { found: true, waitedMs: r.elapsedMs };
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** Contract-mandated alias, matching tools/navigation.ts's exported `waitFor`. */
|
|
185
|
+
export const waitFor = waitForText;
|
|
186
|
+
|
|
187
|
+
/* ------------------------------- evaluate (1) ------------------------------- */
|
|
188
|
+
|
|
189
|
+
export async function evaluateScript(
|
|
190
|
+
driver: BrowserDriver,
|
|
191
|
+
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[] },
|
|
192
|
+
): Promise<unknown> {
|
|
193
|
+
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
194
|
+
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
195
|
+
}
|
|
196
|
+
return withPage(driver, args.target, (page) => page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* -------------------------------- snapshot (1) -------------------------------- */
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Legacy-faithful render, keyed by field name (see the file header's uid/extras note). The
|
|
203
|
+
* "cdp" scheme strips its "cdp:" tag to reproduce the historical bare-backendDOMNodeId uid;
|
|
204
|
+
* every other scheme keeps its tag (a tool must never guess at a foreign driver's uid grammar,
|
|
205
|
+
* per driver.ts's THE UID CODEC block: this is a display choice made by the tool that already
|
|
206
|
+
* knows its own driver's scheme, not a parse of a uid it didn't mint).
|
|
207
|
+
*/
|
|
208
|
+
const BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
|
|
209
|
+
const UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
|
|
210
|
+
|
|
211
|
+
function renderSnapshotLine(n: SnapshotNode, scheme: string): string {
|
|
212
|
+
const indent = " ".repeat(n.depth);
|
|
213
|
+
const uidText = scheme === "cdp" && n.uid.startsWith("cdp:") ? n.uid.slice(4) : n.uid;
|
|
214
|
+
const name = n.name !== undefined ? ` ${JSON.stringify(n.name)}` : "";
|
|
215
|
+
const extrasToks: string[] = [];
|
|
216
|
+
if (n.extras) {
|
|
217
|
+
for (const [k, v] of Object.entries(n.extras)) {
|
|
218
|
+
if (BARE_TOKEN_EXTRAS.has(k)) extrasToks.push(k);
|
|
219
|
+
else if (UNQUOTED_EXTRAS.has(k)) extrasToks.push(`${k}=${v}`);
|
|
220
|
+
else extrasToks.push(`${k}=${JSON.stringify(v)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const extra = extrasToks.length ? ` [${extrasToks.join(" ")}]` : "";
|
|
224
|
+
return `${indent}[${uidText}] ${n.role}${name}${extra}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function takeSnapshot(
|
|
228
|
+
driver: BrowserDriver,
|
|
229
|
+
args: { target?: TargetSelector; interactiveOnly?: boolean } = {},
|
|
230
|
+
): Promise<{ snapshot: string; target: { id: string; url: string; title: string }; nodeCount: number }> {
|
|
231
|
+
return withPage(driver, args.target, async (page) => {
|
|
232
|
+
const nodes = await page.snapshot({ interactiveOnly: args.interactiveOnly ?? false });
|
|
233
|
+
const scheme = page.browser.scheme;
|
|
234
|
+
return { snapshot: nodes.map((n) => renderSnapshotLine(n, scheme)).join("\n"), target: target3(page.info), nodeCount: nodes.length };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ------------------------------ interaction (8) ------------------------------ */
|
|
239
|
+
|
|
240
|
+
export async function click(
|
|
241
|
+
driver: BrowserDriver,
|
|
242
|
+
args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number },
|
|
243
|
+
): Promise<{ clicked: true; x: number; y: number }> {
|
|
244
|
+
return withPage(driver, args.target, async (page) => {
|
|
245
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
246
|
+
return { clicked: true, x, y };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
|
|
251
|
+
return withPage(driver, args.target, async (page) => {
|
|
252
|
+
const { x, y } = await page.hover(locatorOf(args));
|
|
253
|
+
return { hovered: true, x, y };
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function drag(
|
|
258
|
+
driver: BrowserDriver,
|
|
259
|
+
args: { target?: TargetSelector; from: { uid?: DriverUid; selector?: string }; to: { uid?: DriverUid; selector?: string } },
|
|
260
|
+
): Promise<{ dragged: true; from: { x: number; y: number }; to: { x: number; y: number } }> {
|
|
261
|
+
if (!args.from || !args.to) throw new SharedToolError("drag requires { from } and { to }");
|
|
262
|
+
return withPage(driver, args.target, async (page) => {
|
|
263
|
+
const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
|
|
264
|
+
return { dragged: true, from, to };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function fill(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; value: string }): Promise<{ filled: true; value: string }> {
|
|
269
|
+
if (typeof args.value !== "string") throw new SharedToolError("fill requires a string { value }");
|
|
270
|
+
return withPage(driver, args.target, async (page) => {
|
|
271
|
+
await page.setValue(locatorOf(args), args.value);
|
|
272
|
+
return { filled: true, value: args.value };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function fillForm(
|
|
277
|
+
driver: BrowserDriver,
|
|
278
|
+
args: { target?: TargetSelector; fields: Array<{ uid?: DriverUid; selector?: string; value: string }> },
|
|
279
|
+
): Promise<{ filled: number; fields: number }> {
|
|
280
|
+
if (!Array.isArray(args.fields) || args.fields.length === 0) throw new SharedToolError("fill_form requires a non-empty { fields } array");
|
|
281
|
+
return withPage(driver, args.target, async (page) => {
|
|
282
|
+
let filled = 0;
|
|
283
|
+
for (const f of args.fields) {
|
|
284
|
+
if (typeof f.value !== "string") throw new SharedToolError("each fill_form field requires a string value");
|
|
285
|
+
await page.setValue(locatorOf(f), f.value);
|
|
286
|
+
filled++;
|
|
287
|
+
}
|
|
288
|
+
return { filled, fields: args.fields.length };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function typeText(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; text: string }): Promise<{ typed: true; text: string }> {
|
|
293
|
+
if (typeof args.text !== "string") throw new SharedToolError("type_text requires a string { text }");
|
|
294
|
+
return withPage(driver, args.target, async (page) => {
|
|
295
|
+
await page.typeText(locatorOf(args), args.text);
|
|
296
|
+
return { typed: true, text: args.text };
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function pressKey(driver: BrowserDriver, args: { target?: TargetSelector; key: string; modifiers?: string[] }): Promise<{ pressed: string; modifiers: string[] }> {
|
|
301
|
+
if (typeof args.key !== "string" || !args.key.length) throw new SharedToolError("press_key requires a non-empty { key }");
|
|
302
|
+
const modifiers = args.modifiers ?? [];
|
|
303
|
+
return withPage(driver, args.target, async (page) => {
|
|
304
|
+
await page.pressKey({ key: args.key, modifiers });
|
|
305
|
+
return { pressed: args.key, modifiers };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function uploadFile(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; files: string | string[] }): Promise<{ uploaded: string[] }> {
|
|
310
|
+
const files = Array.isArray(args.files) ? args.files : [args.files];
|
|
311
|
+
if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
|
|
312
|
+
throw new SharedToolError("upload_file requires { files } as a non-empty path or array of paths");
|
|
313
|
+
}
|
|
314
|
+
return withPage(driver, args.target, async (page) => {
|
|
315
|
+
await page.setFiles(locatorOf(args), files);
|
|
316
|
+
return { uploaded: files };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* -------------------------- screenshot + emulation (3) -------------------------- */
|
|
321
|
+
|
|
322
|
+
function stamp(): string {
|
|
323
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function takeScreenshot(
|
|
327
|
+
driver: BrowserDriver,
|
|
328
|
+
args: {
|
|
329
|
+
target?: TargetSelector; format?: "png" | "jpeg"; quality?: number; fullPage?: boolean;
|
|
330
|
+
uid?: DriverUid; selector?: string; savePath?: string; returnBase64?: boolean;
|
|
331
|
+
} = {},
|
|
332
|
+
): Promise<{ path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string }> {
|
|
333
|
+
if ((args.uid !== undefined && args.uid !== "") && args.selector) throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
334
|
+
const format = args.format ?? "png";
|
|
335
|
+
return withPage(driver, args.target, async (page) => {
|
|
336
|
+
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
337
|
+
const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...(clip ? { clip } : {}) });
|
|
338
|
+
if (data.byteLength === 0) throw new SharedToolError("captureScreenshot returned empty data");
|
|
339
|
+
await mkdir(ARTIFACT_DIR, { recursive: true });
|
|
340
|
+
const ext = outFormat === "jpeg" ? "jpg" : "png";
|
|
341
|
+
const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
342
|
+
await writeFile(path, Buffer.from(data));
|
|
343
|
+
const result: { path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string } = {
|
|
344
|
+
path, bytes: data.byteLength, format: outFormat, target: target3(page.info),
|
|
345
|
+
};
|
|
346
|
+
if (args.returnBase64) result.base64 = Buffer.from(data).toString("base64");
|
|
347
|
+
return result;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function emulate(
|
|
352
|
+
driver: BrowserDriver,
|
|
353
|
+
args: {
|
|
354
|
+
target?: TargetSelector; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean;
|
|
355
|
+
userAgent?: string; cpuThrottlingRate?: number; media?: string; mediaFeatures?: Array<{ name: string; value: string }>;
|
|
356
|
+
networkConditions?: { offline?: boolean; latency?: number; downloadThroughput?: number; uploadThroughput?: number; connectionType?: string };
|
|
357
|
+
clearOverrides?: boolean;
|
|
358
|
+
} = {},
|
|
359
|
+
): Promise<{ target: { id: string; url: string; title: string }; applied: string[]; cleared?: boolean }> {
|
|
360
|
+
return withPage(driver, args.target, async (page) => {
|
|
361
|
+
if (args.clearOverrides) {
|
|
362
|
+
await page.emulate({ clearOverrides: true });
|
|
363
|
+
return { target: target3(page.info), applied: [], cleared: true };
|
|
364
|
+
}
|
|
365
|
+
const { applied } = await page.emulate(args);
|
|
366
|
+
if (applied.length === 0) throw new SharedToolError("emulate: no overrides specified (pass clearOverrides:true to reset)");
|
|
367
|
+
return { target: target3(page.info), applied };
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function resizePage(
|
|
372
|
+
driver: BrowserDriver,
|
|
373
|
+
args: { target?: TargetSelector; width: number; height: number; deviceScaleFactor?: number; mobile?: boolean },
|
|
374
|
+
): Promise<{ target: { id: string; url: string; title: string }; width: number; height: number; innerWidth: number; innerHeight: number }> {
|
|
375
|
+
if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
|
|
376
|
+
throw new SharedToolError("resize_page requires positive numeric width and height");
|
|
377
|
+
}
|
|
378
|
+
return withPage(driver, args.target, async (page) => {
|
|
379
|
+
await page.emulate({ width: args.width, height: args.height, deviceScaleFactor: args.deviceScaleFactor, mobile: args.mobile });
|
|
380
|
+
const v = (await page.evaluate("({ w: window.innerWidth, h: window.innerHeight })")) as { w?: number; h?: number } | undefined;
|
|
381
|
+
return { target: target3(page.info), width: args.width, height: args.height, innerWidth: v?.w ?? args.width, innerHeight: v?.h ?? args.height };
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ---------------------------------- dialogs (1) ---------------------------------- */
|
|
386
|
+
|
|
387
|
+
export async function handleDialog(
|
|
388
|
+
driver: BrowserDriver,
|
|
389
|
+
args: { target?: TargetSelector; accept: boolean; promptText?: string; timeoutMs?: number; autoMs?: number },
|
|
390
|
+
): Promise<unknown> {
|
|
391
|
+
if (args.accept === undefined) throw new SharedToolError("handle_dialog requires { accept }");
|
|
392
|
+
return withPage(driver, args.target, (page) => page.handleDialog(args.accept, args.promptText, { timeoutMs: args.timeoutMs, autoMs: args.autoMs }));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* ------------------------------------- registry ------------------------------------- */
|
|
396
|
+
|
|
397
|
+
/** The 20 tools this file unifies, importable by both src/index.ts (Chrome) and
|
|
398
|
+
* src/firefox-tools.ts (Firefox). Each function is (driver, args) => Promise<result>. */
|
|
399
|
+
export const SHARED_TOOLS = {
|
|
400
|
+
list_pages: listPages,
|
|
401
|
+
new_page: newPage,
|
|
402
|
+
close_page: closePage,
|
|
403
|
+
select_page: selectPage,
|
|
404
|
+
navigate_page: navigatePage,
|
|
405
|
+
wait_for: waitForText,
|
|
406
|
+
evaluate_script: evaluateScript,
|
|
407
|
+
take_snapshot: takeSnapshot,
|
|
408
|
+
click,
|
|
409
|
+
hover,
|
|
410
|
+
drag,
|
|
411
|
+
fill,
|
|
412
|
+
fill_form: fillForm,
|
|
413
|
+
type_text: typeText,
|
|
414
|
+
press_key: pressKey,
|
|
415
|
+
upload_file: uploadFile,
|
|
416
|
+
take_screenshot: takeScreenshot,
|
|
417
|
+
emulate,
|
|
418
|
+
resize_page: resizePage,
|
|
419
|
+
handle_dialog: handleDialog,
|
|
420
|
+
} satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;
|