cdp-toolkit 1.9.1 → 1.9.5

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 CHANGED
@@ -5,6 +5,440 @@ 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.5] - 2026-08-11
9
+
10
+ **Documentation and stale code-comment clarifications; NO behavior changes since 1.9.4.** Two
11
+ updates. First, the Firefox `fullPage` screenshot gap that 1.9.2's docs hedged as "unmeasured" is
12
+ now resolved: measured against a real Firefox 153.0.3 through the shipped CLI in attach mode, a
13
+ 20,000px-tall page captured with `take_screenshot --fullPage true` came back as a single
14
+ 1366×20000 PNG at scale 1 — verified complete top-to-bottom (a marker at y=0, a marker at y=20000,
15
+ an unbroken ruler through the middle, no truncation). Firefox has no equivalent of Chrome's
16
+ 16384-device-px encode cap, so `browsingContext.captureScreenshot(origin:"document")` returns the
17
+ whole long page in one BiDi call and never needs the band-tiling Chrome's cap requires — tiling
18
+ exists solely to route around that cap, and Firefox doesn't have one, so `screenshot.tile` is a
19
+ non-issue for Firefox correctness rather than a gap. `screenshot.scale` remains a genuine,
20
+ unrelated Firefox gap: BiDi's `captureScreenshot` has no scale parameter at all, so Firefox
21
+ captures stay 1x only. That same measured result also replaces three stale "unmeasured"/"probably
22
+ implementable" comments in `src/bidi/driver.ts` describing `screenshot.tile` — comments only, no
23
+ logic changed, since `screenshot.tile` was already correctly left undeclared and `tile:true`
24
+ already correctly refused on Firefox; only the *reasoning* in the comments was stale. Second, this
25
+ release also gives a changelog entry to doc-only additions that landed on top of 1.9.4 without one:
26
+ Linux and remote-over-SSH attach guidance, and a `claude mcp add` one-liner, both in README.md's
27
+ setup instructions.
28
+
29
+ ### Added
30
+
31
+ - README.md: Linux and remote-over-SSH `--connect`/`CDP_FIREFOX_ENDPOINT`
32
+ attach guidance, and a `claude mcp add cdp-toolkit --scope user -- bun run
33
+ "$(pwd)/src/mcp.ts"` one-liner in the quick-start block.
34
+
35
+ ### Changed
36
+
37
+ - README.md's "Honest capability gaps" list and CONTRACT.md's `screenshot.ts`
38
+ module row: the Firefox `tile:true`/`screenshot.tile` hedge ("unmeasured
39
+ gap... probably implementable") is replaced with the measured result above.
40
+ `scale`'s status is unchanged and still called out as a genuine gap.
41
+ - `src/bidi/driver.ts`: three comment blocks (the `BIDI_CAPABILITIES` header
42
+ comment, the `capture()` method's `tile:true` refusal comment, and the
43
+ per-capability doc-comment list) rewritten from "unmeasured"/"probably
44
+ implementable" to the measured result above. No capability declarations, no
45
+ refusal logic, and no error-message text changed — comments only.
46
+
47
+ ### Notes
48
+
49
+ - Tool count unchanged at **45**; no tool added, renamed, or removed.
50
+ - Version bump only: `package.json` and `src/mcp.ts`'s `VERSION` constant move
51
+ 1.9.4 → 1.9.5. No runtime behavior changed anywhere in this release.
52
+
53
+ ## [1.9.4] - 2026-08-11
54
+
55
+ Firefox gains a second mode: **attach**. Until now `--browser firefox` always spawned a
56
+ throwaway-profile Firefox — a login wall for anything needing a real, already-authenticated
57
+ session. `--connect <port|host:port|ws-url>` (or env `CDP_FIREFOX_ENDPOINT`) instead connects to
58
+ a Firefox **you** already started with `--remote-debugging-port`, so tools see your real logged-in
59
+ profile. This process never launches or kills that browser: dispose only ends the BiDi session
60
+ (`session.end`), never the process. Also fixes a doc-nit (README/CONTRACT overclaimed "Firefox
61
+ cannot be attached to at all" — the real, narrower limitation is that you cannot relaunch the
62
+ `firefox` binary with the debug flag against an already-running instance) and a real hang (a
63
+ second attach against a Firefox that already has an active BiDi session used to hang forever;
64
+ it now fails fast with an actionable error).
65
+
66
+ ### Added
67
+
68
+ - **`--connect <endpoint>` / `CDP_FIREFOX_ENDPOINT`** — Firefox attach mode. `<endpoint>` accepts
69
+ three spellings, all normalized to a ws URL: a bare port (`9223`), `host:port`
70
+ (`127.0.0.1:9223`), or a full `ws://`/`wss://` URL. Flag beats env, mirroring
71
+ `--browser`/`CDP_BROWSER` precedence. An endpoint **implies** the Firefox backend on its own —
72
+ there is nothing else it could mean — and errors against an explicit `--browser chrome` /
73
+ `CDP_BROWSER=chrome` rather than silently picking one side.
74
+ - `src/backend.ts`: `normalizeBidiEndpoint`, `resolveFirefoxEndpoint`, `stripConnectFlag`,
75
+ `resolveBackend` (the single backend-selection entry point `cli.ts`/`mcp.ts` now both call —
76
+ returns `{kind, endpoint?}`), and `startFirefoxSession`/`getOrCreateFirefoxSession` now accept
77
+ an `{ endpoint }` to attach instead of launch.
78
+ - `src/bidi/client.ts`: `connectBidiSessionUrl(wsUrl, opts?)` — connects directly to a ws URL,
79
+ no port-to-URL construction. `connectBidiSession(port, opts)` is now a thin wrapper over it
80
+ (kept exported, unchanged behavior).
81
+ - `src/bidi/driver.ts`: `createFirefoxDriverForEndpoint(wsUrl, opts?)` — the attach-mode driver
82
+ constructor. The driver's connection identity moved from a bare `port: number` to a ws
83
+ `endpoint: string`, so an attach endpoint can carry a different host/port/path than a launched
84
+ Firefox's `ws://127.0.0.1:<port>/session`.
85
+ - `firefox:attach:smoke` script (`test/firefox-attach-smoke.ts`) — drives the real attach path
86
+ against an out-of-band-spawned Firefox: attach, read a page, dispose (Firefox stays alive), a
87
+ second attach cycle to the same Firefox (proves `session.end` freed the slot), and confirms
88
+ `resolveBackend` auto-implies `firefox` from `--connect` alone.
89
+ - `test/backend-attach.test.ts` — pure-unit coverage of `normalizeBidiEndpoint`,
90
+ `resolveFirefoxEndpoint`, `stripConnectFlag`, and `resolveBackend`, including the
91
+ `--browser chrome` + `--connect` conflict.
92
+
93
+ ### Fixed
94
+
95
+ - **A second attach against a Firefox with an already-active BiDi session used to hang forever.**
96
+ Firefox allows exactly one active WebDriver BiDi session at a time; a colliding `session.new`
97
+ fails with the raw wire message `Maximum number of active sessions`, which reached no caller
98
+ as an error — it hung. `getConnection` (`src/bidi/driver.ts`) now catches that specific message
99
+ and raises a clear, actionable error naming the endpoint and the fix (close the other client,
100
+ or restart Firefox; a client killed without a clean dispose, e.g. `SIGKILL`, can leave a
101
+ session Firefox does not reap on its own).
102
+ - **Attach-mode dispose was undefined behavior before this release** (the mode did not exist):
103
+ `BidiBrowserDriver.dispose()` now sends `session.end` before closing the socket, which is what
104
+ actually frees Firefox's single session slot for the next attach — closing the socket alone
105
+ does not. Launch-mode dispose is unaffected: the spawned process is still killed after the
106
+ driver disposes, same as before.
107
+ - **`src/bidi/launch.ts` doc-nit** (the issue's explicit ask, code unchanged): the header/footer
108
+ said Firefox "cannot be attached to at all." That overstated a real, narrower fact — you cannot
109
+ *relaunch* the `firefox` binary with `--remote-debugging-port` against an already-running
110
+ instance and have it take effect (the relaunch hands off and exits, opening no port; still true,
111
+ still verified against Firefox 153.0.3). Attaching to a port a Firefox process was *originally*
112
+ launched with — by this toolkit or by hand — always worked and is now this release's first-class
113
+ path. README.md and CONTRACT.md carried the same overclaim and are corrected too.
114
+
115
+ ### Notes
116
+
117
+ - Tool count unchanged at **45**; no tool added, renamed, or removed. Both Firefox modes expose
118
+ the same tool set as before (see README's "Tool availability is filtered per backend").
119
+ - The CLI is one process per invocation either way: an attach-mode CLI call still launches no
120
+ process and kills none, it just skips `launchFirefox` entirely. Multi-step attach workflows
121
+ still want the MCP server, same reasoning as launch mode.
122
+
123
+ ## [1.9.3] - 2026-08-10
124
+
125
+ `cli.ts` had **no `--help` handling at all.** `--help` fell through the generic
126
+ `--key value` branch into `args.help = true`, and the named tool ran with that
127
+ as an inert extra property; `-h` doesn't even start with `--`, so it fell all
128
+ the way to the positional-token branch and was silently dropped once a tool
129
+ name was already set. Either way, **the tool ran.** Measured live this
130
+ session: `cdp take_screenshot --help` executed a real capture of whatever tab
131
+ happened to be active and wrote a 1.2 MB PNG to `/tmp/cdp-toolkit` — a "tell
132
+ me, don't do it" discovery gesture that silently photographed what the user
133
+ was looking at. Reproduced again here with the safe, non-capturing
134
+ `list_pages --help` against a `CDP_BASE` pointed at a closed port: pre-fix it
135
+ printed `{"error":"Unable to connect. Is the computer able to access the
136
+ url?"}` and exited 1 — proof the tool dispatched and tried to reach the
137
+ browser instead of printing help. Post-fix, the identical invocation against
138
+ the same dead port prints usage and exits 0, and a directory listing of the
139
+ artifact dir taken before and after is byte-identical: no file, no browser
140
+ touch.
141
+
142
+ ### Fixed
143
+
144
+ - **`--help`/`-h` now short-circuit before any tool dispatch, recognised
145
+ anywhere in argv** (not just as the first token), and are never treated as
146
+ a tool argument or a tool name. With no tool named, `cdp --help`/`-h` prints
147
+ the existing top-level usage banner (now also documenting the flag itself).
148
+ With a tool named, `cdp <tool> --help` prints that tool's argument list —
149
+ name, type, required/optional, description — read directly from
150
+ `MANIFEST`'s `inputSchema` (`src/manifest.ts`), the exact JSON Schema the
151
+ MCP server advertises via `tools/list`, so there is no second
152
+ hand-maintained argument list to drift from it.
153
+ - An unknown tool name alongside `--help` (`cdp nope --help`) is reported as
154
+ `{"error":"unknown tool 'nope'. ..."}` and exits 1 — the same message the
155
+ CLI already gives for `cdp nope` without `--help` — rather than silently
156
+ falling back to top-level usage as though the name had been accepted.
157
+ - **Decision, stated explicitly:** an unknown `--flag` passed to a *known*
158
+ tool still passes through as a tool argument, unchanged from pre-1.9.3
159
+ behavior. Every `inputSchema` already declares `additionalProperties:
160
+ false`, but neither `cli.ts` nor `mcp.ts` has ever enforced that — each tool
161
+ validates its own args at runtime, extra keys are typically just ignored,
162
+ and an existing caller may already depend on that leniency for something
163
+ harmless. Rejecting unknown flags here would diverge the CLI from the MCP
164
+ server (whose validation this release does not touch) to fix a problem this
165
+ bug did not create. `--help`/`-h` are instead recognised by name,
166
+ specifically, ahead of generic flag parsing — narrow enough that it needs
167
+ none of that broader, riskier call.
168
+
169
+ ### Added
170
+
171
+ - `test/cli-help.test.ts` (6 tests): spawns the real CLI as a subprocess with
172
+ `CDP_BASE` pointed at a closed port, so any invocation that still reaches
173
+ tool dispatch fails loudly with a connection error instead of silently
174
+ succeeding. Watched fail against the pre-fix code first — 5 of 6 red on
175
+ exit code 1 instead of 0 — before confirming green post-fix.
176
+
177
+ ### Notes
178
+
179
+ - Tool count unchanged at **45**; no tool added, renamed, or removed.
180
+ `take_screenshot` is not among the files this release touches.
181
+ - 748 pass / 0 fail across 22 files, up from 742/21. `bun run typecheck` clean.
182
+
183
+ ## [1.9.2] - 2026-08-10
184
+
185
+ `take_screenshot` grows four arguments — `scale`, `renderWidth`/`renderHeight`,
186
+ `tile` — and stops being able to hang the tab it is capturing. The constraint
187
+ worth learning from this release: **Chrome cannot encode a screenshot larger
188
+ than 16384 device px on either side, and past that it does not refuse politely
189
+ — `Page.captureScreenshot` never answers, and because `captureBeyondViewport`
190
+ resizes the renderer to the clip for the length of the capture, a call that
191
+ never returns never puts it back.** Output px are `ceil(css × scale ×
192
+ devicePixelRatio)`, so on an ordinary ratio-2 display that ceiling arrives at
193
+ about 8192 CSS px — a long article, a chat log, a docs page. A plain no-flag
194
+ `take_screenshot` of a 9000 CSS px page timed out (probed with an 8 s command
195
+ timeout) and left the tab wedged at 1390×9000, with every later capture on it
196
+ timing out too. It now returns a 2780×18000 px file in 1 second, as two
197
+ stitched bands. Tool count
198
+ stays 45 — no new tool, `take_screenshot` grew arguments. Five commits landed
199
+ it in sequence, on top of two measurement passes that drove a live
200
+ Chrome 151.0.7922.76 and wrote no code — the encode cap and the band-artifact
201
+ hunt. Every number below comes off a measurement, and where a measurement
202
+ contradicted the brief that ordered the work, the measurement is what is
203
+ written down.
204
+
205
+ ### Added
206
+
207
+ - **`scale` — a per-capture output multiplier**, a finite number greater than
208
+ 0 and at most 8 (default 1), Chrome only (capability `screenshot.scale`).
209
+ Measured exact on a live tab: a 2780×2128 default capture at `scale 3` is
210
+ 8340×6384, a `--fullPage` at `scale 2` is 5560×13000, a 400×250 CSS element
211
+ at `scale 4` is 3200×2000 — `sips` agrees with the reported size on 9 of 9
212
+ artifacts, PNG and JPEG alike. It re-renders, it does not upsample, and it
213
+ changes nothing the page can see: `devicePixelRatio` read 2 and
214
+ `innerWidth`/`innerHeight` read 1390×1064 both before and after a `scale 3`
215
+ capture. Firefox refuses it rather than silently returning a 1x image the
216
+ caller could not tell apart from the one they wanted —
217
+ `browsingContext.captureScreenshot` has no scale parameter, and the refusal
218
+ points at `emulate {deviceScaleFactor}` instead.
219
+
220
+ - **`renderWidth`/`renderHeight` — emulate a viewport for exactly one capture,
221
+ then put the previous one back.** Required together, integers in
222
+ `[1, 16384]` CSS px, and declared by **both** backends (capability
223
+ `screenshot.renderSize`) — unlike `scale`, this is not a Chrome-only
224
+ argument. A 1390×1064 tab captured at 1920×1080 yields 3840×2160 device px
225
+ and reads back at 1390×1064 afterwards; captured at 420×900 it is
226
+ **byte-identical** (sha256 `93ffca74…`) to the same page genuinely emulated
227
+ at 420×900, media query flipped and all. That is the difference from `scale`
228
+ in one sentence: `scale` re-renders the same layout at more pixels,
229
+ `renderWidth`/`renderHeight` change what the page *believes* for one
230
+ capture. The restore holds on the success path, on the throwing path, and
231
+ over a caller's prior `emulate` (a tab emulated at 777×555 is 777×555 again
232
+ afterwards, not the real device). Two honest limits ride in the result
233
+ rather than in a comment: the restore can only put back an override **this
234
+ toolkit** applied — one set by the DevTools UI, another client, or an older
235
+ build leaves no record and is reset to the real device — and a capture that
236
+ TIMES OUT cannot be restored at all, because the `finally` runs while
237
+ Chrome's own command is still executing and re-resizes the page behind it.
238
+ `renderRestored: false` (plus `renderRestoreError`) is what the caller gets
239
+ when the tab is still emulated and they must fix it themselves.
240
+
241
+ - **`tile` — vertical band capture, stitched losslessly, and AUTO by
242
+ default.** Three-valued: omitted means one shot when the projection fits and
243
+ bands when it does not; `false` never bands and refuses instead (the exact
244
+ pre-1.9.2 behaviour); `true` always bands, even when one shot would have
245
+ fitted. Only `true` costs a capability (`screenshot.tile`, Chrome only).
246
+ The headline, a plain call with no flags on a 140,982 CSS px page:
247
+ **2780×281964 px, 18 bands, 10,818,058 bytes, 17–18 s**, tab healthy
248
+ afterwards (`[innerWidth, innerHeight, devicePixelRatio]` = `[1390, 1064,
249
+ 2]`, `scrollY` 0) and two runs byte-identical. 281964 is 140982 × 2 exactly:
250
+ the whole document, nothing dropped. A banded capture is PNG-only and cannot
251
+ also `returnBase64`; both combinations are refused in 21–31 ms measured,
252
+ before a single band is captured, with a message naming the band count and
253
+ the region that forced the decision. Tiling is **vertical only** — a projected WIDTH
254
+ past the cap is refused (naming the largest scale, or renderWidth, that
255
+ would fit), not split into a grid, because PNG scanlines are the unit that
256
+ can be concatenated without decoding the image whole.
257
+
258
+ - **New result fields.** `scale` is always present. `width`/`height` are
259
+ **decoded from the encoded bytes** (PNG IHDR, JPEG SOF marker walk) rather
260
+ than projected, and are **omitted entirely** when the bytes cannot be
261
+ decoded — absent beats invented. `renderSize`, `renderRestored` and
262
+ `renderRestoreError` accompany a render-size capture; `tiled: true` and
263
+ `bands: <n>` are present only on a banded one, so a caller always knows when
264
+ an image was assembled rather than captured.
265
+
266
+ - **`src/png.ts`, a streaming lossless PNG band stitcher**, zero runtime
267
+ dependencies (`node:zlib` is a builtin). Peak RSS is **135.3 MB while
268
+ writing a 1.42 GB image** (368,640 rows) — 9× the raw pixels of a 157 MB one
269
+ for 11.7% more memory, because peak tracks band size, not image size (Bun's
270
+ own floor is 23.6 MB of that).
271
+ Its output is byte-identical under ffmpeg's raw decode to the source bands
272
+ concatenated, at every one of PNG's five row filters, and a 2-band stitch of
273
+ a page that fitted decodes to the identical sha256 as the single shot of the
274
+ same page. It carries `gAMA`/`cHRM`/`sRGB`/`iCCP`/`pHYs` from the first band
275
+ and throws on a mismatch: every Chrome capture measured carries an embedded
276
+ ICC profile, so a stitcher that dropped ancillary chunks — the obvious
277
+ default — would silently recolour every image it touched.
278
+
279
+ ### Changed
280
+
281
+ - **Every capture now costs one `Page.getLayoutMetrics` round trip, including
282
+ the plain default one.** That is the change that closed the hazard. The
283
+ plain path was the one path that built no clip, so the encode guard never
284
+ ran on it, while `captureBeyondViewport: true` is hardcoded for every
285
+ capture — meaning an ordinary long page, hit with an ordinary flagless
286
+ screenshot, was already capturing beyond the viewport with nothing standing
287
+ between it and the hang. Nothing else about that path moved: it still sends
288
+ **no `clip` key at all**, and every step of this release A/B'd the common
289
+ path against its own parent commit's binaries on the same live tab —
290
+ default, `fullPage`, `scale 2`, `jpeg`, element-clip and `tile:false` all
291
+ came back byte-identical.
292
+
293
+ - **`savePath` outside the artifact dir now works.** The destination directory
294
+ is created from the output path's own dirname rather than always
295
+ `CDP_ARTIFACT_DIR`; previously a `savePath` in a non-existent directory
296
+ failed `ENOENT` while `CDP_ARTIFACT_DIR` was created for nobody.
297
+
298
+ - **The default filename's stamp is the capture's START**, not its end: a
299
+ banded capture writes as bands arrive and has no finished image to name a
300
+ file after, and one naming path is easier to trust than two.
301
+
302
+ - **A new state-file type**, `${CDP_ARTIFACT_DIR}/viewport-<scheme>-<targetId>.json`,
303
+ a sibling of the lease and origin files. A render-size capture must restore
304
+ the caller's *previous* override, and a `PageDriver` lives for exactly one
305
+ call (under the CLI, one process), so the record has to outlive the
306
+ connection the same way a lease does — measured: an `emulate` override set
307
+ in one process is still in force when read from another.
308
+
309
+ ### Fixed
310
+
311
+ - **An element-clipped `take_screenshot` returned the wrong region of the page
312
+ whenever the element was not already on screen.** `--selector` and `--uid`
313
+ both, at every page height tried. The mechanism is a frame mismatch worth
314
+ learning on its own: `DOM.getBoxModel` answers in the **viewport** frame (its
315
+ quad matched `getBoundingClientRect()` to the pixel every time), but
316
+ `captureBeyondViewport: true` — hardcoded on every capture here — makes
317
+ Chrome read `clip.x`/`clip.y` as **document** coordinates. The driver scrolled
318
+ the element into view with `DOM.scrollIntoViewIfNeeded` and then handed
319
+ Chrome the element's now-small *on-screen* y as if it were its position in
320
+ the document, so the capture landed within one viewport height of the top of
321
+ the page no matter how far down the element really was. Measured on
322
+ Chrome 151.0.7922.76, a 10,000 CSS px page with the target at y=5000: the
323
+ scroll settled at `scrollY` 4593 with `rectTop` 407, and the returned
324
+ 2780×500 PNG was `document[407..657]` — a flat slab of page background with
325
+ no part of the element in it. Identical failure at the two sizes this bug was
326
+ first reported at, 16,578 px (`scrollY` 14593) and 140,982 px (`scrollY`
327
+ 69593). Starting the same call from `scrollY` 4950 instead — element already
328
+ visible at `rectTop` 50, so the driver's own scroll is a no-op — returned
329
+ `document[50..300]`: the document's top banner for its first 100 CSS px, then
330
+ background, boundary on the predicted pixel. **The fix is the frame
331
+ conversion**: the page's scroll offset is added to the box-model quad, in CSS
332
+ px (`cssVisualViewport.pageX`/`pageY`), read **after** the scroll settles and
333
+ off the same single `Page.getLayoutMetrics` every capture already pays for —
334
+ reading it before the scroll would correct by 0 on a freshly navigated tab
335
+ and change nothing. Those same three page heights now return the element
336
+ itself, and the three starting scroll positions 0 / 4950 / 9000 return one
337
+ identical sha256 `54b96705…` instead of two different wrong images. `scale`
338
+ (5560×1000 at `scale 2`), `renderWidth`/`renderHeight` (1600×500 at 800×600,
339
+ `renderRestored: true`), `tile` and the `uid` path all ride the corrected
340
+ rect unchanged, and the short-page control — element inside the initial
341
+ viewport, no scroll needed, offset 0 — is byte-for-byte what it was.
342
+ **Two honest notes.** First, the units are a live trap: on a scrolled page
343
+ `visualViewport.pageX`/`pageY` come back in **CSS** px (700/4593) while
344
+ `clientWidth`/`clientHeight` on that very same rect are **device** px
345
+ (2780×2128), and only `layoutViewport` reports the offset in device px
346
+ (1400/9186) — so "no `css` prefix means device px" is false for exactly the
347
+ fields this fix reads, and a plausible implementation that divided them by
348
+ the pixel ratio would have halved the correction. Second, this release's own
349
+ regression check could not have caught it: the element-clip A/B recorded
350
+ under *Changed* above compared each commit against its parent's binaries and
351
+ came back byte-identical **because both sides were wrong the same way** — a
352
+ same-vs-same check is blind to a bug older than both of its arms.
353
+ `DOM.scrollIntoViewIfNeeded` was kept rather than dropped, and that was
354
+ measured too, not assumed: with `captureBeyondViewport` nothing has to be on
355
+ screen to be captured, but on a fixture whose target fills itself from an
356
+ `IntersectionObserver`, the capture with the scroll reads "LAZY CONTENT
357
+ LOADED" and the same capture without it returns the unloaded placeholder in
358
+ the correct region — the same caveat the tiling notes below record, from the
359
+ other side. WebDriver BiDi never had this defect: it passes
360
+ `clip: {type: "element"}` and does no coordinate arithmetic of its own.
361
+
362
+ - **`emulate {clearOverrides: true}` reported `"cleared": true` while clearing
363
+ nothing.** Reproduced across three separate CLI processes: process A set
364
+ 802×601, process B cleared and reported success, process C read back
365
+ `[802, 601]`. `Emulation.clearDeviceMetricsOverride` only reverts to the real
366
+ device when the clearing connection is also the connection that *set* the
367
+ override — and under this toolkit's per-call connection lifetime it
368
+ essentially never is, so this was the common case, not an edge case. The
369
+ driver now reads the tab's currently effective size, re-asserts that exact
370
+ size on its own connection (a value the caller cannot observe changing,
371
+ whose only job is to take ownership), and then clears; the same trace now
372
+ reads back `[1390, 1064]`, the real device. If any step of that sequence
373
+ throws, the call throws too rather than returning a false success, and the
374
+ viewport record is deliberately left in place so a later capture does not
375
+ read "no record" as "nothing to restore".
376
+
377
+ ### Measured, not assumed — the numbers this release ships on
378
+
379
+ - **16384 device px per side, both axes, clipped and unclipped alike.**
380
+ 16384×1600 encodes in 266 ms; 16400×1600 — sixteen pixels more — never
381
+ answers (60 s probe cap). With no `clip` key at all: 2560×16384 succeeds in
382
+ 217 ms, 2560×24000 hangs. **It is not an area limit:** 8192×8192 (67,108,864
383
+ px) succeeds in 1.3 s while 16400×1600 (26,240,000 px, 2.6× less) hangs. The
384
+ cap is also in DEVICE px, so a CSS clip of 8200×800 at `deviceScaleFactor: 2`
385
+ hangs identically to asking for 16400×1600 directly.
386
+ - **Band tiling is visually clean, and this was falsification-tested rather
387
+ than assumed.** A `position: fixed` header present in the top band appears
388
+ **zero** times in a band 8000 px down; a `position: sticky` header appears
389
+ only in the band containing its true document position. Seams are row-exact
390
+ — band A ends on ROW 039, band B begins on ROW 040, no duplicate, no gap.
391
+ Clipped bands are byte-identical across three different real scroll
392
+ positions, so a stitcher never has to manage scroll. `clip.y` is always CSS
393
+ px and `scale` only multiplies resolution, so the two compose without
394
+ rescaling offsets. Content-verified end to end on the 140,982 px page: all
395
+ **71** label bands land at exactly their expected rows across 9 bands and 8
396
+ seams.
397
+ - **The honest caveat.** A page that loads content only on real scroll —
398
+ lazy images, virtualized lists, infinite scroll — renders **blank** in bands
399
+ the real viewport never reached. Measured directly: a page whose
400
+ `IntersectionObserver` had loaded 9 of 300 rows still reported 9 after a
401
+ deep-band capture, and the deep band was uniform `#222`.
402
+ `captureBeyondViewport` does not trigger an `IntersectionObserver`. This is
403
+ a property of the source page, not a bug in the stitch, and it is not
404
+ fixable here — which is exactly why an auto-tiled capture reports `tiled`,
405
+ so a caller knows to reason about it (pre-scroll the page, or disable
406
+ virtualization, before capturing).
407
+ - **Two corrections to earlier internal notes, kept rather than quietly
408
+ dropped.** (1) At 140,982 CSS px the *old* code did not hang: it failed in
409
+ about a second with `Page.captureScreenshot: Page is too large.` and left
410
+ the tab healthy. The hang-and-wedge lives at *modest* overshoot — the 9000
411
+ CSS px page above. Both are failures, but "it hangs at 140,982" is not what
412
+ this Chrome build does. (2) A wedged tab is **not** recovered by
413
+ `emulate --clearOverrides`: measured in order, clearing reports success and
414
+ the tab stays 1390×9000, an explicit `emulate --width/--height` recovers it,
415
+ and clearing again puts it *back*. Only a reload actually clears it. The
416
+ renderer's real viewport is what the abandoned capture resized, so
417
+ "clear to the real device" lands on the wrong size.
418
+
419
+ ### Notes
420
+
421
+ - Tool count is unchanged at **45**. `take_screenshot` grew four arguments;
422
+ no tool was added, renamed, or removed.
423
+ - **Firefox/BiDi does not declare `screenshot.scale` or `screenshot.tile`**,
424
+ and for two different reasons stated plainly rather than dressed up as
425
+ design: `browsingContext.captureScreenshot` genuinely has no scale
426
+ parameter, while band tiling is *probably* implementable there
427
+ (`clip: {type: "box"}` exists) but every property it rests on — does a box
428
+ clip render past the viewport, is `clip.y` CSS px, do fixed elements repeat
429
+ per band, are seams pixel-exact — was measured on Chrome and nowhere else,
430
+ because no Firefox was driven for it. `screenshot.renderSize` **is**
431
+ declared by both. Auto-tiling never fires on a backend without
432
+ `screenshot.tile`, so Firefox behaviour is unchanged.
433
+ - New tests: **117**, in `test/screenshot-scale.test.ts` (81) and
434
+ `test/png.test.ts` (36) — 736 pass / 0 fail across 21 files, up from 619.
435
+ Both new suites were mutation-checked rather than merely green: 13 deliberate
436
+ mutants of `src/png.ts` and 6 of the tiling arithmetic were each killed,
437
+ and the three mutants that survived are documented in the source as provably
438
+ equivalent rather than silently "fixed" later.
439
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
440
+ needed no re-render for this release.
441
+
8
442
  ## [1.9.1] - 2026-08-10
9
443
 
10
444
  `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`).** Firefox declares neither `screenshot.scale` (BiDi's `captureScreenshot` has no scale parameter, so Firefox captures are always 1x) nor `screenshot.tile` — but the tile gap is moot, not a limitation: Firefox has no Chrome-style 16384-device-px encode cap to band around, so `browsingContext.captureScreenshot(origin:"document")` returns an arbitrarily long `fullPage` capture in one shot. Measured against Firefox 153.0.3: a 20,000px-tall page came back as a single 1366×20000 PNG at scale 1, verified complete top-to-bottom (no truncation). 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`. |