cdp-toolkit 1.9.0 → 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 CHANGED
@@ -5,6 +5,512 @@ 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
+
397
+ ## [1.9.1] - 2026-08-10
398
+
399
+ `list_network_requests`, `get_network_request`, and `list_console_messages` now
400
+ observe MV3 background service workers, not just pages: the outbound fetch a
401
+ worker makes to a real backend is a first-class capture target, not something
402
+ you have to fake with an `evaluate_script` monkeypatch. Tool count stays 45 —
403
+ the three tools grow a `worker:` arm, no new tool. One seat (N1) landed it,
404
+ every protocol claim below probed against a live Chrome 151 before being built
405
+ on.
406
+
407
+ ### Added
408
+
409
+ - **The three console/network reader tools accept `target: "worker:<substring>"`
410
+ or a bare worker target id**, alongside the page selectors they already took.
411
+ Chrome only (capability `worker.targets`) — Firefox refuses the arm from its
412
+ real capability set before launching a browser that could never have served
413
+ the call. The allowlist that grants the arm, `WORKER_CAPABLE_TOOLS` in
414
+ `src/workers.ts`, now names four tools (`evaluate_script` plus these three);
415
+ it is the single source of truth for both the refusal messages and
416
+ `test/manifest-grammar-drift.test.ts`'s exclusion property, so a fifth tool
417
+ cannot silently advertise `worker:` without a deliberate edit here.
418
+
419
+ - **A worker capture LISTENS instead of reloading.** Measured on Chrome
420
+ 151.0.7922.109: `Network.enable` and `Runtime.consoleAPICalled` work
421
+ identically on a direct worker session to how they work on a page — all four
422
+ events the recorder persists (`requestWillBeSent`, `responseReceived`,
423
+ `loadingFinished`, `loadingFailed`) arrive, and `Network.getResponseBody`
424
+ serves the body — but `Page.enable`/`Page.reload` **do not exist** on a
425
+ worker session (`-32601 'Page.enable' wasn't found`). A page capture is
426
+ reload-driven; a worker capture cannot be, so it opens the worker's own CDP
427
+ session and listens for `durationMs` instead. `captureWindow()`'s branch is
428
+ keyed off the resolved target's type, not the selector's shape, so a bare
429
+ worker id takes the listen path too.
430
+
431
+ - **Recording a worker keeps it alive for the length of the capture — a
432
+ deliberate side effect, not a bug.** A held CDP session suppresses MV3 idle
433
+ eviction; measured in both directions with the same polling loop so
434
+ "stayed alive" isn't an artifact of how the poll works: with the recorder's
435
+ session held, the worker is still present in `/json/list` and still emits a
436
+ fresh `Network.requestWillBeSent` for a triggered fetch at t+60s; once the
437
+ session detaches, the same worker idle-evicts within ~30s. Documented in the
438
+ three tools' manifest descriptions and asserted in both directions by the
439
+ live smoke, not left implicit.
440
+
441
+ - **`wake` (default `true` on a capture) is REFUSED on a read-only call and on
442
+ a bare target id, rather than accepted and ignored.** Measured: a worker
443
+ that idle-evicts and restarts comes back under a brand-new target id (probed
444
+ restart: `C43F0A18...` → `1F7D3C59...` for the same extension). The recorder
445
+ keys its buffer file by target id, so waking a worker on a *read* would hand
446
+ back a live worker with an empty buffer — "0 requests" for a capture that
447
+ really did record some, a zero shaped like an answer. A wake therefore only
448
+ makes sense when a capture is being started, and only from a
449
+ `worker:<substring>` selector, since an evicted worker's old id no longer
450
+ exists to name. The refusal messages (`workerWakeMisuseMessage`,
451
+ `workerBufferReadMissMessage` in `src/workers.ts`) teach the id-re-mint fact
452
+ rather than just rejecting the argument.
453
+
454
+ - **Known limitation, stated rather than silently absent: a worker woken BY a
455
+ capture has already run its top-level code by the time the recorder
456
+ attaches**, so a fetch the worker makes at startup can be missed. Trigger
457
+ the request you want to observe (a message, an alarm) after the capture
458
+ starts rather than relying on the worker's own boot-time fetch. Fixing this
459
+ needs `Target.setAutoAttach{waitForDebuggerOnStart}` on a held browser
460
+ session, a different feature; the limitation is documented in
461
+ `recorder.ts`'s header and in the three manifest descriptions instead.
462
+
463
+ - **`get_console_message` reads the same worker-keyed buffer (so its miss
464
+ message matches what it reads) but is deliberately NOT in
465
+ `WORKER_CAPABLE_TOOLS`** — it already resolved worker targets through the
466
+ 1.9.0 arm, routing it through the read-path resolver only sharpens its miss
467
+ message; advertising it as worker-capable was out of scope, and the drift
468
+ test pins the advertised list at four.
469
+
470
+ ### Measured, not assumed — the protocol facts this release ships on
471
+
472
+ - **`Page.enable`/`Page.reload` are `-32601` on a worker session** — the
473
+ single measurement that shapes the implementation: a worker window cannot
474
+ be reload-driven, so it listens.
475
+ - **A held session suppresses MV3 idle eviction; detach restores it** —
476
+ confirmed in both directions with the same polling loop, including a
477
+ post-hold evaluate and a fresh `Network.requestWillBeSent` at t+60s, so
478
+ "alive" means genuinely live, not a stale listing entry.
479
+ - **A worker's target id is re-minted on every restart, never stable** — this
480
+ is why `wake` is refused on reads and on bare ids rather than silently doing
481
+ the wrong thing.
482
+ - **Two things a caller might reach for instead don't work, for ordinary
483
+ JavaScript reasons, not a service-worker-specific one.** A value bound at
484
+ module top level is not visible from `evaluate_script`'s global scope
485
+ (plain lexical scoping, identical in a page); assigning `self.fetch`
486
+ afterward cannot rebind a `fetch` reference the module already captured
487
+ (closure semantics, also identical in a page). `Network`-domain recording
488
+ on the worker's own session observes what the code ACTUALLY SENT and needs
489
+ neither. (An earlier field report attributing this to service-worker eval
490
+ semantics was a misdiagnosis of an outdated loaded extension build, not a
491
+ real capability gap — see the stale-build tip below.)
492
+
493
+ ### Notes
494
+
495
+ - Tool count is unchanged at **45** — this release grew the `target` grammar
496
+ on three existing tools, not the tool list.
497
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
498
+ needed no re-render for this release.
499
+ - Before deep-debugging an extension that "the toolkit can't see": verify the
500
+ loaded build is current with
501
+ `evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`.
502
+ An outdated unpacked build left over from a prior load mimics every symptom
503
+ in this release's "measured facts" section above.
504
+ - New tests: 20 in `test/worker-capture.test.ts`, 3 more in
505
+ `test/manifest-grammar-drift.test.ts` (allowlist rescoped from an
506
+ evaluate_script-only exclusion to the four-tool `WORKER_CAPABLE_TOOLS`
507
+ allowlist), and new live scenarios in `test/extension-smoke.ts` covering the
508
+ worker fetch/console capture, the eviction pair in both directions, and the
509
+ capture-path wake (proven by the fixture's own start counter, `starts: 2 ->
510
+ 3`, never by `ServiceWorker.startWorker`'s result, which reports empty
511
+ success even for a scope that doesn't exist) — 31/31 assertions, up from the
512
+ 1.9.0 baseline of 20.
513
+
8
514
  ## [1.9.0] - 2026-08-09
9
515
 
10
516
  Four complaints from a field agent driving an MV3 extension, closed without adding
@@ -870,6 +1376,7 @@ First public release.
870
1376
  - Zero runtime dependencies in the CDP/CLI layer (Node's global `WebSocket` + `fetch`). The MCP server adds only `@modelcontextprotocol/sdk`; `lighthouse_audit` is the sole non-CDP tool and shells out to `npx lighthouse`.
871
1377
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
872
1378
 
1379
+ [1.9.1]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.1
873
1380
  [1.9.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.0
874
1381
  [1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
875
1382
  [1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0