cdp-toolkit 1.1.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 +67 -30
- package/dist/cli.js +410 -61
- package/dist/index.js +370 -39
- package/dist/mcp.js +609 -72
- package/package.json +2 -2
- package/src/bidi/driver.ts +31 -3
- package/src/capabilities.ts +1 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +1 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +6 -1
- package/src/leases-tools.ts +101 -0
- package/src/leases.ts +533 -0
- package/src/manifest.ts +211 -13
- package/src/mcp.ts +27 -19
- package/src/shared-tools.ts +52 -6
- 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/shared-tools.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type {
|
|
|
38
38
|
BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
39
|
} from "./driver.ts";
|
|
40
40
|
import type { TargetSelector } from "./types.ts";
|
|
41
|
+
import { assertLeaseOk, claimLease, leaseTtlMs, releaseLeaseFor, type LeaseBackend, type LeaseToken } from "./leases.ts";
|
|
41
42
|
|
|
42
43
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
43
44
|
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -49,6 +50,12 @@ function target3(p: PageInfo): { id: string; url: string; title: string } {
|
|
|
49
50
|
return { id: p.id, url: p.url, title: p.title };
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Same discriminator leases-tools.ts uses: the driver's own uid scheme tag
|
|
54
|
+
* ("cdp" for Chrome, "bidi" for Firefox). */
|
|
55
|
+
function backendOf(driver: BrowserDriver): LeaseBackend {
|
|
56
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
/* -------------------------------- page acquisition -------------------------------- */
|
|
53
60
|
|
|
54
61
|
/** Acquire, run, always release: the Driver counterpart of client.ts's withPage. */
|
|
@@ -61,14 +68,31 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
|
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
|
|
72
|
+
const pages = await driver.listPages();
|
|
73
|
+
const hit = await pickPage(driver, pages, selector);
|
|
74
|
+
// THE third choke point. close_page and select_page resolve here and nowhere
|
|
75
|
+
// else, so the lease check has to be here too, not only in resolveTarget /
|
|
76
|
+
// resolveContext. Closing someone else's leased tab is the worst version of
|
|
77
|
+
// the collision this feature exists to stop.
|
|
78
|
+
//
|
|
79
|
+
// Deliberately NOT passing liveIds, matching resolveTarget and resolveContext.
|
|
80
|
+
// For every branch but one it would be inert (hit is an element of `pages`),
|
|
81
|
+
// and for the bare-id branch it would be actively wrong: that branch can
|
|
82
|
+
// return a non-page target off the `all:true` listing, whose id is absent
|
|
83
|
+
// from `pages`, so passing pages' ids would report a perfectly healthy lease
|
|
84
|
+
// as target-gone and wave the caller straight through it.
|
|
85
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
86
|
+
return hit;
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
/**
|
|
65
90
|
* Same TargetSelector grammar as client.ts's resolveTarget. Matches its one asymmetry exactly:
|
|
66
91
|
* every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
|
|
67
92
|
* legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
|
|
68
93
|
* exact non-page targetId (a worker, an iframe) still resolves.
|
|
69
94
|
*/
|
|
70
|
-
async function
|
|
71
|
-
const pages = await driver.listPages();
|
|
95
|
+
async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
|
|
72
96
|
if (selector === undefined || selector === "active") {
|
|
73
97
|
if (!pages.length) throw new SharedToolError("no page targets open");
|
|
74
98
|
return pages[0]!;
|
|
@@ -114,16 +138,38 @@ export async function listPages(driver: BrowserDriver, args: { all?: boolean } =
|
|
|
114
138
|
return { pages, count: pages.length };
|
|
115
139
|
}
|
|
116
140
|
|
|
117
|
-
export async function newPage(
|
|
141
|
+
export async function newPage(
|
|
142
|
+
driver: BrowserDriver,
|
|
143
|
+
args: { url?: string; claim?: boolean; label?: string; ttlMs?: number } = {},
|
|
144
|
+
): Promise<{ targetId: string; url: string; lease?: LeaseToken; label?: string; expiresAt?: number }> {
|
|
118
145
|
const p = await driver.newPage(args.url);
|
|
119
|
-
return { targetId: p.id, url: p.url };
|
|
146
|
+
if (args.claim !== true) return { targetId: p.id, url: p.url };
|
|
147
|
+
// Atomic in the sense that matters: the tab is claimed before this call
|
|
148
|
+
// returns, so no other agent can see it unclaimed and take it first.
|
|
149
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
150
|
+
label: typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`,
|
|
151
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
152
|
+
});
|
|
153
|
+
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
120
154
|
}
|
|
121
155
|
|
|
122
|
-
export async function closePage(
|
|
156
|
+
export async function closePage(
|
|
157
|
+
driver: BrowserDriver,
|
|
158
|
+
args: { target?: TargetSelector },
|
|
159
|
+
): Promise<{ closed: string; success: boolean; leaseReleased?: boolean }> {
|
|
123
160
|
if (args.target === undefined || args.target === "") throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
124
161
|
const p = await resolvePage(driver, args.target);
|
|
125
162
|
const res = await driver.closePage(p.id);
|
|
126
|
-
|
|
163
|
+
// A failed close (target refused to close, or was already gone) must leave
|
|
164
|
+
// the lease intact: the caller still owns a tab that never actually closed,
|
|
165
|
+
// and releasing it here would let another agent claim a tab this one still
|
|
166
|
+
// thinks it holds.
|
|
167
|
+
if (!res.success) return { closed: p.id, success: false, leaseReleased: false };
|
|
168
|
+
// Closing a leased tab must not leave its lease file behind waiting on the
|
|
169
|
+
// TTL. resolvePage above already refused this call unless the caller holds
|
|
170
|
+
// the lease, so no token is needed here.
|
|
171
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
172
|
+
return { closed: p.id, success: true, ...(released.released ? { leaseReleased: true } : {}) };
|
|
127
173
|
}
|
|
128
174
|
|
|
129
175
|
async function writeSelectedFile(id: string): Promise<void> {
|
package/src/tools/console.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* console.ts
|
|
2
|
+
* console.ts: read console output captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_console_messages -> listConsoleMessages
|
|
5
5
|
* get_console_message -> getConsoleMessage
|
|
@@ -135,7 +135,7 @@ async function readConsoleEntries(file: string): Promise<ConsoleEntry[]> {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
* list_console_messages
|
|
138
|
+
* list_console_messages: return console output for the target. With
|
|
139
139
|
* `reload:true`, records a fresh capture window by reloading the page.
|
|
140
140
|
*/
|
|
141
141
|
export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): Promise<{
|
|
@@ -166,7 +166,7 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* get_console_message
|
|
169
|
+
* get_console_message: return a single console entry by index from the
|
|
170
170
|
* existing buffer for the target. Throws if the index is out of range.
|
|
171
171
|
*/
|
|
172
172
|
export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
|
package/src/tools/evaluate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* evaluate_script
|
|
2
|
+
* evaluate_script: run arbitrary JavaScript in a page's main context over raw
|
|
3
3
|
* CDP and return the evaluated value.
|
|
4
4
|
*
|
|
5
5
|
* Two modes, selected by whether `args` is supplied:
|
|
@@ -148,7 +148,7 @@ export async function evaluateScript(args: EvaluateScriptArgs): Promise<unknown>
|
|
|
148
148
|
*
|
|
149
149
|
* Parity gaps vs chrome-devtools-mcp evaluate_script:
|
|
150
150
|
* - MCP injects a typed `page`/element handle into the evaluated function; here `args` are plain
|
|
151
|
-
* JSON-serializable values passed positionally
|
|
151
|
+
* JSON-serializable values passed positionally; no live element/page handle is bound.
|
|
152
152
|
* - Non-serializable return values (DOM nodes, functions, circular objects) come back as their CDP
|
|
153
153
|
* `description` string rather than a structured handle, since returnByValue is used.
|
|
154
154
|
* - No isolated-world/contextId selection: evaluation runs in the page's default main-world context.
|
package/src/tools/heap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* heap.ts
|
|
2
|
+
* heap.ts: `take_heapsnapshot` over raw CDP.
|
|
3
3
|
*
|
|
4
4
|
* Captures a V8 heap snapshot of a page target and writes it as a
|
|
5
5
|
* `.heapsnapshot` file (the JSON format DevTools' Memory panel loads).
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* CDP semantics this module relies on:
|
|
8
8
|
* - `HeapProfiler.takeHeapSnapshot` streams the snapshot back as a sequence
|
|
9
9
|
* of `HeapProfiler.addHeapSnapshotChunk` events. The command's own result
|
|
10
|
-
* does NOT carry the data
|
|
10
|
+
* does NOT carry the data; it resolves only after the final chunk has been
|
|
11
11
|
* emitted. So we subscribe to the chunk event first, accumulate every
|
|
12
12
|
* chunk's string, then `await` the command; once it resolves we have the
|
|
13
13
|
* complete snapshot and join the chunks into one JSON document.
|
|
@@ -103,7 +103,7 @@ export async function takeHeapsnapshot(
|
|
|
103
103
|
*
|
|
104
104
|
* Parity gaps vs chrome-devtools-mcp `take_heapsnapshot`:
|
|
105
105
|
* - reportProgress is forced false; no incremental progress callbacks surfaced.
|
|
106
|
-
* - Returns {path,bytes,chunks,target} only
|
|
106
|
+
* - Returns {path,bytes,chunks,target} only; does not parse/summarize the
|
|
107
107
|
* snapshot (node counts, retained sizes); the .heapsnapshot file is for the
|
|
108
108
|
* DevTools Memory panel to load, matching the MCP tool's artifact behavior.
|
|
109
109
|
* --------------------------------------------------------------------------*/
|
package/src/tools/lighthouse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lighthouse_audit
|
|
2
|
+
* lighthouse_audit: THE ONLY non-CDP tool in this toolkit.
|
|
3
3
|
*
|
|
4
4
|
* Unlike every other module, this one does NOT speak the Chrome DevTools
|
|
5
5
|
* Protocol over a WebSocket. Instead it shells out to the external Lighthouse
|
|
@@ -105,7 +105,7 @@ interface LighthouseReport {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface LighthouseAuditArgs {
|
|
108
|
-
/** The URL to audit. Required
|
|
108
|
+
/** The URL to audit. Required, and never points at a user tab implicitly. */
|
|
109
109
|
url: string;
|
|
110
110
|
/**
|
|
111
111
|
* Lighthouse categories to run. Defaults to the full set.
|
|
@@ -206,7 +206,7 @@ export async function lighthouseAudit(
|
|
|
206
206
|
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(
|
|
207
207
|
res.stderr,
|
|
208
208
|
)
|
|
209
|
-
? " The Lighthouse CLI could not be fetched/installed via npx
|
|
209
|
+
? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable."
|
|
210
210
|
: "";
|
|
211
211
|
throw new CdpError(
|
|
212
212
|
`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`,
|
|
@@ -264,7 +264,7 @@ function truncate(s: string, max = 600): string {
|
|
|
264
264
|
* ---------------------------------------------------------------------------
|
|
265
265
|
* NON-CDP MODULE. This is the toolkit's sole tool that does not speak raw CDP.
|
|
266
266
|
*
|
|
267
|
-
* External dependency (subprocess, fetched on demand by npx
|
|
267
|
+
* External dependency (subprocess, fetched on demand by npx, not an npm dep):
|
|
268
268
|
* - `npx --yes lighthouse <url> --port=<cdpPort> --output=json
|
|
269
269
|
* --output-path=<artifact> --quiet --preset=desktop
|
|
270
270
|
* [--only-categories=...] --chrome-flags=--headless=new`
|
package/src/tools/navigation.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface NavigatePageResult {
|
|
|
48
48
|
* finish loading.
|
|
49
49
|
*
|
|
50
50
|
* `Page.navigate`/`Page.reload` resolve once the action is *committed*. We
|
|
51
|
-
* separately race the load milestone event
|
|
51
|
+
* separately race the load milestone event, subscribed BEFORE issuing the
|
|
52
52
|
* command so a fast load can't fire before we're listening. Pass
|
|
53
53
|
* `reload:true, ignoreCache:true` for a hard reload that refetches every
|
|
54
54
|
* subresource (the only way to pick up a freshly-deployed bundle the HTTP
|
package/src/tools/network.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* network.ts
|
|
2
|
+
* network.ts: read network activity captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_network_requests -> listNetworkRequests
|
|
5
5
|
* get_network_request -> getNetworkRequest
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* - default ({ reload: false }): read the target's shared "latest" buffer
|
|
9
9
|
* (rec-<targetId>.jsonl), correlate the four Network.* events by requestId,
|
|
10
10
|
* and return one row per request.
|
|
11
|
-
* - { reload: true }: run a captureWindow (both domains)
|
|
11
|
+
* - { reload: true }: run a captureWindow (both domains): Page.reload, capture
|
|
12
12
|
* for `durationMs` (default 2500ms), stop, then read+return the requests.
|
|
13
13
|
*
|
|
14
14
|
* Response bodies: CDP only serves a body from the LIVE renderer session, so
|
|
15
15
|
* get_network_request with { includeBody: true } drives a fresh reload capture
|
|
16
16
|
* and calls Network.getResponseBody before closing. Because a reload re-mints
|
|
17
17
|
* requestIds, body fetch is matched by `url` (stable), not by a carried-over
|
|
18
|
-
* requestId
|
|
18
|
+
* requestId; see getNetworkRequest for the exact contract.
|
|
19
19
|
*/
|
|
20
20
|
import { readFile } from "node:fs/promises";
|
|
21
21
|
import { CdpError, resolveTarget } from "../client.ts";
|
|
@@ -59,7 +59,7 @@ export interface ListNetworkRequestsArgs {
|
|
|
59
59
|
|
|
60
60
|
export interface GetNetworkRequestArgs {
|
|
61
61
|
target?: TargetSelector;
|
|
62
|
-
/** Match by exact requestId (metadata only
|
|
62
|
+
/** Match by exact requestId (metadata only, see includeBody). */
|
|
63
63
|
requestId?: string;
|
|
64
64
|
/** Match by URL substring (first match). Required for body fetch (includeBody). */
|
|
65
65
|
url?: string;
|
|
@@ -153,7 +153,7 @@ async function readNetworkRequests(file: string): Promise<NetworkRequest[]> {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* list_network_requests
|
|
156
|
+
* list_network_requests: correlated request rows for the target. With
|
|
157
157
|
* `reload:true`, records a fresh capture window (both domains) by reloading.
|
|
158
158
|
*/
|
|
159
159
|
export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): Promise<{
|
|
@@ -187,13 +187,13 @@ export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): P
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* get_network_request
|
|
190
|
+
* get_network_request: return one request (matched by requestId, else by URL
|
|
191
191
|
* substring) including response status/headers.
|
|
192
192
|
*
|
|
193
193
|
* Body retrieval: CDP only serves a response body from the LIVE renderer
|
|
194
194
|
* session, so `includeBody:true` must drive a fresh reload capture and fetch the
|
|
195
195
|
* body before the connection closes. A reload re-mints requestIds, so a body
|
|
196
|
-
* fetch can only be matched by `url` (stable across reload)
|
|
196
|
+
* fetch can only be matched by `url` (stable across reload), NOT by a
|
|
197
197
|
* `requestId` carried over from a prior `list_network_requests`. When a
|
|
198
198
|
* requestId is supplied with includeBody we therefore return metadata from the
|
|
199
199
|
* existing buffer and explain why the body is unavailable, rather than reloading
|
|
@@ -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
|
*/
|