cdp-toolkit 1.0.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 +29 -0
- package/CONTRACT.md +116 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli.js +2108 -0
- package/dist/index.js +2019 -0
- package/dist/mcp.js +3056 -0
- package/package.json +82 -0
- package/src/cli.ts +145 -0
- package/src/client.ts +255 -0
- package/src/index.ts +128 -0
- package/src/manifest.ts +1030 -0
- package/src/mcp.ts +61 -0
- package/src/tools/console.ts +199 -0
- package/src/tools/dialogs.ts +236 -0
- package/src/tools/emulation.ts +212 -0
- package/src/tools/evaluate.ts +155 -0
- package/src/tools/heap.ts +109 -0
- package/src/tools/input.ts +399 -0
- package/src/tools/lighthouse.ts +285 -0
- package/src/tools/navigation.ts +202 -0
- package/src/tools/network.ts +275 -0
- package/src/tools/network_mock.ts +367 -0
- package/src/tools/pages.ts +159 -0
- package/src/tools/performance.ts +492 -0
- package/src/tools/recorder.ts +265 -0
- package/src/tools/screenshot.ts +180 -0
- package/src/tools/snapshot.ts +206 -0
- package/src/types.ts +63 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* cdp-toolkit MCP server (stdio).
|
|
4
|
+
*
|
|
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 — each
|
|
7
|
+
* tool call lazily opens a single-target CDP connection (with its own timeout),
|
|
8
|
+
* so the server loads cleanly even when Chrome isn't running; individual calls
|
|
9
|
+
* then fail with a clear error if the browser is unreachable.
|
|
10
|
+
*
|
|
11
|
+
* Launch: `bunx -y cdp-toolkit` (or `bun run src/mcp.ts` from a checkout)
|
|
12
|
+
* Config: CDP_BASE (default http://127.0.0.1:9222), CDP_TIMEOUT_MS, CDP_ARTIFACT_DIR.
|
|
13
|
+
*
|
|
14
|
+
* stdout is the JSON-RPC channel — all diagnostics go to stderr only.
|
|
15
|
+
*/
|
|
16
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
17
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
19
|
+
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
20
|
+
import { MANIFEST } from "./manifest.ts";
|
|
21
|
+
|
|
22
|
+
const VERSION = "1.0.0";
|
|
23
|
+
|
|
24
|
+
/** Loose dispatch view of the strongly-typed TOOLS registry. */
|
|
25
|
+
const dispatch = TOOLS as Record<string, (args: unknown) => Promise<unknown>>;
|
|
26
|
+
|
|
27
|
+
/** Warn (to stderr) about any registry/manifest drift, but don't fail startup. */
|
|
28
|
+
function auditCoverage(): void {
|
|
29
|
+
const manifestNames = new Set(MANIFEST.map((s) => s.name));
|
|
30
|
+
const registryNames = new Set<string>(TOOL_NAMES);
|
|
31
|
+
const missingSchema = [...registryNames].filter((n) => !manifestNames.has(n));
|
|
32
|
+
const orphanSchema = [...manifestNames].filter((n) => !registryNames.has(n));
|
|
33
|
+
if (missingSchema.length) console.error(`[cdp-toolkit] WARN: tools without a manifest schema: ${missingSchema.join(", ")}`);
|
|
34
|
+
if (orphanSchema.length) console.error(`[cdp-toolkit] WARN: manifest schemas with no registered tool: ${orphanSchema.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const server = new Server({ name: "cdp-toolkit", version: VERSION }, { capabilities: { tools: {} } });
|
|
38
|
+
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
40
|
+
tools: MANIFEST.map((s) => ({ name: s.name, description: s.description, inputSchema: s.inputSchema })),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
44
|
+
const { name } = request.params;
|
|
45
|
+
const args = (request.params.arguments ?? {}) as unknown;
|
|
46
|
+
const fn = dispatch[name];
|
|
47
|
+
if (!fn) {
|
|
48
|
+
return { content: [{ type: "text" as const, text: `unknown tool: ${name}` }], isError: true };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const result = await fn(args);
|
|
52
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
55
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
auditCoverage();
|
|
60
|
+
await server.connect(new StdioServerTransport());
|
|
61
|
+
console.error(`[cdp-toolkit] MCP server v${VERSION} ready — ${MANIFEST.length} tools, CDP_BASE=${BASE}`);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* console.ts — read console output captured by the recorder.
|
|
3
|
+
*
|
|
4
|
+
* list_console_messages -> listConsoleMessages
|
|
5
|
+
* get_console_message -> getConsoleMessage
|
|
6
|
+
*
|
|
7
|
+
* READ vs CAPTURE model (see recorder.ts header):
|
|
8
|
+
* - default ({ reload: false }): read the target's shared "latest" buffer
|
|
9
|
+
* (rec-<targetId>.jsonl) and return its parsed console entries. If no capture
|
|
10
|
+
* has run for this target, returns an empty list.
|
|
11
|
+
* - { reload: true }: run a captureWindow (records BOTH console+network into a
|
|
12
|
+
* unique per-capture file), Page.reload, capture for `durationMs` (default
|
|
13
|
+
* 2500ms), stop, then read+return the console entries from that capture. The
|
|
14
|
+
* both-domains capture means a network reload never wipes console history.
|
|
15
|
+
*/
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { CdpError, resolveTarget } from "../client.ts";
|
|
18
|
+
import type { TargetSelector } from "../types.ts";
|
|
19
|
+
import { recFile, captureWindow } from "./recorder.ts";
|
|
20
|
+
import type { RecLine } from "./recorder.ts";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CAPTURE_MS = 2500;
|
|
23
|
+
|
|
24
|
+
/** A normalized console entry returned to callers. */
|
|
25
|
+
export interface ConsoleEntry {
|
|
26
|
+
index: number;
|
|
27
|
+
/** Origin domain: "console" (Runtime.consoleAPICalled), "exception" (Runtime.exceptionThrown), "log" (Log.entryAdded). */
|
|
28
|
+
source: "console" | "exception" | "log";
|
|
29
|
+
/** Severity level: log/info/warn/error/debug/verbose. */
|
|
30
|
+
level: string;
|
|
31
|
+
/** Best-effort flattened text of the message. */
|
|
32
|
+
text: string;
|
|
33
|
+
/** Page URL associated with the message, if present. */
|
|
34
|
+
url?: string;
|
|
35
|
+
/** Source line number, if present. */
|
|
36
|
+
lineNumber?: number;
|
|
37
|
+
/** Wall-clock capture time (ms since epoch). */
|
|
38
|
+
ts: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ListConsoleMessagesArgs {
|
|
42
|
+
target?: TargetSelector;
|
|
43
|
+
/** Record fresh by reloading the page and capturing for a window. */
|
|
44
|
+
reload?: boolean;
|
|
45
|
+
/** Capture window for reload mode (ms). Default 2500. */
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GetConsoleMessageArgs {
|
|
50
|
+
target?: TargetSelector;
|
|
51
|
+
/** Zero-based index into the parsed console entries. Default 0. */
|
|
52
|
+
index?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Stringify a single CDP RemoteObject arg into readable text. */
|
|
56
|
+
function remoteObjectToText(arg: Record<string, unknown> | undefined): string {
|
|
57
|
+
if (!arg) return "";
|
|
58
|
+
if (arg.value !== undefined && arg.value !== null) return String(arg.value);
|
|
59
|
+
if (typeof arg.description === "string") return arg.description;
|
|
60
|
+
if (typeof arg.unserializableValue === "string") return arg.unserializableValue;
|
|
61
|
+
if (arg.type === "undefined") return "undefined";
|
|
62
|
+
if (arg.type === "object" && arg.subtype === "null") return "null";
|
|
63
|
+
if (typeof arg.type === "string") return `[${arg.type}]`;
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Map a persisted JSONL line into a normalized ConsoleEntry. */
|
|
68
|
+
function lineToEntry(line: RecLine, index: number): ConsoleEntry | null {
|
|
69
|
+
const p = line.params;
|
|
70
|
+
if (line.method === "Runtime.consoleAPICalled") {
|
|
71
|
+
const args = Array.isArray(p.args) ? (p.args as Array<Record<string, unknown>>) : [];
|
|
72
|
+
return {
|
|
73
|
+
index,
|
|
74
|
+
source: "console",
|
|
75
|
+
level: typeof p.type === "string" ? p.type : "log",
|
|
76
|
+
text: args.map(remoteObjectToText).join(" "),
|
|
77
|
+
url: undefined,
|
|
78
|
+
ts: line.ts,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (line.method === "Runtime.exceptionThrown") {
|
|
82
|
+
const detail = (p.exceptionDetails ?? {}) as Record<string, unknown>;
|
|
83
|
+
const exception = (detail.exception ?? {}) as Record<string, unknown>;
|
|
84
|
+
const text =
|
|
85
|
+
remoteObjectToText(exception) ||
|
|
86
|
+
(typeof detail.text === "string" ? detail.text : "uncaught exception");
|
|
87
|
+
return {
|
|
88
|
+
index,
|
|
89
|
+
source: "exception",
|
|
90
|
+
level: "error",
|
|
91
|
+
text,
|
|
92
|
+
url: typeof detail.url === "string" ? detail.url : undefined,
|
|
93
|
+
lineNumber: typeof detail.lineNumber === "number" ? detail.lineNumber : undefined,
|
|
94
|
+
ts: line.ts,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (line.method === "Log.entryAdded") {
|
|
98
|
+
const entry = (p.entry ?? {}) as Record<string, unknown>;
|
|
99
|
+
return {
|
|
100
|
+
index,
|
|
101
|
+
source: "log",
|
|
102
|
+
level: typeof entry.level === "string" ? entry.level : "info",
|
|
103
|
+
text: typeof entry.text === "string" ? entry.text : "",
|
|
104
|
+
url: typeof entry.url === "string" ? entry.url : undefined,
|
|
105
|
+
lineNumber: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
|
|
106
|
+
ts: line.ts,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse a JSONL buffer file into console entries (skips network lines). */
|
|
113
|
+
async function readConsoleEntries(file: string): Promise<ConsoleEntry[]> {
|
|
114
|
+
let raw: string;
|
|
115
|
+
try {
|
|
116
|
+
raw = await readFile(file, "utf8");
|
|
117
|
+
} catch {
|
|
118
|
+
return []; // no buffer yet
|
|
119
|
+
}
|
|
120
|
+
const out: ConsoleEntry[] = [];
|
|
121
|
+
for (const rawLine of raw.split("\n")) {
|
|
122
|
+
const trimmed = rawLine.trim();
|
|
123
|
+
if (!trimmed) continue;
|
|
124
|
+
let line: RecLine;
|
|
125
|
+
try {
|
|
126
|
+
line = JSON.parse(trimmed) as RecLine;
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (line.kind !== "console") continue;
|
|
131
|
+
const entry = lineToEntry(line, out.length);
|
|
132
|
+
if (entry) out.push(entry);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* list_console_messages — return console output for the target. With
|
|
139
|
+
* `reload:true`, records a fresh capture window by reloading the page.
|
|
140
|
+
*/
|
|
141
|
+
export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): Promise<{
|
|
142
|
+
target: { id: string; url: string; title: string };
|
|
143
|
+
count: number;
|
|
144
|
+
messages: ConsoleEntry[];
|
|
145
|
+
droppedWrites?: number;
|
|
146
|
+
}> {
|
|
147
|
+
if (args.reload) {
|
|
148
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
149
|
+
await cap.stop();
|
|
150
|
+
const messages = await readConsoleEntries(cap.file);
|
|
151
|
+
return {
|
|
152
|
+
target: cap.resolved,
|
|
153
|
+
count: messages.length,
|
|
154
|
+
messages,
|
|
155
|
+
droppedWrites: cap.droppedWrites(),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const target = await resolveTarget(args.target);
|
|
160
|
+
const messages = await readConsoleEntries(recFile(target.id));
|
|
161
|
+
return {
|
|
162
|
+
target: { id: target.id, url: target.url, title: target.title },
|
|
163
|
+
count: messages.length,
|
|
164
|
+
messages,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* get_console_message — return a single console entry by index from the
|
|
170
|
+
* existing buffer for the target. Throws if the index is out of range.
|
|
171
|
+
*/
|
|
172
|
+
export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
|
|
173
|
+
const target = await resolveTarget(args.target);
|
|
174
|
+
const messages = await readConsoleEntries(recFile(target.id));
|
|
175
|
+
const index = args.index ?? 0;
|
|
176
|
+
const entry = messages[index];
|
|
177
|
+
if (!entry) {
|
|
178
|
+
throw new CdpError(
|
|
179
|
+
`no console message at index ${index} (have ${messages.length}); run list_console_messages or list with reload:true first`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return entry;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/*
|
|
186
|
+
* CDP methods / domains used (via recorder.ts + this module):
|
|
187
|
+
* Page.enable (reload mode)
|
|
188
|
+
* Page.reload (reload mode)
|
|
189
|
+
* Runtime.enable / Runtime.consoleAPICalled / Runtime.exceptionThrown (recorder)
|
|
190
|
+
* Log.enable / Log.entryAdded (recorder)
|
|
191
|
+
*
|
|
192
|
+
* Parity gaps vs chrome-devtools-mcp:
|
|
193
|
+
* - RemoteObject args are flattened to text best-effort; deep object inspection
|
|
194
|
+
* (expandable previews) the DevTools UI shows is not reconstructed.
|
|
195
|
+
* - Stack traces from exceptions are summarized to the top message + url/line,
|
|
196
|
+
* not the full frame list.
|
|
197
|
+
* - Without reload:true the result reflects whatever a prior recorder buffered;
|
|
198
|
+
* the MCP keeps a live in-process buffer for its managed page.
|
|
199
|
+
*/
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dialogs.ts — JavaScript dialog handling (alert / confirm / prompt / beforeunload).
|
|
3
|
+
*
|
|
4
|
+
* Replicates chrome-devtools-mcp's `handle_dialog`. A JS dialog opened by the
|
|
5
|
+
* page (alert/confirm/prompt/beforeunload) blocks the renderer until the
|
|
6
|
+
* embedder responds via Page.handleJavaScriptDialog. Over raw CDP we:
|
|
7
|
+
* 1. Page.enable
|
|
8
|
+
* 2. subscribe to Page.javascriptDialogOpening
|
|
9
|
+
* 3. on fire, respond with Page.handleJavaScriptDialog({ accept, promptText })
|
|
10
|
+
*
|
|
11
|
+
* Two modes are supported per CONTRACT:
|
|
12
|
+
* - "wait for next dialog then handle" (default): arm the listener and resolve
|
|
13
|
+
* when the next dialog is handled, or time out if none appears.
|
|
14
|
+
* - "auto-handle for N ms" ({ autoMs }): handle every dialog that opens during
|
|
15
|
+
* a fixed window, then resolve with the list handled.
|
|
16
|
+
*
|
|
17
|
+
* Because a dialog blocks the renderer, the code that *triggers* the dialog
|
|
18
|
+
* (e.g. a Runtime.evaluate of `confirm("x")`) must NOT be awaited before the
|
|
19
|
+
* dialog is handled — otherwise it deadlocks. `handleDialogForExpression` arms
|
|
20
|
+
* the listener, fires the expression WITHOUT awaiting, waits for the dialog to
|
|
21
|
+
* be handled, then awaits the now-unblocked evaluate result.
|
|
22
|
+
*/
|
|
23
|
+
import type { CdpConnection } from "../client.ts";
|
|
24
|
+
import { CdpError, openPage } from "../client.ts";
|
|
25
|
+
import type { TargetSelector } from "../types.ts";
|
|
26
|
+
|
|
27
|
+
/** Raw shape of the Page.javascriptDialogOpening event params. */
|
|
28
|
+
interface JavascriptDialogOpening {
|
|
29
|
+
url: string;
|
|
30
|
+
message: string;
|
|
31
|
+
type: "alert" | "confirm" | "prompt" | "beforeunload" | string;
|
|
32
|
+
hasBrowserHandler: boolean;
|
|
33
|
+
defaultPrompt?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A single handled dialog. */
|
|
37
|
+
export interface HandledDialog {
|
|
38
|
+
type: string;
|
|
39
|
+
message: string;
|
|
40
|
+
url: string;
|
|
41
|
+
defaultPrompt?: string;
|
|
42
|
+
accept: boolean;
|
|
43
|
+
promptText?: string;
|
|
44
|
+
handled: true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HandleDialogArgs {
|
|
48
|
+
target?: TargetSelector;
|
|
49
|
+
/** Whether to accept (OK) or dismiss (Cancel) the dialog. */
|
|
50
|
+
accept: boolean;
|
|
51
|
+
/** Text to enter for a prompt() dialog when accepting. */
|
|
52
|
+
promptText?: string;
|
|
53
|
+
/** How long to wait for the next dialog (default 15000ms). */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Auto-handle mode: keep handling every dialog that opens for this many ms,
|
|
57
|
+
* then resolve. When set, the return value is a list of handled dialogs and
|
|
58
|
+
* the function never throws on "no dialog" (an empty list is a valid result).
|
|
59
|
+
*/
|
|
60
|
+
autoMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Arm a dialog handler on a live page connection. Returns a disarm function and
|
|
65
|
+
* a promise that resolves with each dialog as it is handled (via the onHandled
|
|
66
|
+
* callback). This is the low-level primitive used by both public modes and by
|
|
67
|
+
* the expression helper.
|
|
68
|
+
*/
|
|
69
|
+
function armDialogHandler(
|
|
70
|
+
conn: CdpConnection,
|
|
71
|
+
accept: boolean,
|
|
72
|
+
promptText: string | undefined,
|
|
73
|
+
onHandled: (d: HandledDialog) => void,
|
|
74
|
+
): () => void {
|
|
75
|
+
return conn.on("Page.javascriptDialogOpening", (params) => {
|
|
76
|
+
const ev = params as unknown as JavascriptDialogOpening;
|
|
77
|
+
void (async () => {
|
|
78
|
+
try {
|
|
79
|
+
await conn.send("Page.handleJavaScriptDialog", {
|
|
80
|
+
accept,
|
|
81
|
+
...(promptText !== undefined ? { promptText } : {}),
|
|
82
|
+
});
|
|
83
|
+
onHandled({
|
|
84
|
+
type: ev.type,
|
|
85
|
+
message: ev.message,
|
|
86
|
+
url: ev.url,
|
|
87
|
+
...(ev.defaultPrompt !== undefined ? { defaultPrompt: ev.defaultPrompt } : {}),
|
|
88
|
+
accept,
|
|
89
|
+
...(promptText !== undefined ? { promptText } : {}),
|
|
90
|
+
handled: true,
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
/* connection may have raced closed; the awaiter will time out */
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* handle_dialog — wait for the next JS dialog on a page and respond to it.
|
|
101
|
+
*
|
|
102
|
+
* Default mode: opens a persistent connection, enables Page, arms the listener,
|
|
103
|
+
* and resolves with the first handled dialog (or throws on timeout). Auto mode
|
|
104
|
+
* ({ autoMs }): handles every dialog for the window and resolves with the list.
|
|
105
|
+
*
|
|
106
|
+
* The caller is expected to trigger the dialog out-of-band (e.g. by clicking a
|
|
107
|
+
* button on the page). To trigger-and-handle in one call, use
|
|
108
|
+
* handleDialogForExpression.
|
|
109
|
+
*/
|
|
110
|
+
export async function handleDialog(
|
|
111
|
+
args: HandleDialogArgs,
|
|
112
|
+
): Promise<HandledDialog | { handled: HandledDialog[]; count: number }> {
|
|
113
|
+
const { target, accept, promptText, timeoutMs = 15_000, autoMs } = args;
|
|
114
|
+
const { conn } = await openPage(target);
|
|
115
|
+
try {
|
|
116
|
+
await conn.send("Page.enable");
|
|
117
|
+
|
|
118
|
+
if (autoMs !== undefined) {
|
|
119
|
+
const handled: HandledDialog[] = [];
|
|
120
|
+
const disarm = armDialogHandler(conn, accept, promptText, (d) => handled.push(d));
|
|
121
|
+
await new Promise<void>((resolve) => setTimeout(resolve, autoMs));
|
|
122
|
+
disarm();
|
|
123
|
+
return { handled, count: handled.length };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return await new Promise<HandledDialog>((resolve, reject) => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
disarm();
|
|
129
|
+
reject(new CdpError(`handle_dialog: no dialog opened within ${timeoutMs}ms`));
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
const disarm = armDialogHandler(conn, accept, promptText, (d) => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
disarm();
|
|
134
|
+
resolve(d);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
conn.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface HandleDialogForExpressionArgs {
|
|
143
|
+
target?: TargetSelector;
|
|
144
|
+
/** A JS expression that opens a dialog, e.g. `confirm("ok?")` or `prompt("name?")`. */
|
|
145
|
+
expression: string;
|
|
146
|
+
accept: boolean;
|
|
147
|
+
promptText?: string;
|
|
148
|
+
timeoutMs?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Arm a dialog handler, evaluate an expression that OPENS a dialog (without
|
|
153
|
+
* awaiting it — a blocking dialog would otherwise deadlock the evaluate), wait
|
|
154
|
+
* for the dialog to be handled, then await the now-unblocked evaluate result.
|
|
155
|
+
*
|
|
156
|
+
* Returns both the handled-dialog metadata and the value the expression
|
|
157
|
+
* resolved to (e.g. `true` for an accepted confirm, the entered text for an
|
|
158
|
+
* accepted prompt, `false`/`null` for a dismissed one). This is the
|
|
159
|
+
* trigger-and-handle helper used by the live smoke test.
|
|
160
|
+
*/
|
|
161
|
+
export async function handleDialogForExpression(
|
|
162
|
+
args: HandleDialogForExpressionArgs,
|
|
163
|
+
): Promise<{ dialog: HandledDialog; value: unknown }> {
|
|
164
|
+
const { target, expression, accept, promptText, timeoutMs = 15_000 } = args;
|
|
165
|
+
const { conn } = await openPage(target);
|
|
166
|
+
try {
|
|
167
|
+
await conn.send("Page.enable");
|
|
168
|
+
await conn.send("Runtime.enable");
|
|
169
|
+
|
|
170
|
+
let resolveDialog!: (d: HandledDialog) => void;
|
|
171
|
+
let rejectDialog!: (e: Error) => void;
|
|
172
|
+
const dialogPromise = new Promise<HandledDialog>((res, rej) => {
|
|
173
|
+
resolveDialog = res;
|
|
174
|
+
rejectDialog = rej;
|
|
175
|
+
});
|
|
176
|
+
const timer = setTimeout(
|
|
177
|
+
() => rejectDialog(new CdpError(`handle_dialog: expression opened no dialog within ${timeoutMs}ms`)),
|
|
178
|
+
timeoutMs,
|
|
179
|
+
);
|
|
180
|
+
const disarm = armDialogHandler(conn, accept, promptText, (d) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
resolveDialog(d);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Fire the dialog-opening expression WITHOUT awaiting: a blocking dialog
|
|
186
|
+
// holds the evaluate open until we handle it. We capture the promise so we
|
|
187
|
+
// can await its (now-unblocked) value after the dialog is handled.
|
|
188
|
+
const evalPromise = conn
|
|
189
|
+
.send<{ result: { value?: unknown }; exceptionDetails?: { text?: string } }>("Runtime.evaluate", {
|
|
190
|
+
expression,
|
|
191
|
+
returnByValue: true,
|
|
192
|
+
awaitPromise: true,
|
|
193
|
+
// The evaluate itself must not time out before we handle the dialog.
|
|
194
|
+
userGesture: true,
|
|
195
|
+
})
|
|
196
|
+
.catch((e: unknown) => {
|
|
197
|
+
// Surface as an exception-shaped result so the await below can decide.
|
|
198
|
+
return { result: {}, __error: e } as { result: { value?: unknown }; __error: unknown };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const dialog = await dialogPromise;
|
|
202
|
+
disarm();
|
|
203
|
+
|
|
204
|
+
const evalResult = (await evalPromise) as {
|
|
205
|
+
result: { value?: unknown };
|
|
206
|
+
exceptionDetails?: { text?: string };
|
|
207
|
+
__error?: unknown;
|
|
208
|
+
};
|
|
209
|
+
if (evalResult.__error) throw evalResult.__error;
|
|
210
|
+
if (evalResult.exceptionDetails) {
|
|
211
|
+
throw new CdpError(`expression threw: ${evalResult.exceptionDetails.text ?? "unknown error"}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { dialog, value: evalResult.result?.value };
|
|
215
|
+
} finally {
|
|
216
|
+
conn.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ----------------------------------------------------------------------------
|
|
221
|
+
* CDP methods / domains used:
|
|
222
|
+
* - Page.enable
|
|
223
|
+
* - Page.javascriptDialogOpening (event, subscribed)
|
|
224
|
+
* - Page.handleJavaScriptDialog ({ accept, promptText })
|
|
225
|
+
* - Runtime.enable (helper only)
|
|
226
|
+
* - Runtime.evaluate (helper only — fires the dialog-opening expr)
|
|
227
|
+
*
|
|
228
|
+
* Parity gaps vs chrome-devtools-mcp `handle_dialog`:
|
|
229
|
+
* - MCP auto-handles the single pending dialog tracked by its Page object;
|
|
230
|
+
* here the caller arms first and triggers out-of-band, OR uses the
|
|
231
|
+
* handleDialogForExpression helper to trigger-and-handle atomically.
|
|
232
|
+
* - MCP returns its standard page/snapshot envelope after handling; this
|
|
233
|
+
* returns just the handled-dialog metadata (type/message/url/accept/...).
|
|
234
|
+
* - beforeunload dialogs are handled the same way but are only emitted when a
|
|
235
|
+
* real navigation/close is attempted; arming alone does not provoke them.
|
|
236
|
+
* -------------------------------------------------------------------------- */
|