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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* network_mock.ts
|
|
2
|
+
* network_mock.ts: a per-target FAKE BACKEND for the page: intercept matching
|
|
3
3
|
* requests via the CDP Fetch domain and fulfill them with a canned response,
|
|
4
4
|
* fail (abort) them, or let them continue (optionally with fault injection).
|
|
5
5
|
* The rod `HijackRequests` capability the read-only console/network tools lack.
|
|
6
6
|
*
|
|
7
7
|
* This file is split into two layers:
|
|
8
8
|
* 1. PURE logic (urlMatches / selectRule / buildFulfillParams / effectiveAction)
|
|
9
|
-
*
|
|
10
|
-
* 2. STATEFUL tools (mockRequest / listMocks / clearMocks)
|
|
9
|
+
* No browser needed, unit-tested in test/network_mock.test.ts.
|
|
10
|
+
* 2. STATEFUL tools (mockRequest / listMocks / clearMocks): hold a persistent
|
|
11
11
|
* CDP connection per target; integration-tested in test/mock-smoke.ts.
|
|
12
12
|
*/
|
|
13
13
|
import { CdpError, openPage, resolveTarget } from "../client.ts";
|
|
@@ -15,7 +15,7 @@ import type { CdpConnection } from "../client.ts";
|
|
|
15
15
|
import type { Target, TargetSelector } from "../types.ts";
|
|
16
16
|
|
|
17
17
|
/* ============================================================================
|
|
18
|
-
* LAYER 1
|
|
18
|
+
* LAYER 1: pure logic (no I/O)
|
|
19
19
|
* ========================================================================== */
|
|
20
20
|
|
|
21
21
|
export type MockAction = "fulfill" | "fail" | "continue";
|
|
@@ -111,7 +111,7 @@ export function effectiveAction(rule: { action: MockAction; failRate?: number },
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/* ============================================================================
|
|
114
|
-
* LAYER 2
|
|
114
|
+
* LAYER 2: stateful tools (persistent per-target CDP connection)
|
|
115
115
|
*
|
|
116
116
|
* A "fake backend" is a per-target SESSION holding ONE persistent CDP connection
|
|
117
117
|
* with the Fetch domain enabled. Each session carries a list of rules; an
|
|
@@ -122,7 +122,7 @@ export function effectiveAction(rule: { action: MockAction; failRate?: number },
|
|
|
122
122
|
* Persistence works because the MCP server process is long-lived across tool
|
|
123
123
|
* calls, so this module-level `sessions` map and its open connections outlive a
|
|
124
124
|
* single call. (The stateless CLI process exits after one command, so persistent
|
|
125
|
-
* mocking is an MCP-server capability
|
|
125
|
+
* mocking is an MCP-server capability, like the recorder's background mode.)
|
|
126
126
|
*
|
|
127
127
|
* SAFETY: every paused request MUST be answered or the renderer hangs. A request
|
|
128
128
|
* that pauses but matches no rule is continued untouched.
|
|
@@ -182,7 +182,7 @@ function registerHandler(session: MockSession): void {
|
|
|
182
182
|
session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
|
|
183
183
|
})
|
|
184
184
|
.catch((e) => {
|
|
185
|
-
// Tab navigated away mid-flight or the connection dropped
|
|
185
|
+
// Tab navigated away mid-flight or the connection dropped, best-effort.
|
|
186
186
|
if (/closed|connection|Target|Inspected/i.test(String(e))) session.dead = true;
|
|
187
187
|
});
|
|
188
188
|
});
|
|
@@ -359,7 +359,7 @@ export async function clearMocks(args: ClearMocksArgs = {}): Promise<{ cleared:
|
|
|
359
359
|
* Page.enable + Page.reload (reload:true), Runtime.evaluate (liveness probe).
|
|
360
360
|
* Model: persistent per-target session (like recorder.ts), MCP-server-scoped state.
|
|
361
361
|
* Parity gaps vs rod HijackRequests / chrome-devtools-mcp:
|
|
362
|
-
* - Request-stage only
|
|
362
|
+
* - Request-stage only, no live Response-body rewriting (add requestStage:"Response"
|
|
363
363
|
* + Fetch.getResponseBody/continueResponse for that).
|
|
364
364
|
* - Persistence is in-process: works via the long-lived MCP server, not the
|
|
365
365
|
* one-shot CLI (mirrors the recorder's background mode).
|
package/src/tools/performance.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* performance.ts
|
|
2
|
+
* performance.ts: performance tracing + insight analysis over raw CDP.
|
|
3
3
|
*
|
|
4
4
|
* NOTE ON THE INSIGHT ANALYZER: `performanceAnalyzeInsight` is a CDP-native
|
|
5
5
|
* APPROXIMATION of chrome-devtools-mcp's insight analyzer. The MCP tool runs the
|
|
6
6
|
* DevTools/Lighthouse "Trace Engine" insight pipeline (a large TS engine that
|
|
7
7
|
* models the full RAIL/Core-Web-Vitals heuristics). We do NOT vendor that
|
|
8
8
|
* engine (zero-runtime-deps rule). Instead we parse the raw Chrome trace event
|
|
9
|
-
* stream ourselves and derive the headline metrics
|
|
10
|
-
* long tasks (>50ms), layout shifts / CLS, and total blocking time
|
|
9
|
+
* stream ourselves and derive the headline metrics (navigationStart, FCP, LCP,
|
|
10
|
+
* long tasks (>50ms), layout shifts / CLS, and total blocking time) directly
|
|
11
11
|
* from the trace events. Numbers will be close to but not byte-identical with
|
|
12
12
|
* the MCP/DevTools panel, which applies extra normalization (e.g. main-thread
|
|
13
13
|
* attribution, frame-scoped LCP, soft-navigation handling).
|
|
14
14
|
*
|
|
15
15
|
* NOTE ON TRACING STATE: CDP `Tracing.start`/`Tracing.end` are bound to a single
|
|
16
|
-
* live session
|
|
16
|
+
* live session: the recording lives on the WebSocket connection, and
|
|
17
17
|
* `Tracing.dataCollected` events stream back on that same connection. There is
|
|
18
18
|
* no way to "re-attach" to an in-flight trace from a fresh process, because the
|
|
19
19
|
* buffer is owned by the connection that started it. Cross-process persistence
|
|
@@ -213,7 +213,7 @@ export async function performanceStopTrace(
|
|
|
213
213
|
): Promise<{ path: string; bytes: number; events: number; metrics: TraceMetrics }> {
|
|
214
214
|
// CDP tracing is browser-GLOBAL: a second Tracing.start throws "Tracing has
|
|
215
215
|
// already been started", so at most one trace is ever live in this process.
|
|
216
|
-
// There is therefore nothing to disambiguate by target
|
|
216
|
+
// There is therefore nothing to disambiguate by target; take the single live
|
|
217
217
|
// trace if present. (`args.target` is accepted for API symmetry with start.)
|
|
218
218
|
void args.target;
|
|
219
219
|
const entry: LiveTrace | undefined = liveTraces.size === 1 ? [...liveTraces.values()][0] : undefined;
|
|
@@ -250,7 +250,7 @@ export async function performanceStopTrace(
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
/* ------------------------------------------------------------------ */
|
|
253
|
-
/* performanceTrace (PRIMARY one-shot
|
|
253
|
+
/* performanceTrace (PRIMARY one-shot, the smoke path) */
|
|
254
254
|
/* ------------------------------------------------------------------ */
|
|
255
255
|
|
|
256
256
|
export interface PerformanceTraceArgs {
|
|
@@ -366,7 +366,7 @@ export function analyzeEvents(events: TraceEvent[]): TraceMetrics {
|
|
|
366
366
|
continue;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
// LCP candidate
|
|
369
|
+
// LCP candidate: the LAST candidate before user input is the real LCP.
|
|
370
370
|
if (name === "largestContentfulPaint::Candidate" && typeof e.ts === "number") {
|
|
371
371
|
// candidates are monotonic; keep the latest timestamp seen.
|
|
372
372
|
if (lcpUs === undefined || e.ts > lcpUs) lcpUs = e.ts;
|
|
@@ -383,7 +383,7 @@ export function analyzeEvents(events: TraceEvent[]): TraceMetrics {
|
|
|
383
383
|
continue;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
// Layout shifts
|
|
386
|
+
// Layout shifts: sum scores that did NOT follow recent user input.
|
|
387
387
|
if (name === "LayoutShift") {
|
|
388
388
|
const data = e.args?.data;
|
|
389
389
|
const hadRecentInput = data?.had_recent_input === true;
|
|
@@ -438,7 +438,7 @@ export interface PerformanceAnalyzeInsightArgs {
|
|
|
438
438
|
/**
|
|
439
439
|
* CDP-native approximation of the MCP insight analyzer. Reads a trace JSON file
|
|
440
440
|
* (bare array OR { traceEvents: [...] }) and returns structured headline
|
|
441
|
-
* metrics. If `tracePath` is omitted, throws
|
|
441
|
+
* metrics. If `tracePath` is omitted, throws; there is no implicit "latest
|
|
442
442
|
* trace" magic; callers pass the path returned by a trace tool.
|
|
443
443
|
*/
|
|
444
444
|
export async function performanceAnalyzeInsight(
|
|
@@ -473,9 +473,9 @@ export async function performanceAnalyzeInsight(
|
|
|
473
473
|
/*
|
|
474
474
|
* CDP methods/domains used:
|
|
475
475
|
* - Tracing.start (traceConfig.includedCategories, transferMode:ReportEvents)
|
|
476
|
-
* - Tracing.dataCollected (event
|
|
476
|
+
* - Tracing.dataCollected (event, buffered into the event array)
|
|
477
477
|
* - Tracing.end
|
|
478
|
-
* - Tracing.tracingComplete (event
|
|
478
|
+
* - Tracing.tracingComplete (event, awaited to know recording is flushed)
|
|
479
479
|
* - Page.enable / Page.navigate / Page.reload / Page.loadEventFired (one-shot reload path)
|
|
480
480
|
*
|
|
481
481
|
* Parity gaps vs chrome-devtools-mcp performance tools:
|
package/src/tools/recorder.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* recorder.ts
|
|
2
|
+
* recorder.ts: the stateful console+network capture engine.
|
|
3
3
|
*
|
|
4
4
|
* RECORDER MODEL
|
|
5
5
|
* ==============
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* CAPTURE MODEL (what the tool surface actually uses)
|
|
24
24
|
* ---------------------------------------------------
|
|
25
|
-
* The console/network tools drive `captureWindow()
|
|
25
|
+
* The console/network tools drive `captureWindow()`, a one-shot reload capture
|
|
26
26
|
* that records BOTH domains into a UNIQUE per-capture file
|
|
27
27
|
* (`rec-<targetId>-<captureId>.jsonl`) and, on stop, publishes that capture as
|
|
28
28
|
* the target's shared "latest" buffer (`rec-<targetId>.jsonl`). Capturing both
|
|
@@ -154,7 +154,7 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
|
|
|
154
154
|
try {
|
|
155
155
|
await conn.send("Log.enable");
|
|
156
156
|
} catch {
|
|
157
|
-
/* Log not supported on this target type
|
|
157
|
+
/* Log not supported on this target type; Runtime events still flow */
|
|
158
158
|
}
|
|
159
159
|
for (const ev of CONSOLE_EVENTS) {
|
|
160
160
|
unsubs.push(conn.on(ev, (params) => persist("console", ev, params)));
|
|
@@ -165,7 +165,7 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
|
|
|
165
165
|
throw err;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
// Stop accepting new events, then let queued appends settle
|
|
168
|
+
// Stop accepting new events, then let queued appends settle, without closing
|
|
169
169
|
// the connection (response bodies stay fetchable on `conn`).
|
|
170
170
|
const flush = async (): Promise<void> => {
|
|
171
171
|
for (const off of unsubs) off();
|
|
@@ -195,7 +195,7 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
|
|
|
195
195
|
export interface CaptureWindow {
|
|
196
196
|
/** The unique per-capture buffer file (read this for isolated results). */
|
|
197
197
|
file: string;
|
|
198
|
-
/** Live connection (open until stop)
|
|
198
|
+
/** Live connection (open until stop), for Network.getResponseBody etc. */
|
|
199
199
|
conn: CdpConnection;
|
|
200
200
|
/** Resolved target identity. */
|
|
201
201
|
resolved: { id: string; url: string; title: string };
|
package/src/tools/snapshot.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The reference scheme (CONTRACT.md "The element-reference scheme"):
|
|
5
5
|
* A `Uid` IS a CDP `backendDOMNodeId` (a number). Refs are therefore
|
|
6
|
-
* stateless
|
|
6
|
+
* stateless: `take_snapshot` emits them straight from the a11y tree, and
|
|
7
7
|
* every interaction tool resolves them back to a live DOM node via
|
|
8
8
|
* `DOM.resolveNode({ backendNodeId: uid })`. There is no server-side ref
|
|
9
9
|
* table to drift or expire.
|
|
@@ -124,7 +124,7 @@ export interface TakeSnapshotResult {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
* take_snapshot
|
|
127
|
+
* take_snapshot: Accessibility.getFullAXTree walked into a compact indented
|
|
128
128
|
* text tree. Each emitted line carries the node's backendDOMNodeId as [uid],
|
|
129
129
|
* which interaction tools feed straight back to resolveUid.
|
|
130
130
|
*/
|
package/src/tools/dialogs.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
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
|
-
* -------------------------------------------------------------------------- */
|
package/src/tools/emulation.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* emulation tools — `emulate` and `resize_page` over raw CDP.
|
|
3
|
-
*
|
|
4
|
-
* `emulate` is a one-call switchboard over Chrome's emulation overrides:
|
|
5
|
-
* device metrics (size / DPR / mobile), user-agent, CPU throttling, emulated
|
|
6
|
-
* media (type + features like prefers-color-scheme), and network conditions.
|
|
7
|
-
* `resize_page` is the narrow case of setting just the device-metrics size.
|
|
8
|
-
* A `clearOverrides` path resets every override back to the browser default.
|
|
9
|
-
*
|
|
10
|
-
* NOTE on persistence: these overrides live on the DevTools *session*. The
|
|
11
|
-
* toolkit's stateless one-shot model (withPage opens then closes the
|
|
12
|
-
* connection) means an override applied in one call is gone once that
|
|
13
|
-
* connection closes — except device metrics, which Chrome keeps applied to the
|
|
14
|
-
* target until explicitly cleared or the renderer navigates/reloads. We
|
|
15
|
-
* therefore re-assert metrics in resize_page and document this in the footer.
|
|
16
|
-
*/
|
|
17
|
-
import type { CdpConnection } from "../client.ts";
|
|
18
|
-
import { CdpError, withPage } from "../client.ts";
|
|
19
|
-
import type { Target, TargetSelector } from "../types.ts";
|
|
20
|
-
|
|
21
|
-
export interface NetworkConditions {
|
|
22
|
-
/** true = simulate offline. */
|
|
23
|
-
offline?: boolean;
|
|
24
|
-
/** additional round-trip latency, ms. */
|
|
25
|
-
latency?: number;
|
|
26
|
-
/** max download throughput, bytes/sec (-1 = no limit). */
|
|
27
|
-
downloadThroughput?: number;
|
|
28
|
-
/** max upload throughput, bytes/sec (-1 = no limit). */
|
|
29
|
-
uploadThroughput?: number;
|
|
30
|
-
/** "none" | "cellular2g" | "cellular3g" | "cellular4g" | "bluetooth" | "ethernet" | "wifi" | "wimax" | "other" */
|
|
31
|
-
connectionType?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface EmulateArgs {
|
|
35
|
-
target?: TargetSelector;
|
|
36
|
-
width?: number;
|
|
37
|
-
height?: number;
|
|
38
|
-
deviceScaleFactor?: number;
|
|
39
|
-
mobile?: boolean;
|
|
40
|
-
userAgent?: string;
|
|
41
|
-
/** 1 = no throttle; 2 = 2x slower; etc. */
|
|
42
|
-
cpuThrottlingRate?: number;
|
|
43
|
-
/** Emulated CSS media type: "screen" | "print" | "" (clear). */
|
|
44
|
-
media?: string;
|
|
45
|
-
/** Emulated media features, e.g. [{ name:"prefers-color-scheme", value:"dark" }]. */
|
|
46
|
-
mediaFeatures?: Array<{ name: string; value: string }>;
|
|
47
|
-
networkConditions?: NetworkConditions;
|
|
48
|
-
/** Reset every override to the browser default; ignores all other fields. */
|
|
49
|
-
clearOverrides?: boolean;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface EmulateResult {
|
|
53
|
-
target: { id: string; url: string; title: string };
|
|
54
|
-
applied: string[];
|
|
55
|
-
cleared?: boolean;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface ResizeArgs {
|
|
59
|
-
target?: TargetSelector;
|
|
60
|
-
width: number;
|
|
61
|
-
height: number;
|
|
62
|
-
deviceScaleFactor?: number;
|
|
63
|
-
mobile?: boolean;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface ResizeResult {
|
|
67
|
-
target: { id: string; url: string; title: string };
|
|
68
|
-
width: number;
|
|
69
|
-
height: number;
|
|
70
|
-
/** window.innerWidth/innerHeight observed after the override is applied. */
|
|
71
|
-
innerWidth: number;
|
|
72
|
-
innerHeight: number;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function clearAll(conn: CdpConnection): Promise<void> {
|
|
76
|
-
// Order doesn't matter; each is independent. Tolerate per-domain absence.
|
|
77
|
-
await conn.send("Emulation.clearDeviceMetricsOverride").catch(() => undefined);
|
|
78
|
-
await conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(() => undefined);
|
|
79
|
-
await conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
|
80
|
-
await conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(() => undefined);
|
|
81
|
-
await conn.send("Network.enable").catch(() => undefined);
|
|
82
|
-
await conn
|
|
83
|
-
.send("Network.emulateNetworkConditions", {
|
|
84
|
-
offline: false,
|
|
85
|
-
latency: 0,
|
|
86
|
-
downloadThroughput: -1,
|
|
87
|
-
uploadThroughput: -1,
|
|
88
|
-
})
|
|
89
|
-
.catch(() => undefined);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* emulate — apply any subset of device-metrics / UA / CPU / media / network
|
|
94
|
-
* overrides in a single call, or clear them all with { clearOverrides:true }.
|
|
95
|
-
*/
|
|
96
|
-
export async function emulate(args: EmulateArgs = {}): Promise<EmulateResult> {
|
|
97
|
-
const { target } = args;
|
|
98
|
-
return withPage(target, async (conn: CdpConnection, t: Target) => {
|
|
99
|
-
if (args.clearOverrides) {
|
|
100
|
-
await clearAll(conn);
|
|
101
|
-
return {
|
|
102
|
-
target: { id: t.id, url: t.url, title: t.title },
|
|
103
|
-
applied: [],
|
|
104
|
-
cleared: true,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const applied: string[] = [];
|
|
109
|
-
|
|
110
|
-
if (args.width != null || args.height != null) {
|
|
111
|
-
if (args.width == null || args.height == null) {
|
|
112
|
-
throw new CdpError("emulate device metrics require both width and height");
|
|
113
|
-
}
|
|
114
|
-
await conn.send("Emulation.setDeviceMetricsOverride", {
|
|
115
|
-
width: args.width,
|
|
116
|
-
height: args.height,
|
|
117
|
-
deviceScaleFactor: args.deviceScaleFactor ?? 0,
|
|
118
|
-
mobile: args.mobile ?? false,
|
|
119
|
-
});
|
|
120
|
-
applied.push("deviceMetrics");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (args.userAgent != null) {
|
|
124
|
-
await conn.send("Emulation.setUserAgentOverride", { userAgent: args.userAgent });
|
|
125
|
-
applied.push("userAgent");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (args.cpuThrottlingRate != null) {
|
|
129
|
-
if (args.cpuThrottlingRate < 1) throw new CdpError("cpuThrottlingRate must be >= 1");
|
|
130
|
-
await conn.send("Emulation.setCPUThrottlingRate", { rate: args.cpuThrottlingRate });
|
|
131
|
-
applied.push("cpuThrottlingRate");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (args.media != null || args.mediaFeatures != null) {
|
|
135
|
-
await conn.send("Emulation.setEmulatedMedia", {
|
|
136
|
-
media: args.media ?? "",
|
|
137
|
-
features: args.mediaFeatures ?? [],
|
|
138
|
-
});
|
|
139
|
-
applied.push("emulatedMedia");
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (args.networkConditions != null) {
|
|
143
|
-
const n = args.networkConditions;
|
|
144
|
-
await conn.send("Network.enable").catch(() => undefined);
|
|
145
|
-
await conn.send("Network.emulateNetworkConditions", {
|
|
146
|
-
offline: n.offline ?? false,
|
|
147
|
-
latency: n.latency ?? 0,
|
|
148
|
-
downloadThroughput: n.downloadThroughput ?? -1,
|
|
149
|
-
uploadThroughput: n.uploadThroughput ?? -1,
|
|
150
|
-
...(n.connectionType ? { connectionType: n.connectionType } : {}),
|
|
151
|
-
});
|
|
152
|
-
applied.push("networkConditions");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (applied.length === 0) {
|
|
156
|
-
throw new CdpError("emulate: no overrides specified (pass clearOverrides:true to reset)");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return { target: { id: t.id, url: t.url, title: t.title }, applied };
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* resize_page — set the page's device-metrics width/height. Verifies by reading
|
|
165
|
-
* back window.innerWidth/innerHeight after the override is applied.
|
|
166
|
-
*/
|
|
167
|
-
export async function resizePage(args: ResizeArgs): Promise<ResizeResult> {
|
|
168
|
-
const { target, width, height } = args;
|
|
169
|
-
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
170
|
-
throw new CdpError("resize_page requires positive numeric width and height");
|
|
171
|
-
}
|
|
172
|
-
return withPage(target, async (conn: CdpConnection, t: Target) => {
|
|
173
|
-
await conn.send("Emulation.setDeviceMetricsOverride", {
|
|
174
|
-
width,
|
|
175
|
-
height,
|
|
176
|
-
deviceScaleFactor: args.deviceScaleFactor ?? 0,
|
|
177
|
-
mobile: args.mobile ?? false,
|
|
178
|
-
});
|
|
179
|
-
await conn.send("Runtime.enable").catch(() => undefined);
|
|
180
|
-
const { result } = await conn.send<{ result: { value?: { w: number; h: number } } }>("Runtime.evaluate", {
|
|
181
|
-
expression: "({ w: window.innerWidth, h: window.innerHeight })",
|
|
182
|
-
returnByValue: true,
|
|
183
|
-
});
|
|
184
|
-
const v = result.value;
|
|
185
|
-
return {
|
|
186
|
-
target: { id: t.id, url: t.url, title: t.title },
|
|
187
|
-
width,
|
|
188
|
-
height,
|
|
189
|
-
innerWidth: v?.w ?? width,
|
|
190
|
-
innerHeight: v?.h ?? height,
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/*
|
|
196
|
-
* CDP methods/domains used:
|
|
197
|
-
* - Emulation.setDeviceMetricsOverride (resize + emulate metrics)
|
|
198
|
-
* - Emulation.clearDeviceMetricsOverride (clearOverrides)
|
|
199
|
-
* - Emulation.setUserAgentOverride
|
|
200
|
-
* - Emulation.setCPUThrottlingRate
|
|
201
|
-
* - Emulation.setEmulatedMedia (media type + features)
|
|
202
|
-
* - Network.enable / Network.emulateNetworkConditions
|
|
203
|
-
* - Runtime.enable / Runtime.evaluate (resize_page read-back verification)
|
|
204
|
-
* Parity gaps vs chrome-devtools-mcp emulate/resize_page:
|
|
205
|
-
* - Override lifetime: toolkit is stateless (connection closes after each call). Device-metrics
|
|
206
|
-
* overrides persist on the target until cleared/navigated; UA/CPU/media/network overrides are
|
|
207
|
-
* session-scoped and reset when the per-call connection closes. The MCP keeps one long-lived
|
|
208
|
-
* session so its non-metrics overrides persist across subsequent MCP calls; ours do not.
|
|
209
|
-
* - No named device-descriptor presets (e.g. "iPhone 15"); caller passes raw metrics + UA.
|
|
210
|
-
* - No touch-emulation / screen-orientation override (Emulation.setTouchEmulationEnabled,
|
|
211
|
-
* setDeviceMetricsOverride.screenOrientation) — out of the assigned arg surface.
|
|
212
|
-
*/
|