cdp-toolkit 1.9.1 → 1.9.4
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 +389 -0
- package/CONTRACT.md +59 -2
- package/README.md +53 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,395 @@ All notable changes to cdp-toolkit are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.9.4] - 2026-08-11
|
|
9
|
+
|
|
10
|
+
Firefox gains a second mode: **attach**. Until now `--browser firefox` always spawned a
|
|
11
|
+
throwaway-profile Firefox — a login wall for anything needing a real, already-authenticated
|
|
12
|
+
session. `--connect <port|host:port|ws-url>` (or env `CDP_FIREFOX_ENDPOINT`) instead connects to
|
|
13
|
+
a Firefox **you** already started with `--remote-debugging-port`, so tools see your real logged-in
|
|
14
|
+
profile. This process never launches or kills that browser: dispose only ends the BiDi session
|
|
15
|
+
(`session.end`), never the process. Also fixes a doc-nit (README/CONTRACT overclaimed "Firefox
|
|
16
|
+
cannot be attached to at all" — the real, narrower limitation is that you cannot relaunch the
|
|
17
|
+
`firefox` binary with the debug flag against an already-running instance) and a real hang (a
|
|
18
|
+
second attach against a Firefox that already has an active BiDi session used to hang forever;
|
|
19
|
+
it now fails fast with an actionable error).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`--connect <endpoint>` / `CDP_FIREFOX_ENDPOINT`** — Firefox attach mode. `<endpoint>` accepts
|
|
24
|
+
three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port`
|
|
25
|
+
(`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. Flag beats env, mirroring
|
|
26
|
+
`--browser`/`CDP_BROWSER` precedence. An endpoint **implies** the Firefox backend on its own —
|
|
27
|
+
there is nothing else it could mean — and errors against an explicit `--browser chrome` /
|
|
28
|
+
`CDP_BROWSER=chrome` rather than silently picking one side.
|
|
29
|
+
- `src/backend.ts`: `normalizeBidiEndpoint`, `resolveFirefoxEndpoint`, `stripConnectFlag`,
|
|
30
|
+
`resolveBackend` (the single backend-selection entry point `cli.ts`/`mcp.ts` now both call —
|
|
31
|
+
returns `{kind, endpoint?}`), and `startFirefoxSession`/`getOrCreateFirefoxSession` now accept
|
|
32
|
+
an `{ endpoint }` to attach instead of launch.
|
|
33
|
+
- `src/bidi/client.ts`: `connectBidiSessionUrl(wsUrl, opts?)` — connects directly to a ws URL,
|
|
34
|
+
no port-to-URL construction. `connectBidiSession(port, opts)` is now a thin wrapper over it
|
|
35
|
+
(kept exported, unchanged behavior).
|
|
36
|
+
- `src/bidi/driver.ts`: `createFirefoxDriverForEndpoint(wsUrl, opts?)` — the attach-mode driver
|
|
37
|
+
constructor. The driver's connection identity moved from a bare `port: number` to a ws
|
|
38
|
+
`endpoint: string`, so an attach endpoint can carry a different host/port/path than a launched
|
|
39
|
+
Firefox's `ws://127.0.0.1:<port>/session`.
|
|
40
|
+
- `firefox:attach:smoke` script (`test/firefox-attach-smoke.ts`) — drives the real attach path
|
|
41
|
+
against an out-of-band-spawned Firefox: attach, read a page, dispose (Firefox stays alive), a
|
|
42
|
+
second attach cycle to the same Firefox (proves `session.end` freed the slot), and confirms
|
|
43
|
+
`resolveBackend` auto-implies `firefox` from `--connect` alone.
|
|
44
|
+
- `test/backend-attach.test.ts` — pure-unit coverage of `normalizeBidiEndpoint`,
|
|
45
|
+
`resolveFirefoxEndpoint`, `stripConnectFlag`, and `resolveBackend`, including the
|
|
46
|
+
`--browser chrome` + `--connect` conflict.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **A second attach against a Firefox with an already-active BiDi session used to hang forever.**
|
|
51
|
+
Firefox allows exactly one active WebDriver BiDi session at a time; a colliding `session.new`
|
|
52
|
+
fails with the raw wire message `Maximum number of active sessions`, which reached no caller
|
|
53
|
+
as an error — it hung. `getConnection` (`src/bidi/driver.ts`) now catches that specific message
|
|
54
|
+
and raises a clear, actionable error naming the endpoint and the fix (close the other client,
|
|
55
|
+
or restart Firefox; a client killed without a clean dispose, e.g. `SIGKILL`, can leave a
|
|
56
|
+
session Firefox does not reap on its own).
|
|
57
|
+
- **Attach-mode dispose was undefined behavior before this release** (the mode did not exist):
|
|
58
|
+
`BidiBrowserDriver.dispose()` now sends `session.end` before closing the socket, which is what
|
|
59
|
+
actually frees Firefox's single session slot for the next attach — closing the socket alone
|
|
60
|
+
does not. Launch-mode dispose is unaffected: the spawned process is still killed after the
|
|
61
|
+
driver disposes, same as before.
|
|
62
|
+
- **`src/bidi/launch.ts` doc-nit** (the issue's explicit ask, code unchanged): the header/footer
|
|
63
|
+
said Firefox "cannot be attached to at all." That overstated a real, narrower fact — you cannot
|
|
64
|
+
*relaunch* the `firefox` binary with `--remote-debugging-port` against an already-running
|
|
65
|
+
instance and have it take effect (the relaunch hands off and exits, opening no port; still true,
|
|
66
|
+
still verified against Firefox 153.0.3). Attaching to a port a Firefox process was *originally*
|
|
67
|
+
launched with — by this toolkit or by hand — always worked and is now this release's first-class
|
|
68
|
+
path. README.md and CONTRACT.md carried the same overclaim and are corrected too.
|
|
69
|
+
|
|
70
|
+
### Notes
|
|
71
|
+
|
|
72
|
+
- Tool count unchanged at **45**; no tool added, renamed, or removed. Both Firefox modes expose
|
|
73
|
+
the same tool set as before (see README's "Tool availability is filtered per backend").
|
|
74
|
+
- The CLI is one process per invocation either way: an attach-mode CLI call still launches no
|
|
75
|
+
process and kills none, it just skips `launchFirefox` entirely. Multi-step attach workflows
|
|
76
|
+
still want the MCP server, same reasoning as launch mode.
|
|
77
|
+
|
|
78
|
+
## [1.9.3] - 2026-08-10
|
|
79
|
+
|
|
80
|
+
`cli.ts` had **no `--help` handling at all.** `--help` fell through the generic
|
|
81
|
+
`--key value` branch into `args.help = true`, and the named tool ran with that
|
|
82
|
+
as an inert extra property; `-h` doesn't even start with `--`, so it fell all
|
|
83
|
+
the way to the positional-token branch and was silently dropped once a tool
|
|
84
|
+
name was already set. Either way, **the tool ran.** Measured live this
|
|
85
|
+
session: `cdp take_screenshot --help` executed a real capture of whatever tab
|
|
86
|
+
happened to be active and wrote a 1.2 MB PNG to `/tmp/cdp-toolkit` — a "tell
|
|
87
|
+
me, don't do it" discovery gesture that silently photographed what the user
|
|
88
|
+
was looking at. Reproduced again here with the safe, non-capturing
|
|
89
|
+
`list_pages --help` against a `CDP_BASE` pointed at a closed port: pre-fix it
|
|
90
|
+
printed `{"error":"Unable to connect. Is the computer able to access the
|
|
91
|
+
url?"}` and exited 1 — proof the tool dispatched and tried to reach the
|
|
92
|
+
browser instead of printing help. Post-fix, the identical invocation against
|
|
93
|
+
the same dead port prints usage and exits 0, and a directory listing of the
|
|
94
|
+
artifact dir taken before and after is byte-identical: no file, no browser
|
|
95
|
+
touch.
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
|
|
99
|
+
- **`--help`/`-h` now short-circuit before any tool dispatch, recognised
|
|
100
|
+
anywhere in argv** (not just as the first token), and are never treated as
|
|
101
|
+
a tool argument or a tool name. With no tool named, `cdp --help`/`-h` prints
|
|
102
|
+
the existing top-level usage banner (now also documenting the flag itself).
|
|
103
|
+
With a tool named, `cdp <tool> --help` prints that tool's argument list —
|
|
104
|
+
name, type, required/optional, description — read directly from
|
|
105
|
+
`MANIFEST`'s `inputSchema` (`src/manifest.ts`), the exact JSON Schema the
|
|
106
|
+
MCP server advertises via `tools/list`, so there is no second
|
|
107
|
+
hand-maintained argument list to drift from it.
|
|
108
|
+
- An unknown tool name alongside `--help` (`cdp nope --help`) is reported as
|
|
109
|
+
`{"error":"unknown tool 'nope'. ..."}` and exits 1 — the same message the
|
|
110
|
+
CLI already gives for `cdp nope` without `--help` — rather than silently
|
|
111
|
+
falling back to top-level usage as though the name had been accepted.
|
|
112
|
+
- **Decision, stated explicitly:** an unknown `--flag` passed to a *known*
|
|
113
|
+
tool still passes through as a tool argument, unchanged from pre-1.9.3
|
|
114
|
+
behavior. Every `inputSchema` already declares `additionalProperties:
|
|
115
|
+
false`, but neither `cli.ts` nor `mcp.ts` has ever enforced that — each tool
|
|
116
|
+
validates its own args at runtime, extra keys are typically just ignored,
|
|
117
|
+
and an existing caller may already depend on that leniency for something
|
|
118
|
+
harmless. Rejecting unknown flags here would diverge the CLI from the MCP
|
|
119
|
+
server (whose validation this release does not touch) to fix a problem this
|
|
120
|
+
bug did not create. `--help`/`-h` are instead recognised by name,
|
|
121
|
+
specifically, ahead of generic flag parsing — narrow enough that it needs
|
|
122
|
+
none of that broader, riskier call.
|
|
123
|
+
|
|
124
|
+
### Added
|
|
125
|
+
|
|
126
|
+
- `test/cli-help.test.ts` (6 tests): spawns the real CLI as a subprocess with
|
|
127
|
+
`CDP_BASE` pointed at a closed port, so any invocation that still reaches
|
|
128
|
+
tool dispatch fails loudly with a connection error instead of silently
|
|
129
|
+
succeeding. Watched fail against the pre-fix code first — 5 of 6 red on
|
|
130
|
+
exit code 1 instead of 0 — before confirming green post-fix.
|
|
131
|
+
|
|
132
|
+
### Notes
|
|
133
|
+
|
|
134
|
+
- Tool count unchanged at **45**; no tool added, renamed, or removed.
|
|
135
|
+
`take_screenshot` is not among the files this release touches.
|
|
136
|
+
- 748 pass / 0 fail across 22 files, up from 742/21. `bun run typecheck` clean.
|
|
137
|
+
|
|
138
|
+
## [1.9.2] - 2026-08-10
|
|
139
|
+
|
|
140
|
+
`take_screenshot` grows four arguments — `scale`, `renderWidth`/`renderHeight`,
|
|
141
|
+
`tile` — and stops being able to hang the tab it is capturing. The constraint
|
|
142
|
+
worth learning from this release: **Chrome cannot encode a screenshot larger
|
|
143
|
+
than 16384 device px on either side, and past that it does not refuse politely
|
|
144
|
+
— `Page.captureScreenshot` never answers, and because `captureBeyondViewport`
|
|
145
|
+
resizes the renderer to the clip for the length of the capture, a call that
|
|
146
|
+
never returns never puts it back.** Output px are `ceil(css × scale ×
|
|
147
|
+
devicePixelRatio)`, so on an ordinary ratio-2 display that ceiling arrives at
|
|
148
|
+
about 8192 CSS px — a long article, a chat log, a docs page. A plain no-flag
|
|
149
|
+
`take_screenshot` of a 9000 CSS px page timed out (probed with an 8 s command
|
|
150
|
+
timeout) and left the tab wedged at 1390×9000, with every later capture on it
|
|
151
|
+
timing out too. It now returns a 2780×18000 px file in 1 second, as two
|
|
152
|
+
stitched bands. Tool count
|
|
153
|
+
stays 45 — no new tool, `take_screenshot` grew arguments. Five commits landed
|
|
154
|
+
it in sequence, on top of two measurement passes that drove a live
|
|
155
|
+
Chrome 151.0.7922.76 and wrote no code — the encode cap and the band-artifact
|
|
156
|
+
hunt. Every number below comes off a measurement, and where a measurement
|
|
157
|
+
contradicted the brief that ordered the work, the measurement is what is
|
|
158
|
+
written down.
|
|
159
|
+
|
|
160
|
+
### Added
|
|
161
|
+
|
|
162
|
+
- **`scale` — a per-capture output multiplier**, a finite number greater than
|
|
163
|
+
0 and at most 8 (default 1), Chrome only (capability `screenshot.scale`).
|
|
164
|
+
Measured exact on a live tab: a 2780×2128 default capture at `scale 3` is
|
|
165
|
+
8340×6384, a `--fullPage` at `scale 2` is 5560×13000, a 400×250 CSS element
|
|
166
|
+
at `scale 4` is 3200×2000 — `sips` agrees with the reported size on 9 of 9
|
|
167
|
+
artifacts, PNG and JPEG alike. It re-renders, it does not upsample, and it
|
|
168
|
+
changes nothing the page can see: `devicePixelRatio` read 2 and
|
|
169
|
+
`innerWidth`/`innerHeight` read 1390×1064 both before and after a `scale 3`
|
|
170
|
+
capture. Firefox refuses it rather than silently returning a 1x image the
|
|
171
|
+
caller could not tell apart from the one they wanted —
|
|
172
|
+
`browsingContext.captureScreenshot` has no scale parameter, and the refusal
|
|
173
|
+
points at `emulate {deviceScaleFactor}` instead.
|
|
174
|
+
|
|
175
|
+
- **`renderWidth`/`renderHeight` — emulate a viewport for exactly one capture,
|
|
176
|
+
then put the previous one back.** Required together, integers in
|
|
177
|
+
`[1, 16384]` CSS px, and declared by **both** backends (capability
|
|
178
|
+
`screenshot.renderSize`) — unlike `scale`, this is not a Chrome-only
|
|
179
|
+
argument. A 1390×1064 tab captured at 1920×1080 yields 3840×2160 device px
|
|
180
|
+
and reads back at 1390×1064 afterwards; captured at 420×900 it is
|
|
181
|
+
**byte-identical** (sha256 `93ffca74…`) to the same page genuinely emulated
|
|
182
|
+
at 420×900, media query flipped and all. That is the difference from `scale`
|
|
183
|
+
in one sentence: `scale` re-renders the same layout at more pixels,
|
|
184
|
+
`renderWidth`/`renderHeight` change what the page *believes* for one
|
|
185
|
+
capture. The restore holds on the success path, on the throwing path, and
|
|
186
|
+
over a caller's prior `emulate` (a tab emulated at 777×555 is 777×555 again
|
|
187
|
+
afterwards, not the real device). Two honest limits ride in the result
|
|
188
|
+
rather than in a comment: the restore can only put back an override **this
|
|
189
|
+
toolkit** applied — one set by the DevTools UI, another client, or an older
|
|
190
|
+
build leaves no record and is reset to the real device — and a capture that
|
|
191
|
+
TIMES OUT cannot be restored at all, because the `finally` runs while
|
|
192
|
+
Chrome's own command is still executing and re-resizes the page behind it.
|
|
193
|
+
`renderRestored: false` (plus `renderRestoreError`) is what the caller gets
|
|
194
|
+
when the tab is still emulated and they must fix it themselves.
|
|
195
|
+
|
|
196
|
+
- **`tile` — vertical band capture, stitched losslessly, and AUTO by
|
|
197
|
+
default.** Three-valued: omitted means one shot when the projection fits and
|
|
198
|
+
bands when it does not; `false` never bands and refuses instead (the exact
|
|
199
|
+
pre-1.9.2 behaviour); `true` always bands, even when one shot would have
|
|
200
|
+
fitted. Only `true` costs a capability (`screenshot.tile`, Chrome only).
|
|
201
|
+
The headline, a plain call with no flags on a 140,982 CSS px page:
|
|
202
|
+
**2780×281964 px, 18 bands, 10,818,058 bytes, 17–18 s**, tab healthy
|
|
203
|
+
afterwards (`[innerWidth, innerHeight, devicePixelRatio]` = `[1390, 1064,
|
|
204
|
+
2]`, `scrollY` 0) and two runs byte-identical. 281964 is 140982 × 2 exactly:
|
|
205
|
+
the whole document, nothing dropped. A banded capture is PNG-only and cannot
|
|
206
|
+
also `returnBase64`; both combinations are refused in 21–31 ms measured,
|
|
207
|
+
before a single band is captured, with a message naming the band count and
|
|
208
|
+
the region that forced the decision. Tiling is **vertical only** — a projected WIDTH
|
|
209
|
+
past the cap is refused (naming the largest scale, or renderWidth, that
|
|
210
|
+
would fit), not split into a grid, because PNG scanlines are the unit that
|
|
211
|
+
can be concatenated without decoding the image whole.
|
|
212
|
+
|
|
213
|
+
- **New result fields.** `scale` is always present. `width`/`height` are
|
|
214
|
+
**decoded from the encoded bytes** (PNG IHDR, JPEG SOF marker walk) rather
|
|
215
|
+
than projected, and are **omitted entirely** when the bytes cannot be
|
|
216
|
+
decoded — absent beats invented. `renderSize`, `renderRestored` and
|
|
217
|
+
`renderRestoreError` accompany a render-size capture; `tiled: true` and
|
|
218
|
+
`bands: <n>` are present only on a banded one, so a caller always knows when
|
|
219
|
+
an image was assembled rather than captured.
|
|
220
|
+
|
|
221
|
+
- **`src/png.ts`, a streaming lossless PNG band stitcher**, zero runtime
|
|
222
|
+
dependencies (`node:zlib` is a builtin). Peak RSS is **135.3 MB while
|
|
223
|
+
writing a 1.42 GB image** (368,640 rows) — 9× the raw pixels of a 157 MB one
|
|
224
|
+
for 11.7% more memory, because peak tracks band size, not image size (Bun's
|
|
225
|
+
own floor is 23.6 MB of that).
|
|
226
|
+
Its output is byte-identical under ffmpeg's raw decode to the source bands
|
|
227
|
+
concatenated, at every one of PNG's five row filters, and a 2-band stitch of
|
|
228
|
+
a page that fitted decodes to the identical sha256 as the single shot of the
|
|
229
|
+
same page. It carries `gAMA`/`cHRM`/`sRGB`/`iCCP`/`pHYs` from the first band
|
|
230
|
+
and throws on a mismatch: every Chrome capture measured carries an embedded
|
|
231
|
+
ICC profile, so a stitcher that dropped ancillary chunks — the obvious
|
|
232
|
+
default — would silently recolour every image it touched.
|
|
233
|
+
|
|
234
|
+
### Changed
|
|
235
|
+
|
|
236
|
+
- **Every capture now costs one `Page.getLayoutMetrics` round trip, including
|
|
237
|
+
the plain default one.** That is the change that closed the hazard. The
|
|
238
|
+
plain path was the one path that built no clip, so the encode guard never
|
|
239
|
+
ran on it, while `captureBeyondViewport: true` is hardcoded for every
|
|
240
|
+
capture — meaning an ordinary long page, hit with an ordinary flagless
|
|
241
|
+
screenshot, was already capturing beyond the viewport with nothing standing
|
|
242
|
+
between it and the hang. Nothing else about that path moved: it still sends
|
|
243
|
+
**no `clip` key at all**, and every step of this release A/B'd the common
|
|
244
|
+
path against its own parent commit's binaries on the same live tab —
|
|
245
|
+
default, `fullPage`, `scale 2`, `jpeg`, element-clip and `tile:false` all
|
|
246
|
+
came back byte-identical.
|
|
247
|
+
|
|
248
|
+
- **`savePath` outside the artifact dir now works.** The destination directory
|
|
249
|
+
is created from the output path's own dirname rather than always
|
|
250
|
+
`CDP_ARTIFACT_DIR`; previously a `savePath` in a non-existent directory
|
|
251
|
+
failed `ENOENT` while `CDP_ARTIFACT_DIR` was created for nobody.
|
|
252
|
+
|
|
253
|
+
- **The default filename's stamp is the capture's START**, not its end: a
|
|
254
|
+
banded capture writes as bands arrive and has no finished image to name a
|
|
255
|
+
file after, and one naming path is easier to trust than two.
|
|
256
|
+
|
|
257
|
+
- **A new state-file type**, `${CDP_ARTIFACT_DIR}/viewport-<scheme>-<targetId>.json`,
|
|
258
|
+
a sibling of the lease and origin files. A render-size capture must restore
|
|
259
|
+
the caller's *previous* override, and a `PageDriver` lives for exactly one
|
|
260
|
+
call (under the CLI, one process), so the record has to outlive the
|
|
261
|
+
connection the same way a lease does — measured: an `emulate` override set
|
|
262
|
+
in one process is still in force when read from another.
|
|
263
|
+
|
|
264
|
+
### Fixed
|
|
265
|
+
|
|
266
|
+
- **An element-clipped `take_screenshot` returned the wrong region of the page
|
|
267
|
+
whenever the element was not already on screen.** `--selector` and `--uid`
|
|
268
|
+
both, at every page height tried. The mechanism is a frame mismatch worth
|
|
269
|
+
learning on its own: `DOM.getBoxModel` answers in the **viewport** frame (its
|
|
270
|
+
quad matched `getBoundingClientRect()` to the pixel every time), but
|
|
271
|
+
`captureBeyondViewport: true` — hardcoded on every capture here — makes
|
|
272
|
+
Chrome read `clip.x`/`clip.y` as **document** coordinates. The driver scrolled
|
|
273
|
+
the element into view with `DOM.scrollIntoViewIfNeeded` and then handed
|
|
274
|
+
Chrome the element's now-small *on-screen* y as if it were its position in
|
|
275
|
+
the document, so the capture landed within one viewport height of the top of
|
|
276
|
+
the page no matter how far down the element really was. Measured on
|
|
277
|
+
Chrome 151.0.7922.76, a 10,000 CSS px page with the target at y=5000: the
|
|
278
|
+
scroll settled at `scrollY` 4593 with `rectTop` 407, and the returned
|
|
279
|
+
2780×500 PNG was `document[407..657]` — a flat slab of page background with
|
|
280
|
+
no part of the element in it. Identical failure at the two sizes this bug was
|
|
281
|
+
first reported at, 16,578 px (`scrollY` 14593) and 140,982 px (`scrollY`
|
|
282
|
+
69593). Starting the same call from `scrollY` 4950 instead — element already
|
|
283
|
+
visible at `rectTop` 50, so the driver's own scroll is a no-op — returned
|
|
284
|
+
`document[50..300]`: the document's top banner for its first 100 CSS px, then
|
|
285
|
+
background, boundary on the predicted pixel. **The fix is the frame
|
|
286
|
+
conversion**: the page's scroll offset is added to the box-model quad, in CSS
|
|
287
|
+
px (`cssVisualViewport.pageX`/`pageY`), read **after** the scroll settles and
|
|
288
|
+
off the same single `Page.getLayoutMetrics` every capture already pays for —
|
|
289
|
+
reading it before the scroll would correct by 0 on a freshly navigated tab
|
|
290
|
+
and change nothing. Those same three page heights now return the element
|
|
291
|
+
itself, and the three starting scroll positions 0 / 4950 / 9000 return one
|
|
292
|
+
identical sha256 `54b96705…` instead of two different wrong images. `scale`
|
|
293
|
+
(5560×1000 at `scale 2`), `renderWidth`/`renderHeight` (1600×500 at 800×600,
|
|
294
|
+
`renderRestored: true`), `tile` and the `uid` path all ride the corrected
|
|
295
|
+
rect unchanged, and the short-page control — element inside the initial
|
|
296
|
+
viewport, no scroll needed, offset 0 — is byte-for-byte what it was.
|
|
297
|
+
**Two honest notes.** First, the units are a live trap: on a scrolled page
|
|
298
|
+
`visualViewport.pageX`/`pageY` come back in **CSS** px (700/4593) while
|
|
299
|
+
`clientWidth`/`clientHeight` on that very same rect are **device** px
|
|
300
|
+
(2780×2128), and only `layoutViewport` reports the offset in device px
|
|
301
|
+
(1400/9186) — so "no `css` prefix means device px" is false for exactly the
|
|
302
|
+
fields this fix reads, and a plausible implementation that divided them by
|
|
303
|
+
the pixel ratio would have halved the correction. Second, this release's own
|
|
304
|
+
regression check could not have caught it: the element-clip A/B recorded
|
|
305
|
+
under *Changed* above compared each commit against its parent's binaries and
|
|
306
|
+
came back byte-identical **because both sides were wrong the same way** — a
|
|
307
|
+
same-vs-same check is blind to a bug older than both of its arms.
|
|
308
|
+
`DOM.scrollIntoViewIfNeeded` was kept rather than dropped, and that was
|
|
309
|
+
measured too, not assumed: with `captureBeyondViewport` nothing has to be on
|
|
310
|
+
screen to be captured, but on a fixture whose target fills itself from an
|
|
311
|
+
`IntersectionObserver`, the capture with the scroll reads "LAZY CONTENT
|
|
312
|
+
LOADED" and the same capture without it returns the unloaded placeholder in
|
|
313
|
+
the correct region — the same caveat the tiling notes below record, from the
|
|
314
|
+
other side. WebDriver BiDi never had this defect: it passes
|
|
315
|
+
`clip: {type: "element"}` and does no coordinate arithmetic of its own.
|
|
316
|
+
|
|
317
|
+
- **`emulate {clearOverrides: true}` reported `"cleared": true` while clearing
|
|
318
|
+
nothing.** Reproduced across three separate CLI processes: process A set
|
|
319
|
+
802×601, process B cleared and reported success, process C read back
|
|
320
|
+
`[802, 601]`. `Emulation.clearDeviceMetricsOverride` only reverts to the real
|
|
321
|
+
device when the clearing connection is also the connection that *set* the
|
|
322
|
+
override — and under this toolkit's per-call connection lifetime it
|
|
323
|
+
essentially never is, so this was the common case, not an edge case. The
|
|
324
|
+
driver now reads the tab's currently effective size, re-asserts that exact
|
|
325
|
+
size on its own connection (a value the caller cannot observe changing,
|
|
326
|
+
whose only job is to take ownership), and then clears; the same trace now
|
|
327
|
+
reads back `[1390, 1064]`, the real device. If any step of that sequence
|
|
328
|
+
throws, the call throws too rather than returning a false success, and the
|
|
329
|
+
viewport record is deliberately left in place so a later capture does not
|
|
330
|
+
read "no record" as "nothing to restore".
|
|
331
|
+
|
|
332
|
+
### Measured, not assumed — the numbers this release ships on
|
|
333
|
+
|
|
334
|
+
- **16384 device px per side, both axes, clipped and unclipped alike.**
|
|
335
|
+
16384×1600 encodes in 266 ms; 16400×1600 — sixteen pixels more — never
|
|
336
|
+
answers (60 s probe cap). With no `clip` key at all: 2560×16384 succeeds in
|
|
337
|
+
217 ms, 2560×24000 hangs. **It is not an area limit:** 8192×8192 (67,108,864
|
|
338
|
+
px) succeeds in 1.3 s while 16400×1600 (26,240,000 px, 2.6× less) hangs. The
|
|
339
|
+
cap is also in DEVICE px, so a CSS clip of 8200×800 at `deviceScaleFactor: 2`
|
|
340
|
+
hangs identically to asking for 16400×1600 directly.
|
|
341
|
+
- **Band tiling is visually clean, and this was falsification-tested rather
|
|
342
|
+
than assumed.** A `position: fixed` header present in the top band appears
|
|
343
|
+
**zero** times in a band 8000 px down; a `position: sticky` header appears
|
|
344
|
+
only in the band containing its true document position. Seams are row-exact
|
|
345
|
+
— band A ends on ROW 039, band B begins on ROW 040, no duplicate, no gap.
|
|
346
|
+
Clipped bands are byte-identical across three different real scroll
|
|
347
|
+
positions, so a stitcher never has to manage scroll. `clip.y` is always CSS
|
|
348
|
+
px and `scale` only multiplies resolution, so the two compose without
|
|
349
|
+
rescaling offsets. Content-verified end to end on the 140,982 px page: all
|
|
350
|
+
**71** label bands land at exactly their expected rows across 9 bands and 8
|
|
351
|
+
seams.
|
|
352
|
+
- **The honest caveat.** A page that loads content only on real scroll —
|
|
353
|
+
lazy images, virtualized lists, infinite scroll — renders **blank** in bands
|
|
354
|
+
the real viewport never reached. Measured directly: a page whose
|
|
355
|
+
`IntersectionObserver` had loaded 9 of 300 rows still reported 9 after a
|
|
356
|
+
deep-band capture, and the deep band was uniform `#222`.
|
|
357
|
+
`captureBeyondViewport` does not trigger an `IntersectionObserver`. This is
|
|
358
|
+
a property of the source page, not a bug in the stitch, and it is not
|
|
359
|
+
fixable here — which is exactly why an auto-tiled capture reports `tiled`,
|
|
360
|
+
so a caller knows to reason about it (pre-scroll the page, or disable
|
|
361
|
+
virtualization, before capturing).
|
|
362
|
+
- **Two corrections to earlier internal notes, kept rather than quietly
|
|
363
|
+
dropped.** (1) At 140,982 CSS px the *old* code did not hang: it failed in
|
|
364
|
+
about a second with `Page.captureScreenshot: Page is too large.` and left
|
|
365
|
+
the tab healthy. The hang-and-wedge lives at *modest* overshoot — the 9000
|
|
366
|
+
CSS px page above. Both are failures, but "it hangs at 140,982" is not what
|
|
367
|
+
this Chrome build does. (2) A wedged tab is **not** recovered by
|
|
368
|
+
`emulate --clearOverrides`: measured in order, clearing reports success and
|
|
369
|
+
the tab stays 1390×9000, an explicit `emulate --width/--height` recovers it,
|
|
370
|
+
and clearing again puts it *back*. Only a reload actually clears it. The
|
|
371
|
+
renderer's real viewport is what the abandoned capture resized, so
|
|
372
|
+
"clear to the real device" lands on the wrong size.
|
|
373
|
+
|
|
374
|
+
### Notes
|
|
375
|
+
|
|
376
|
+
- Tool count is unchanged at **45**. `take_screenshot` grew four arguments;
|
|
377
|
+
no tool was added, renamed, or removed.
|
|
378
|
+
- **Firefox/BiDi does not declare `screenshot.scale` or `screenshot.tile`**,
|
|
379
|
+
and for two different reasons stated plainly rather than dressed up as
|
|
380
|
+
design: `browsingContext.captureScreenshot` genuinely has no scale
|
|
381
|
+
parameter, while band tiling is *probably* implementable there
|
|
382
|
+
(`clip: {type: "box"}` exists) but every property it rests on — does a box
|
|
383
|
+
clip render past the viewport, is `clip.y` CSS px, do fixed elements repeat
|
|
384
|
+
per band, are seams pixel-exact — was measured on Chrome and nowhere else,
|
|
385
|
+
because no Firefox was driven for it. `screenshot.renderSize` **is**
|
|
386
|
+
declared by both. Auto-tiling never fires on a backend without
|
|
387
|
+
`screenshot.tile`, so Firefox behaviour is unchanged.
|
|
388
|
+
- New tests: **117**, in `test/screenshot-scale.test.ts` (81) and
|
|
389
|
+
`test/png.test.ts` (36) — 736 pass / 0 fail across 21 files, up from 619.
|
|
390
|
+
Both new suites were mutation-checked rather than merely green: 13 deliberate
|
|
391
|
+
mutants of `src/png.ts` and 6 of the tiling arithmetic were each killed,
|
|
392
|
+
and the three mutants that survived are documented in the source as provably
|
|
393
|
+
equivalent rather than silently "fixed" later.
|
|
394
|
+
- `docs/social-preview.html` and `docs/demo.tape` carry no version string and
|
|
395
|
+
needed no re-render for this release.
|
|
396
|
+
|
|
8
397
|
## [1.9.1] - 2026-08-10
|
|
9
398
|
|
|
10
399
|
`list_network_requests`, `get_network_request`, and `list_console_messages` now
|
package/CONTRACT.md
CHANGED
|
@@ -68,15 +68,72 @@ export async function click(args: ClickArgs): Promise<unknown>;
|
|
|
68
68
|
|
|
69
69
|
## Artifact + state locations
|
|
70
70
|
|
|
71
|
-
- Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally).
|
|
71
|
+
- Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally). **`mkdir -p` the DIRNAME OF THE PATH BEING WRITTEN, not `ARTIFACT_DIR` unconditionally** (1.9.2): a tool taking a caller-supplied destination (`take_screenshot`'s `savePath`) otherwise creates a directory nobody writes to and then fails `ENOENT` on the one that matters.
|
|
72
72
|
- Selected-target state file (for `select_page`): `process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit"` + `/selected`. Contains a bare targetId. `resolveTarget` does NOT read it; tools may read it as a fallback default if you choose, but the simplest correct behavior is fine.
|
|
73
73
|
- Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
|
|
74
74
|
- Screencast spool: `${ARTIFACT_DIR}/screencast-<targetIdShort>-<isoish>/` holding `frame-NNNNNN.jpg|png` plus the generated `frames.ffconcat`. Deleted on a successful encode; **kept, and named in the error, when ffmpeg fails**, so the frames can be re-encoded by hand instead of lost. Output video: `${ARTIFACT_DIR}/screen-recording-<targetIdShort>-<isoish>.mp4`.
|
|
75
75
|
- Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction. Carries an `auto?: boolean` field (absent, and read as `false`, on any record older than 1.7.0): `true` means the gate itself acquired it under `CDP_REQUIRE_LEASE` and it is usable by any call from the owning pid with no token; `false` means it was claimed explicitly (`claim_page` or `new_page{claim:true}`) and demands its token even from that same pid.
|
|
76
|
+
- Viewport records (`src/driver.ts`, added 1.9.2): `${ARTIFACT_DIR}/viewport-<scheme>-<targetId>.json`, one per target whose device-metrics override this toolkit applied, written by both drivers' `emulate()` and cleared by a confirmed `clearOverrides`. Keyed by uid scheme for the same reason lease files are keyed by backend: a CDP targetId and a BiDi context id are not disjoint by construction. It exists because `take_screenshot`'s `renderWidth`/`renderHeight` must restore the caller's PREVIOUS override, and neither a private field nor the connection can hold that: a `PageDriver` is `lifetime: "per-call"` (under the CLI, one process per call) while a device-metrics override **outlives the connection that set it** — measured, an override set in one process reads back in another. Note the honest limit this record defines: the driver can only restore an override it recorded, so one applied by the DevTools UI, another client, or a pre-1.9.2 build leaves no file and a render-size capture resets it to the real device. There is no CDP or BiDi command that reads a device-metrics override back, so a driver genuinely cannot discover it.
|
|
76
77
|
- Origin files (`src/origins.ts`): `${ARTIFACT_DIR}/origin-<backend>-<targetId>.json`, one per tab this toolkit itself created (never written for a tab merely claimed). Same exclusive-create discipline as lease files, same directory, and it deliberately OUTLIVES the lease: `release_page`, expiry, and reclamation never touch it, only the tab closing does (reaped on the next `list_pages`/`list_origins` read). This is what `release_page` and reap both consult to decide whether closing a given tab is this toolkit's call to make; see `src/reap.ts`'s header for the four conditions that gate a reap close. As of 1.8.0 an `expired` lease qualifies for that close only past `ttlMs + CDP_REAP_GRACE_MS` (default 45 extra minutes) — `list_leases`' `stale`/reclaimable reading is unchanged at `ttlMs`; only the destructive close moved later. `dead-pid` is unaffected by grace.
|
|
77
78
|
- Beacon sessions (`src/activity.ts`, `src/cdp/driver.ts`): a `BeaconSessions` pool of held Chrome connections, one per tab with an installed activity beacon, bounded at 32 with oldest-first eviction — the same module-scope pattern `recorder.ts`/`network_mock.ts` already use for their own persistent per-target sessions. Exists only because `Page.addScriptToEvaluateOnNewDocument`'s registration dies with the connection that made it (verified against a live browser, not assumed), so surviving navigation under this toolkit's per-call lifetime requires holding a connection open; eviction degrades (the current document's listeners keep answering, only the next navigation's re-arm is lost) rather than breaking. Firefox needs no equivalent: its `LifetimeModel` is `"session"`, so a `script.addPreloadScript` registration on the one memoized BiDi connection per port already lives as long as that connection does.
|
|
78
79
|
- The browser-session connection (`src/tools/browser-session.ts`): one lazily-opened, module-scoped connection to the CDP browser endpoint (not a page endpoint), held open for the process's life, backing `wait_for_download` and `grant_permissions`. Opened only on first use of either tool, so a session that never touches downloads or permissions pays nothing. Exists because `Browser.setDownloadBehavior` and `Browser.grantPermissions` were measured — probed against a real browser before either tool was written — to NOT persist past the arming/granting client's disconnect, which this toolkit's per-call connection lifetime would otherwise hit on every single call. Both tools are therefore MCP-server-only capabilities, the same category as `performance_start_trace`/`stop_screen_recording`'s persistent-connection requirement, for the same underlying reason.
|
|
79
80
|
|
|
81
|
+
## Firefox backend: launch vs. attach (`src/backend.ts`, `src/bidi/driver.ts`)
|
|
82
|
+
|
|
83
|
+
The Firefox/BiDi backend runs in one of two modes, selected by `resolveBackend(argv, env)`
|
|
84
|
+
(`src/backend.ts`) and shared by `cli.ts` and `mcp.ts`. The two modes differ in exactly one
|
|
85
|
+
thing — process ownership — and that difference must never blur:
|
|
86
|
+
|
|
87
|
+
- **LAUNCH** (default, no endpoint resolved): `startFirefoxSession()` calls `launchFirefox()`
|
|
88
|
+
(`src/bidi/launch.ts`), which owns the spawned process end to end. `dispose()` closes the BiDi
|
|
89
|
+
driver **then** kills the process, in that order, so no invocation can leak a process.
|
|
90
|
+
- **ATTACH** (an endpoint was resolved): `startFirefoxSession({ endpoint })` calls
|
|
91
|
+
`createFirefoxDriverForEndpoint(wsUrl)` (`src/bidi/driver.ts`) directly — no `launchFirefox`,
|
|
92
|
+
no process handle at all. `dispose()` is `driver.dispose()` only: it sends `session.end` (see
|
|
93
|
+
below) then closes the socket. **It must never call a process `close()`; there is no process
|
|
94
|
+
to close.** The Firefox the user started outlives every attach session against it.
|
|
95
|
+
|
|
96
|
+
**Config surface** (`resolveFirefoxEndpoint`, `normalizeBidiEndpoint`, `resolveBackend` in
|
|
97
|
+
`src/backend.ts`):
|
|
98
|
+
|
|
99
|
+
- env `CDP_FIREFOX_ENDPOINT`, CLI flag `--connect <endpoint>` — flag beats env (same precedence
|
|
100
|
+
as `--browser`/`CDP_BROWSER`). An empty env value counts as unset, so an exported-but-blank
|
|
101
|
+
`CDP_FIREFOX_ENDPOINT` does not force attach mode.
|
|
102
|
+
- `<endpoint>` accepts three spellings, all normalized by `normalizeBidiEndpoint` to a ws URL:
|
|
103
|
+
bare port (`9223` → `ws://127.0.0.1:9223/session`), `host:port` (`127.0.0.1:9223` → same
|
|
104
|
+
shape), or a full `ws://`/`wss://` URL (used verbatim; a bare origin with no path or `/` gets
|
|
105
|
+
`/session` appended, since Firefox serves BiDi there). Invalid input (bad URL, non-numeric,
|
|
106
|
+
port outside 1–65535) throws with the accepted spellings named in the message rather than
|
|
107
|
+
reaching the transport as an opaque connect timeout.
|
|
108
|
+
- An endpoint **implies** `kind: "firefox"` in the returned `BackendSelection` — there is nothing
|
|
109
|
+
else `--connect`/`CDP_FIREFOX_ENDPOINT` could mean. `resolveBackend` throws
|
|
110
|
+
(`"--connect / CDP_FIREFOX_ENDPOINT is a Firefox attach option and is not valid with the
|
|
111
|
+
chrome backend"`) when an endpoint is present **and** the browser was explicitly set to chrome
|
|
112
|
+
(`--browser chrome` or `CDP_BROWSER=chrome`); an endpoint alongside no explicit `--browser` (or
|
|
113
|
+
an explicit `--browser firefox`) silently selects Firefox as always.
|
|
114
|
+
- `stripConnectFlag` mirrors `stripBrowserFlag`: `cli.ts` strips `--connect <value>` out of argv
|
|
115
|
+
before tool-arg parsing so it never reaches a tool's own args.
|
|
116
|
+
|
|
117
|
+
**The single-BiDi-session constraint.** Firefox allows exactly one active WebDriver BiDi session
|
|
118
|
+
per process; a second `session.new` against the same Firefox fails with `session.new: Maximum
|
|
119
|
+
number of active sessions` (verified against Firefox 153.0.3). `getConnection` in
|
|
120
|
+
`src/bidi/driver.ts` catches that specific message and rethrows via the local `driverError`
|
|
121
|
+
helper (code `"disconnected"`) naming the endpoint and explaining the fix (close the other
|
|
122
|
+
client, or restart Firefox), instead of letting the raw wire message reach the caller. Closing the BiDi socket alone does
|
|
123
|
+
**not** free the slot — only `session.end` does, which is why `BidiBrowserDriver.dispose()`
|
|
124
|
+
sends `session.end` (best-effort, swallowing any error — harmless if the socket is already gone)
|
|
125
|
+
**before** closing the connection. A client that dies without a clean dispose (e.g. `SIGKILL`)
|
|
126
|
+
leaves the slot occupied until Firefox restarts: this toolkit does not and cannot force-end a
|
|
127
|
+
session it does not own, since there is no id to end. This is why a colliding attach now fails
|
|
128
|
+
fast with an actionable error instead of hanging forever (the pre-1.9.4 behavior).
|
|
129
|
+
|
|
130
|
+
**Driver identity moved from port to endpoint.** `BidiBrowserDriver` is keyed by a BiDi ws
|
|
131
|
+
endpoint string (`this.endpoint`), not a `port: number`: a launched Firefox's endpoint is
|
|
132
|
+
`ws://127.0.0.1:${port}/session` (via `createFirefoxDriver(port)`), while an attach endpoint may
|
|
133
|
+
carry a different host, port, or path entirely (via `createFirefoxDriverForEndpoint(wsUrl)`).
|
|
134
|
+
Both funnel into the same `connections: Map<string, BidiConnection>` keyed by that string. Any
|
|
135
|
+
future change to connection identity must account for both call shapes.
|
|
136
|
+
|
|
80
137
|
## The element-reference scheme (READ if you touch snapshot or input)
|
|
81
138
|
|
|
82
139
|
A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
@@ -114,7 +171,7 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
|
114
171
|
| `src/tools/evaluate.ts` | `evaluate_script`→`evaluateScript` | `Runtime.enable` + `Runtime.evaluate` (`returnByValue`, `awaitPromise`). Support optional `args` via `Runtime.callFunctionOn` on a uid/selector. Surface `exceptionDetails` as thrown error. |
|
|
115
172
|
| `src/tools/snapshot.ts` | `take_snapshot`→`takeSnapshot` **+ export `resolveUid`** | a11y tree + ref scheme above. |
|
|
116
173
|
| `src/tools/input.ts` | `click`→`click`, `hover`→`hover`, `drag`→`drag`, `fill`→`fill`, `fill_form`→`fillForm`, `type_text`→`typeText`, `press_key`→`pressKey`, `upload_file`→`uploadFile` | imports `resolveUid` from `./snapshot.ts`. `hover`=mouseMoved. `drag`=press→move→release. `fill`/`type_text`=focus + `Input.insertText` (fast) or `dispatchKeyEvent`. `press_key`=`Input.dispatchKeyEvent` (support modifiers + named keys). `upload_file`=`DOM.setFileInputFiles` on resolved node. `fill_form`=array of {uid|selector,value}. |
|
|
117
|
-
| `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. |
|
|
174
|
+
| `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. **As of 1.9.2 also `scale` (Chrome-only capability `screenshot.scale`, rides `clip.scale`), `renderWidth`/`renderHeight` (BOTH backends, `screenshot.renderSize`, an `Emulation.setDeviceMetricsOverride` held for one capture and undone in a `finally` against the viewport record above), and `tile` (three-valued, `true` costs `screenshot.tile`).** Two rules the size arithmetic imposes on anyone touching this path. **(a) Guard the projection, not the knob:** output px are `ceil(css × scale × devicePixelRatio)`, Chrome cannot encode past 16384 device px per side, and past that `Page.captureScreenshot` NEVER ANSWERS while `captureBeyondViewport` (hardcoded true) leaves the renderer resized to the clip — a wedged tab a `finally` cannot undo, because the client-side timeout fires while Chrome's command is still running. So `Page.getLayoutMetrics` is fetched and the projection checked on EVERY capture, including the plain no-clip one; a guard conditioned on the feature that motivated it (`scale !== 1`) has already let two wedges through. **(b) Bands are lazy and vertical:** past the cap the driver hands the tool layer a `BandedCapture` whose Nth `Page.captureScreenshot` runs only when the Nth band is pulled (accumulating them into an array would typecheck, pass on a small page, and hold 1.4 GB on a large one), stitched by `stitchPngBandsToFile` in `src/png.ts` — a zero-dependency streaming PNG stitcher (`node:zlib`), not an MCP tool and deliberately absent from the `TOOLS` registry. Vertical only: PNG scanlines are the one axis concatenable without decoding the image, so an over-WIDE projection is refused, never split. |
|
|
118
175
|
| `src/tools/emulation.ts` | `emulate`→`emulate`, `resize_page`→`resizePage` | `Emulation.setDeviceMetricsOverride` (width,height,deviceScaleFactor,mobile), `Emulation.setUserAgentOverride`, `Emulation.setCPUThrottlingRate`, `Emulation.setEmulatedMedia`, `Network`-based throttling via `Network.emulateNetworkConditions`. `resize_page` = device metrics width/height. |
|
|
119
176
|
| `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
|
|
120
177
|
| `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. **As of 1.9.1, the same three tools also accept a `worker:<substring>` target** (Chrome only, capability `worker.targets`; MV3 service/shared workers), resolved via `resolveRecorderTarget`/`resolveWorkerSelector` in `src/workers.ts` rather than `resolveTarget`. Because `Page.enable`/`Page.reload` do not exist on a worker session (measured: `-32601`), `captureWindow()` skips the reload for a worker-typed resolution and LISTENS on the held connection for `durationMs` instead — keyed off the resolved target's type, not the selector's shape, so a bare worker id takes the same branch as a `worker:` selector. Holding that session is a deliberate side effect, the same pattern as the beacon sessions below: it SUPPRESSES the worker's MV3 idle eviction for as long as the capture runs (measured: alive and still emitting events at 60s held, evicted ~30s after detach). `wake` (default `true` on a capture) restarts an evicted worker first, but is refused on a read-only call and on a bare target id, because a restart mints a NEW target id and the buffer is keyed by the old one — see `workerWakeMisuseMessage` in `src/workers.ts`. |
|
package/README.md
CHANGED
|
@@ -119,6 +119,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
|
|
|
119
119
|
- **One target per call, never a broadcast.** Each call opens one WebSocket to the one target it named, with a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. Drive as many tabs as you like across calls; there is still no broadcast step that can stall on a wedged tab.
|
|
120
120
|
- **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
|
|
121
121
|
- **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, real HTML5 drag-and-drop, raw scroll/mouse dispatch, download capture, permission grants, and tab-to-video screen recording. 45 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
|
|
122
|
+
- **A whole page is a file, not a wedged tab.** Chrome cannot encode a screenshot past 16384 device px on either side, and past that `Page.captureScreenshot` does not error — it never answers, and leaves the tab resized to the clip it was capturing. On a ratio-2 display that ceiling arrives at about 8192 CSS px: an ordinary long article. `take_screenshot` measures the projection before every capture and, past the cap, takes the page as vertical bands stitched losslessly into one PNG — a 140,982 CSS px page comes back as a 2780×281964 file in 18 bands, in 17 seconds, with the tab still healthy. Also per-capture `scale`, and `renderWidth`/`renderHeight` to shoot one capture at an emulated viewport and restore the tab afterwards.
|
|
122
123
|
- **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
|
|
123
124
|
- **Knows when a human is already using the tab you're driving.** An in-page activity beacon distinguishes a person's clicks/keys/scrolls from the toolkit's own dispatched input, so `claim_page` and `list_leases` can report `humanActiveMs` and warn on contention instead of silently fighting someone for the keyboard. See "Staleness: is a human already using this tab?" below.
|
|
124
125
|
- **Out-of-model secret handling.** A read that would return a credential, a JWT in `localStorage` via `evaluate_script` or an httpOnly session cookie via `list_cookies`, takes a `savePath` that writes the value to a file and keeps it out of the tool response entirely, so the secret never lands in the agent transcript. That same per-call, in-process design makes cdp-toolkit a clean substrate for credential-injection tools: a vaulted password can be typed straight into the DOM while only a status crosses back to the model, never the secret.
|
|
@@ -158,6 +159,8 @@ cdp clear_mocks --all true
|
|
|
158
159
|
# Run any tool by its MCP name; args come from --json and/or --key value flags.
|
|
159
160
|
cdp <tool> [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
|
|
160
161
|
cdp --list # list every available tool name
|
|
162
|
+
cdp --help # top-level usage
|
|
163
|
+
cdp take_screenshot --help # that tool's arguments, from its schema — touches no browser
|
|
161
164
|
|
|
162
165
|
cdp list_pages
|
|
163
166
|
cdp navigate_page --target index:0 --url https://example.com
|
|
@@ -176,11 +179,21 @@ cdp set_cookie --json '{"name":"session","value":"abc","url":"https://example.co
|
|
|
176
179
|
# Remove it again. Either url or domain is required on both write tools.
|
|
177
180
|
cdp delete_cookies --json '{"name":"session","url":"https://example.com/"}'
|
|
178
181
|
cdp take_screenshot --target url:example --fullPage true
|
|
182
|
+
# A whole page, however long: past Chrome's 16384-device-px encode limit the capture is
|
|
183
|
+
# taken as vertical bands and stitched into one lossless PNG. No flag needed — this is the
|
|
184
|
+
# default — and the result says so: {"width":2780,"height":281964,"tiled":true,"bands":18}.
|
|
185
|
+
cdp take_screenshot --target url:example --fullPage true --savePath /tmp/whole-page.png
|
|
186
|
+
# 3x output pixels for one capture (Chrome only). The page is not told anything changed.
|
|
187
|
+
cdp take_screenshot --target url:example --scale 3
|
|
188
|
+
# Shoot a responsive page at desktop width from a tab that isn't that size, then restore it.
|
|
189
|
+
cdp take_screenshot --target url:example --renderWidth 1920 --renderHeight 1080
|
|
179
190
|
cdp lighthouse_audit --url https://example.com --json '{"categories":["performance"]}'
|
|
180
191
|
```
|
|
181
192
|
|
|
182
193
|
**Argument parsing:** the first positional token is the tool name. `--json '<obj>'` merges a JSON object into the args (applied first). `--target <sel>` sets `args.target`. Repeated `--key value` pairs become `args.key`, coerced (`true`/`false` → boolean, numeric strings → number, else string); a bare `--flag` is `true`. Explicit flags override keys from `--json`. Output is `JSON.stringify(result, null, 2)` on stdout (exit 0); on any throw, `{"error":"<message>"}` goes to stderr and the process exits 1.
|
|
183
194
|
|
|
195
|
+
**`--help`/`-h`** are recognized anywhere in argv, ahead of every other flag, and are never treated as a tool argument or a tool name — as of 1.9.3, `<tool> --help` used to run the tool instead. With no tool named, `cdp --help` prints the usage above. With a tool named, `cdp <tool> --help` prints that tool's arguments (name, type, required/optional, description) read from its schema, and exits 0 having made no CDP connection, taken no lease, and written no file.
|
|
196
|
+
|
|
184
197
|
### Programmatic use
|
|
185
198
|
|
|
186
199
|
```ts
|
|
@@ -214,11 +227,48 @@ cdp --capabilities --browser firefox # see what's available and
|
|
|
214
227
|
|
|
215
228
|
Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER`, then `chrome`. For the MCP server, set `CDP_BROWSER=firefox` (or pass `--browser firefox` in its launch args) in your MCP client config; the backend is fixed for the life of that server process.
|
|
216
229
|
|
|
217
|
-
|
|
230
|
+
Firefox runs in one of **two modes**, and the difference between them is process ownership.
|
|
231
|
+
|
|
232
|
+
**LAUNCH (default): a fresh throwaway-profile Firefox, launched and killed per session.** `--browser firefox` with no `--connect`/`CDP_FIREFOX_ENDPOINT` starts a brand-new Firefox process with an empty profile — a login wall for anything that needs a real, already-authenticated session:
|
|
218
233
|
|
|
219
234
|
- **CLI** (one process per invocation): each command launches Firefox, runs exactly one tool call, and disposes the session and kills the process before exiting, win or lose. State does not carry between separate CLI invocations: there is no running Firefox left afterward for a second command to find.
|
|
220
235
|
- **MCP server** (long-lived): the first Firefox tool call launches one Firefox process and memoizes its BiDi session for the life of the server; every later Firefox call reuses it. The session is torn down on `SIGINT`/`SIGTERM`/stdin close. Multi-step Firefox workflows (navigate, then snapshot, then click) need the MCP server, not the CLI, for exactly this reason.
|
|
221
236
|
|
|
237
|
+
**ATTACH (`--connect <port|host:port|ws-url>` / `CDP_FIREFOX_ENDPOINT`): connect to a Firefox YOU already started.** Launch a Firefox with its debug port open yourself, and cdp-toolkit connects to that process's BiDi endpoint instead of spawning a throwaway one — so tools see your real, logged-in profile, cookies and all. This process never launches or kills that Firefox: dispose only ends the BiDi session (`session.end`), never the browser.
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# 1. Start YOUR Firefox with the debug port open (a separate, empty --no-remote profile
|
|
241
|
+
# is recommended so it doesn't collide with a Firefox you already have open; drop
|
|
242
|
+
# -profile/-no-remote to attach to your everyday profile instead, once nothing else
|
|
243
|
+
# is holding its BiDi session):
|
|
244
|
+
firefox --remote-debugging-port 9223 --no-remote -profile /tmp/ff-attach-profile &
|
|
245
|
+
|
|
246
|
+
# 2. CLI: --connect implies --browser firefox, so it doesn't need to be passed too
|
|
247
|
+
bun run src/cli.ts --connect 9223 take_snapshot
|
|
248
|
+
cdp --connect 127.0.0.1:9223 take_snapshot # host:port also works
|
|
249
|
+
cdp --connect ws://127.0.0.1:9223/session take_snapshot # or the full ws:// URL
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
3. MCP client config: set the env var instead of a flag (the endpoint doesn't belong in `args`):
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"mcpServers": {
|
|
257
|
+
"cdp-toolkit": {
|
|
258
|
+
"command": "npx",
|
|
259
|
+
"args": ["-y", "cdp-toolkit"],
|
|
260
|
+
"env": { "CDP_BROWSER": "firefox", "CDP_FIREFOX_ENDPOINT": "9223" }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`<endpoint>` accepts three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port` (`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. `--connect`/`CDP_FIREFOX_ENDPOINT` implies the Firefox backend on its own and errors against an explicit `--browser chrome`.
|
|
267
|
+
|
|
268
|
+
**Firefox allows only one active WebDriver BiDi session at a time.** A second `session.new` against the same endpoint fails outright while a first session is open. Disposing cleanly (the normal exit path, both CLI and MCP shutdown) sends `session.end` first, which frees the slot for the next attach. A client that dies **without** disposing — a `SIGKILL`, a crash — leaves the slot occupied: Firefox does not reap an abandoned session on its own. A colliding attach against an occupied slot now fails fast with an actionable error naming the endpoint, rather than hanging forever (fixed in 1.9.4); recover by closing the other client or restarting Firefox.
|
|
269
|
+
|
|
270
|
+
**Attaching is not relaunching.** The one thing that is genuinely impossible is handing a debug port to an *already-running* Firefox process after the fact: the `--remote-debugging-port` flag only takes effect on a process's original launch, so relaunching the `firefox` binary against a running instance hands off to it and exits silently, opening no port (verified against Firefox 153.0.3). That is a real, narrow limitation of the Firefox binary itself. It is not the same claim as "Firefox cannot be attached to" — a Firefox that was launched *with* the debug port open, whether by this toolkit or by your own hand, exposes a plain BiDi endpoint that any number of fresh clients can connect to later, which is exactly what `--connect`/`CDP_FIREFOX_ENDPOINT` does.
|
|
271
|
+
|
|
222
272
|
**Tool availability is filtered per backend**, not per call: `tools/list` (MCP) and `--list`/`--capabilities` (CLI) only ever advertise a tool the selected browser can actually run. A tool is never listed and then thrown from at call time. Under Firefox, four tool groups are absent because Firefox 153's BiDi implementation has no equivalent domain:
|
|
223
273
|
|
|
224
274
|
- `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
|
|
@@ -237,9 +287,9 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
|
|
|
237
287
|
- **Thin emulation.** Only viewport size/DPR and `userAgent` are applied. CPU throttling, media-feature emulation (e.g. `prefers-color-scheme`), and network-condition throttling are not available: Firefox 153 does not implement the underlying BiDi commands.
|
|
238
288
|
- **No tracing, heap snapshots, or Lighthouse.** See the capability list above; there is no BiDi equivalent for any of the three.
|
|
239
289
|
- **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
|
|
240
|
-
- **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
|
|
241
290
|
- **No modifier-key clicks.** `click`'s `modifiers` (Alt/Control/Meta/Shift) is a Chrome-only *parameter* on an otherwise-universal tool: a non-empty `modifiers` array throws under `--browser firefox` rather than being silently dropped. A plain click still works on both backends.
|
|
242
291
|
- **No real HTML5 drag-and-drop mode.** `drag`'s `mode:"html5"` requires capability `input.html5Drag` and is rejected with a clear error under Firefox; `mode:"mouse"` (the default) works on both.
|
|
292
|
+
- **No per-capture screenshot `scale`, and no band tiling** — two *parameters* on an otherwise-universal tool, for two different reasons. `scale` (`screenshot.scale`) is a genuine protocol gap: `browsingContext.captureScreenshot` has no scale parameter at all, and the refusal points at `emulate {deviceScaleFactor}` + a scale-1 capture instead. `tile:true` (`screenshot.tile`) is an **unmeasured** gap, not a missing primitive: BiDi does take a box clip, so bands are probably implementable, but every property tiling rests on (does a box clip render past the viewport, is `clip.y` CSS px, do fixed elements repeat per band, are seams pixel-exact) was measured on Chrome and nowhere else, so it is not advertised. Auto-tiling never fires without `screenshot.tile`, so a Firefox capture behaves exactly as it did before. **`renderWidth`/`renderHeight` (`screenshot.renderSize`) are available on both backends** — that one is not a gap.
|
|
243
293
|
|
|
244
294
|
## The tools (29 parity + 16 superset = 45)
|
|
245
295
|
|
|
@@ -268,7 +318,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
268
318
|
| `type_text` | `Input.insertText` | Appends (does not clear first); insertText, not per-key. |
|
|
269
319
|
| `press_key` | `Input.dispatchKeyEvent` | Curated named-key table + single chars; not the full Puppeteer KeyInput enum. |
|
|
270
320
|
| `upload_file` | `DOM.setFileInputFiles` | Requires a resolvable `<input type=file>` (uid or selector). |
|
|
271
|
-
| `take_screenshot` | `Page.captureScreenshot` (+
|
|
321
|
+
| `take_screenshot` | `Page.captureScreenshot` (+ `Page.getLayoutMetrics` on **every** capture) | Full-page uses `captureBeyondViewport` + a layout-metrics clip. `scale` (>0, ≤8, **Chrome-only**) multiplies output pixels for one capture: output px = `ceil(css × scale × devicePixelRatio)`, and the page is never told (`devicePixelRatio`/`innerWidth` are unmoved). `renderWidth`+`renderHeight` (**both backends**, required together) emulate a viewport for one capture and restore it after — media queries flip, so this is how you shoot a responsive page at 1920×1080 from a tab that isn't. Chrome cannot encode past **16384 device px per side** and does not refuse politely there, so past the cap the capture is taken as vertical bands and stitched losslessly into one PNG (`tile`, auto by default; `tiled`/`bands` on the result): a 140,982 CSS px page returns 2780×281964 px in 18 bands instead of hanging and wedging the tab. Banding is vertical only (an over-wide projection is refused, not split), PNG-only, and never with `returnBase64`; content the page loads only on real scroll renders blank past the first viewport. `width`/`height` are decoded from the encoded bytes and omitted when undecodable. |
|
|
272
322
|
| `start_screen_recording` *(superset)* | `Page.startScreencast` / `Page.screencastFrame` / `Page.screencastFrameAck` | **Toolkit addition; chrome-devtools-mcp has no screen-recording tool.** Opens a persistent per-target connection and spools frames to a ledger on disk; pairs with `stop_screen_recording` in the SAME process, for the same cross-process reason as `performance_start_trace`. ffmpeg is probed here so a missing encoder fails before a recording is captured. Chrome only: absent from `tools/list` under `--browser firefox` (needs `capture.screencast`, which BiDi has no primitive for). |
|
|
273
323
|
| `stop_screen_recording` *(superset)* | `Page.stopScreencast` (+ ffmpeg encode) | Assembles the spooled frames into an H.265 (`hevc_videotoolbox`, falling back to `h264_videotoolbox` → `libx265` → `libx264`) MP4 using per-frame durations from the capture ledger, coalesced onto ffmpeg's 40ms concat-demuxer grid. Returns `{path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}` (`frameCount` captured vs. `encodedFrames` in the video). On an ffmpeg failure the spool is kept and the exact re-run command is named in the error. |
|
|
274
324
|
| `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
|