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/src/cdp/driver.ts CHANGED
@@ -13,11 +13,13 @@ import { resolveUid } from "../tools/snapshot.ts";
13
13
  import { resolveWorkerSelector } from "./workers.ts";
14
14
  import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
15
15
  import {
16
- LEGACY_NUMERIC_UID, interpolatePoints, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
16
+ LEGACY_NUMERIC_UID, interpolatePoints, clearViewportRecord, readViewportRecord, renderRestoreFailureNote, writeViewportRecord,
17
+ type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
17
18
  type SetCookieParams,
18
19
  type DragDestination, type DragOptions,
19
20
  type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel, type MouseButtonOptions,
20
21
  type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
22
+ type BandConsumer, type BandedCapture, type CaptureDelivery, type TiledScreenshotResult,
21
23
  } from "../driver.ts";
22
24
  /* ------------------------------ error + uid codec ------------------------------ */
23
25
  function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
@@ -292,13 +294,267 @@ async function backendNodeIdOf(conn: CdpConnection, loc: ElementLocator): Promis
292
294
  }
293
295
  return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
294
296
  }
295
- async function fullPageClip(conn: CdpConnection): Promise<{ x: number; y: number; width: number; height: number }> {
296
- const m = await conn.send<{ cssContentSize?: { width: number; height: number }; contentSize?: { width: number; height: number } }>("Page.getLayoutMetrics");
297
+ /**
298
+ * The Page.getLayoutMetrics reply, read off a live browser rather than guessed. Measured shape,
299
+ * Chrome/151.0.7922.76 on a deviceScaleFactor-2 display (2026-08-10):
300
+ * layoutViewport {pageX:0,pageY:0,clientWidth:2780,clientHeight:2128}
301
+ * visualViewport {offsetX,offsetY,pageX,pageY,clientWidth:2780,clientHeight:2128,scale:1,zoom:1}
302
+ * contentSize {x,y,width:2780,height:2128}
303
+ * cssLayoutViewport{pageX:0,pageY:0,clientWidth:1390,clientHeight:1064}
304
+ * cssVisualViewport{offsetX,offsetY,pageX,pageY,clientWidth:1390,clientHeight:1064,scale:1,zoom:1}
305
+ * cssContentSize {x,y,width:1390,height:1064}
306
+ * The css-prefixed rects are CSS px and the legacy twins carry the SAME rect in device px, so their
307
+ * clientWidth ratio is the live device-pixel multiplier (2 here). Every field is optional on this
308
+ * type because the css-prefixed family is the newer half of the reply and an older Chrome answers
309
+ * with only the legacy names.
310
+ *
311
+ * THE UNIT RULE HAS ONE MEASURED EXCEPTION, and it is a live footgun rather than trivia: on a
312
+ * scrolled page `visualViewport.pageX`/`pageY` come back in CSS px, not device px, while
313
+ * `clientWidth`/`clientHeight` on that very same rect stay device px. Scrolled to 700/4593 above:
314
+ * cssVisualViewport 700/4593, cssLayoutViewport 700/4593, visualViewport 700/4593 (CSS!),
315
+ * layoutViewport 1400/9186 (device). scrollOffsetCssPx below is the one reader of those offsets and
316
+ * spells out which fields it trusts and why.
317
+ */
318
+ interface LayoutMetrics {
319
+ layoutViewport?: { pageX?: number; pageY?: number; clientWidth?: number; clientHeight?: number };
320
+ visualViewport?: { pageX?: number; pageY?: number; clientWidth?: number; clientHeight?: number };
321
+ contentSize?: { width: number; height: number };
322
+ cssLayoutViewport?: { pageX?: number; pageY?: number; clientWidth?: number; clientHeight?: number };
323
+ cssVisualViewport?: { pageX?: number; pageY?: number; clientWidth?: number; clientHeight?: number };
324
+ cssContentSize?: { width: number; height: number };
325
+ }
326
+ async function layoutMetrics(conn: CdpConnection): Promise<LayoutMetrics> {
327
+ return conn.send<LayoutMetrics>("Page.getLayoutMetrics");
328
+ }
329
+ /**
330
+ * The rect a capture with no element clip covers — for `fullPage`, and ALSO for the plain
331
+ * no-clip-no-fullPage call, because on this driver those two are the same picture. Measured, not
332
+ * assumed: captureBeyondViewport is hardcoded true just below, so a no-clip capture of a 1390x3250
333
+ * css-px document at scroll y=1200 returned 2780x6500 device px from the document ORIGIN — the
334
+ * same 72490 bytes, sha256 ab46801c…, as the same page captured with fullPage:true.
335
+ *
336
+ * That is why a scaled plain capture synthesizes THIS clip and not cssVisualViewport (which the
337
+ * metrics reply also carries: pageX/pageY = the scroll offset, clientWidth/clientHeight = the
338
+ * VISIBLE 1390x1064). Clipping to the visual viewport would make `scale` do two things at once —
339
+ * multiply the resolution AND silently crop 3250 css px of document down to 1064 — so `--scale 3`
340
+ * would return a different picture than `--scale 1`, not a bigger one. Here scale only ever
341
+ * multiplies: 2780x6500 at scale 1 -> 5560x13000 at scale 2, same framing.
342
+ */
343
+ function contentClip(m: LayoutMetrics): { x: number; y: number; width: number; height: number } {
297
344
  const size = m.cssContentSize ?? m.contentSize;
298
345
  if (!size) throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
299
346
  return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
300
347
  }
301
- async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{ x: number; y: number; width: number; height: number }> {
348
+ /**
349
+ * How many output pixels one CSS px of clip becomes BEFORE `scale` is applied — the display's (or
350
+ * emulation override's) device-pixel multiplier, read as the ratio between the device-px and CSS-px
351
+ * copies of the same viewport rect rather than from window.devicePixelRatio (no extra round trip,
352
+ * same source of truth as the capture itself). Measured: clip {100x100, scale:1} on a ratio-2
353
+ * display returns a 200x200 PNG, so this factor really does multiply into the encoded size.
354
+ * Falls back to 1 when a metrics reply carries only one of the two rects.
355
+ */
356
+ function devicePixelsPerCssPx(m: LayoutMetrics): number {
357
+ const css = m.cssVisualViewport?.clientWidth ?? m.cssLayoutViewport?.clientWidth;
358
+ const dev = m.visualViewport?.clientWidth ?? m.layoutViewport?.clientWidth;
359
+ if (!css || !dev) return 1;
360
+ const ratio = dev / css;
361
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
362
+ }
363
+ /**
364
+ * The page's scroll offset in CSS px — what has to be ADDED to a viewport-relative rect to make it
365
+ * document-absolute, which is the frame `Page.captureScreenshot` reads its clip in whenever
366
+ * captureBeyondViewport is true (hardcoded true below).
367
+ *
368
+ * WHICH FIELD, MEASURED rather than inferred from the "css" prefix, because that prefix is NOT a
369
+ * clean CSS-px/device-px split for the offsets. Same reply, same instant, page scrolled to
370
+ * window.scrollX/scrollY = 700/4593 on a deviceScaleFactor-2 display (Chrome/151.0.7922.76,
371
+ * 2026-08-10):
372
+ * cssVisualViewport pageX/pageY = 700 / 4593 <- CSS px (what this returns)
373
+ * cssLayoutViewport pageX/pageY = 700 / 4593 <- CSS px, identical
374
+ * visualViewport pageX/pageY = 700 / 4593 <- CSS px TOO, despite clientWidth/clientHeight
375
+ * on that same rect reading 2780x2128 (device px)
376
+ * layoutViewport pageX/pageY = 1400 / 9186 <- device px, exactly 2x
377
+ * So `visualViewport` mixes units WITHIN one rect, and is the one field a "no css prefix means
378
+ * device px" assumption would silently double. It is never read here.
379
+ *
380
+ * The legacy fallback rides `layoutViewport` over the measured ratio (9186/2 = 4593 exactly) and is
381
+ * only reachable on a Chrome old enough to answer with no css-prefixed rects at all — where
382
+ * devicePixelsPerCssPx above has already fallen back to 1 and contentClip is already handing Chrome
383
+ * the DEVICE-px contentSize as a clip. The division is what keeps this offset in whatever frame the
384
+ * rest of the reply put the file in, rather than correcting into a frame nothing else is using.
385
+ */
386
+ function scrollOffsetCssPx(m: LayoutMetrics, devicePx: number): { x: number; y: number } {
387
+ const css = m.cssVisualViewport ?? m.cssLayoutViewport;
388
+ if (css) return { x: css.pageX ?? 0, y: css.pageY ?? 0 };
389
+ const legacy = m.layoutViewport;
390
+ if (legacy) return { x: (legacy.pageX ?? 0) / devicePx, y: (legacy.pageY ?? 0) / devicePx };
391
+ return { x: 0, y: 0 };
392
+ }
393
+ /**
394
+ * Chrome's per-side encode limit. MEASURED against Chrome/151.0.7922.76, clip width x height in
395
+ * CSS px at the given scale on a device-pixel-ratio-2 display, so the encoded size is
396
+ * ceil(css * scale * ratio):
397
+ * 1024x100 @8 -> 16384x1600 encoded: OK in ~1s
398
+ * 1025x100 @8 -> 16400x1600 encoded: Page.captureScreenshot NEVER ANSWERS (killed at 45s, and
399
+ * again at 180s on a fresh tab) — 16 px over the cap and the command just hangs
400
+ * 100x1024 @8 -> 1600x16384 encoded: OK in ~1s (same cap on the other axis)
401
+ * 100x1025 @8 -> 1600x16400 encoded: hangs the same way
402
+ * 100x1064 @16 -> 3200x34048 encoded: fails fast with the opaque CDP error
403
+ * "Page.captureScreenshot: Unable to capture screenshot"
404
+ * So the failure is not a silent clamp — but it is either a wedged command or an error naming no
405
+ * dimension, which is why this is refused BEFORE the capture, with the numbers spelled out.
406
+ *
407
+ * And the cost of just letting Chrome try is worse than a failed call: captureBeyondViewport
408
+ * (hardcoded true below) RESIZES the page to the clip for the duration of the capture, and a
409
+ * capture that never returns never puts it back. After the 1025x100 @8 hang above, that tab was
410
+ * left reporting window.innerWidth/innerHeight of 8200x800 — the clip's own css size — and every
411
+ * later screenshot on it timed out too. A refusal here is what keeps a caller's tab intact.
412
+ */
413
+ const MAX_ENCODED_SIDE_PX = 16384;
414
+ /**
415
+ * `renderSize` is only for the message. It is named separately because it changes what the caller
416
+ * should DO about the refusal: past the cap because of `scale`, the fix is a smaller scale, but past
417
+ * it because a narrow render size made the document reflow taller, the fix is a different render
418
+ * size (or dropping fullPage) and a "largest scale that fits" hint would be useless advice.
419
+ */
420
+ function assertClipEncodable(
421
+ clip: { width: number; height: number },
422
+ scale: number,
423
+ devicePx: number,
424
+ renderSize?: { width: number; height: number },
425
+ ): void {
426
+ const outWidth = Math.ceil(clip.width * scale * devicePx);
427
+ const outHeight = Math.ceil(clip.height * scale * devicePx);
428
+ if (outWidth <= MAX_ENCODED_SIDE_PX && outHeight <= MAX_ENCODED_SIDE_PX) return;
429
+ const at = renderSize ? ` at render size ${renderSize.width}x${renderSize.height}` : "";
430
+ const projection =
431
+ `(clip ${clip.width}x${clip.height} css px${at} x scale ${scale} x device pixel ratio ${devicePx}), ` +
432
+ `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit; `;
433
+ if (scale !== 1) {
434
+ // Floor to 2dp: a rounded-UP suggestion would be a scale that fails the same way.
435
+ const fits = Math.floor((MAX_ENCODED_SIDE_PX / (Math.max(clip.width, clip.height) * devicePx)) * 100) / 100;
436
+ throw driverError(
437
+ "page-error",
438
+ `screenshot scale ${scale} would encode ${outWidth}x${outHeight} px ${projection}` +
439
+ (fits >= 0.01
440
+ ? `the largest scale that fits this clip is ${fits}.`
441
+ : `this clip is already past the limit at scale 1 — capture a smaller region.`),
442
+ );
443
+ }
444
+ const maxCssSide = Math.floor(MAX_ENCODED_SIDE_PX / devicePx);
445
+ throw driverError(
446
+ "page-error",
447
+ `this capture would encode ${outWidth}x${outHeight} px ${projection}` +
448
+ `at device pixel ratio ${devicePx} the clip may be at most ${maxCssSide} css px per side` +
449
+ (renderSize
450
+ ? ` — a narrow renderWidth reflows the document TALLER, so widen it, shorten the page, or drop fullPage.`
451
+ : ` — capture a smaller region or drop fullPage.`),
452
+ );
453
+ }
454
+ /** The encoded size a clip projects to, the one number every guard and every band decision is made
455
+ * on. `scale` and the device pixel ratio BOTH multiply — measured, see devicePixelsPerCssPx. */
456
+ function projectedSide(cssLength: number, scale: number, devicePx: number): number {
457
+ return Math.ceil(cssLength * scale * devicePx);
458
+ }
459
+
460
+ /**
461
+ * How the capture is split, decided before a single Page.captureScreenshot is sent. Pure and
462
+ * exported so the arithmetic is unit-testable without a browser: getting it wrong is invisible in
463
+ * a small test and catastrophic on a 140,000 px page.
464
+ *
465
+ * BAND HEIGHT IS IN CSS PX AND SO IS clip.y, and that is MEASURED, not assumed. A ruler page with
466
+ * a marker at CSS y=4000-4008, clipped {y:3980,height:40}, put the marker at the same CSS position
467
+ * at scale 1 and at scale 2 — the output was simply twice the resolution. So a band advances by
468
+ * bandHeightCss regardless of scale or device pixel ratio; multiplying the offset by either would
469
+ * skip or repeat that factor's worth of page on every seam.
470
+ *
471
+ * WHY `tile:true` ON A FITTING REGION STILL SPLITS IN TWO. The flag exists so the banded path can
472
+ * be exercised on an ordinary page. A band height derived only from the cap would return ONE band
473
+ * for any page small enough to be convenient to test, i.e. `tile:true` would be a no-op on exactly
474
+ * the pages someone would use it to check seams with. So an explicit `true` also halves a region
475
+ * that already fits. It stays lossless either way (the stitcher is byte-exact; a seam is not a
476
+ * resample), and the result reports the band count honestly.
477
+ */
478
+ export interface TilePlan {
479
+ tiled: boolean;
480
+ /** How many Page.captureScreenshot calls. 1 when not tiled. */
481
+ bandCount: number;
482
+ /** CSS-px height of every band but the last; the last is the remainder. */
483
+ bandHeightCss: number;
484
+ }
485
+ export function planTiledCapture(
486
+ clip: { width: number; height: number },
487
+ scale: number,
488
+ devicePx: number,
489
+ tile: boolean | undefined,
490
+ ): TilePlan {
491
+ const untiled: TilePlan = { tiled: false, bandCount: 1, bandHeightCss: clip.height };
492
+ if (tile === false) return untiled;
493
+ const fits =
494
+ projectedSide(clip.width, scale, devicePx) <= MAX_ENCODED_SIDE_PX &&
495
+ projectedSide(clip.height, scale, devicePx) <= MAX_ENCODED_SIDE_PX;
496
+ if (fits && tile !== true) return untiled;
497
+ // WIDTH CANNOT BE TILED, and this refusal is the deliberate edge of the feature's scope. The
498
+ // stitcher concatenates PNG scanlines, so it can only grow the image downward; a too-wide
499
+ // capture would need a grid and a horizontal join it cannot do. Refusing names the two knobs
500
+ // that actually shrink a width — the alternative, silently capturing 16384 px of a wider page,
501
+ // would hand back a truncated picture indistinguishable from a complete one.
502
+ const outWidth = projectedSide(clip.width, scale, devicePx);
503
+ if (outWidth > MAX_ENCODED_SIDE_PX) {
504
+ const maxCssWidth = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
505
+ throw driverError(
506
+ "page-error",
507
+ `this capture is ${outWidth} px WIDE (clip ${clip.width} css px x scale ${scale} x device pixel ratio ${devicePx}), ` +
508
+ `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit, and width cannot be tiled: bands are stitched ` +
509
+ `vertically only. Lower scale (at most ${Math.floor((MAX_ENCODED_SIDE_PX / (clip.width * devicePx)) * 100) / 100}), ` +
510
+ `or set renderWidth at most ${maxCssWidth} css px, or clip to a narrower element.`,
511
+ );
512
+ }
513
+ // The largest band whose PROJECTION still fits: ceil(h x scale x devicePx) <= cap. The loop is a
514
+ // floating-point backstop — floor() alone is right for every integer ratio, but a fractional
515
+ // scale x ratio can land a hair above the cap after ceil(), and a band one pixel over is not an
516
+ // off-by-one, it is the hang this whole feature exists to avoid. Measured by mutation: dropping
517
+ // devicePx from the floor() alone changes NOTHING, because the loop walks the value back down to
518
+ // the same answer (slowly, one px at a time). The loop is the correctness; the floor is the speed.
519
+ let bandHeightCss = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
520
+ while (bandHeightCss > 1 && projectedSide(bandHeightCss, scale, devicePx) > MAX_ENCODED_SIDE_PX) bandHeightCss -= 1;
521
+ // UNREACHABLE AS THINGS STAND, and said so rather than presented as a live branch: a band height
522
+ // below 1 needs scale x devicePx past the cap, and every clip is at least 1 css px wide (both
523
+ // clip builders ceil, and a zero-area element is refused before this), so the width check above
524
+ // has already thrown. Kept because it is the invariant the loop depends on — a zero band height
525
+ // would make bandCount infinite — not because a caller can get here today.
526
+ if (bandHeightCss < 1) {
527
+ throw driverError(
528
+ "page-error",
529
+ `scale ${scale} at device pixel ratio ${devicePx} projects a single css pixel to ${scale * devicePx} device px, ` +
530
+ `past the ${MAX_ENCODED_SIDE_PX} px limit — no band height can fit. Lower scale.`,
531
+ );
532
+ }
533
+ // An explicit `true` on a region that already fits: split it for real (see the header).
534
+ if (tile === true && clip.height <= bandHeightCss) bandHeightCss = Math.max(1, Math.ceil(clip.height / 2));
535
+ return { tiled: true, bandCount: Math.max(1, Math.ceil(clip.height / bandHeightCss)), bandHeightCss };
536
+ }
537
+
538
+ /**
539
+ * The element's content box, in CSS px and VIEWPORT-RELATIVE — which is the frame
540
+ * `DOM.getBoxModel` answers in, measured rather than assumed: on a 10,000 css px document scrolled
541
+ * to y=4593, the quad for an element whose real document position is y=5000 came back at y=407,
542
+ * matching `getBoundingClientRect().top` to the pixel. NOT a capture clip; see documentClip below
543
+ * for the frame conversion that has to happen before this can be one.
544
+ *
545
+ * THE SCROLL IS DELIBERATE, AND IT IS NOT WHAT MAKES THE CLIP CORRECT. With captureBeyondViewport
546
+ * (hardcoded true in capture()) Chrome renders the whole document, so nothing here has to be on
547
+ * screen to be captured, and the REJECTED ALTERNATIVE was to drop this call entirely and read the
548
+ * box model where the page already sat. Measured consequence of dropping it, on a fixture whose
549
+ * target only fills itself from an IntersectionObserver: with the scroll, the capture reads
550
+ * "LAZY CONTENT LOADED"; without it, the same capture returns the element's "NOT LOADED
551
+ * (placeholder)" state in the right region — because captureBeyondViewport renders what the DOM
552
+ * currently IS (the tab still reported the placeholder text afterwards, scrollY 0), and never
553
+ * triggers a viewport-driven load. So the scroll buys lazy content, costs one round trip, and the
554
+ * document-frame correction below is what buys correctness. Keeping it also means the offset this
555
+ * scroll CREATES must be read after it settles — see capture()'s ordering.
556
+ */
557
+ async function elementBox(conn: CdpConnection, loc: ElementLocator): Promise<{ x: number; y: number; width: number; height: number }> {
302
558
  const backendNodeId = await backendNodeIdOf(conn, loc);
303
559
  await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => undefined);
304
560
  const box = await conn.send<{ model: { content: number[] } }>("DOM.getBoxModel", { backendNodeId });
@@ -311,6 +567,36 @@ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{
311
567
  if (width <= 0 || height <= 0) throw driverError("page-error", "resolved element has zero area");
312
568
  return { x: minX, y: minY, width, height };
313
569
  }
570
+ /**
571
+ * A viewport-relative element box -> the document-absolute clip `Page.captureScreenshot` actually
572
+ * wants. THE BUG THIS FIXES, measured on Chrome/151.0.7922.76: the box model's own x/y used to be
573
+ * handed straight to the capture, so a `--selector` clip returned the wrong region of the page
574
+ * whenever bringing the element into view required a scroll. On a 10,000 css px document with the
575
+ * target at y=5000, `DOM.scrollIntoViewIfNeeded` left the page at scrollY 4593 and the element at
576
+ * viewport y=407 — and the capture came back as document[407..657], a flat slab of page background,
577
+ * with no part of the element in it. Starting the same call from scrollY 4950 instead, where the
578
+ * element already sat at viewport y=50 and the scroll was a no-op, produced document[50..300]: the
579
+ * document's own top banner for the first 100 css px, background below, boundary on the predicted
580
+ * pixel. Both are the same arithmetic — a viewport-relative number read in the document frame —
581
+ * and the pair is also why the old behaviour depended on where the CALLER had left the page.
582
+ *
583
+ * Adding the scroll offset is the whole correction, and it is added in CSS px because `clip` is in
584
+ * CSS px (a clip of {1390 x 1064} on a ratio-2 display encodes 2780x2128, i.e. the multiplier is
585
+ * applied by Chrome AFTER the clip, not expected in it). Mixing in the device-px twin of the offset
586
+ * would be the same class of bug wearing different units.
587
+ *
588
+ * `scale`, `renderWidth`/`renderHeight` and `tile` all ride this same corrected rect and needed no
589
+ * changes of their own: scale only ever rides clip.scale, a render size reflows the document before
590
+ * anything here is measured, and the band walker starts from clip.y and walks in CSS px.
591
+ */
592
+ function documentClip(
593
+ box: { x: number; y: number; width: number; height: number },
594
+ m: LayoutMetrics,
595
+ devicePx: number,
596
+ ): { x: number; y: number; width: number; height: number } {
597
+ const scroll = scrollOffsetCssPx(m, devicePx);
598
+ return { x: box.x + scroll.x, y: box.y + scroll.y, width: box.width, height: box.height };
599
+ }
314
600
  /**
315
601
  * Chrome capabilities. network.responseBodyPostHoc is deliberately NOT declared: the Network
316
602
  * domain is enabled lazily (only by startBodyCapture/intercept), so a body is only readable for
@@ -342,11 +628,28 @@ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{
342
628
  * is declared for BiDi: Firefox's browsingContext.downloadWillBegin/downloadEnd exist but there is
343
629
  * no BiDi command to redirect a download to a chosen directory, and BiDi has no permission-granting
344
630
  * command at all outside the optional `permissions` module this driver does not negotiate.
631
+ * screenshot.scale rides Page.captureScreenshot's clip.scale, a per-CALL output multiplier that
632
+ * leaves page emulation untouched (the deviceScaleFactor workaround does not: it re-lays-out the
633
+ * page and persists until cleared). It is a PARAM-level capability like input.html5Drag, so
634
+ * take_screenshot stays universal and only the `scale` argument is gated; WebDriver BiDi's
635
+ * browsingContext.captureScreenshot has no scale parameter at all, so it is honestly Chrome-only.
636
+ * screenshot.renderSize rides Emulation.setDeviceMetricsOverride applied for the length of one
637
+ * capture and undone in a finally. Unlike screenshot.scale this is NOT a Chrome-only gap: BiDi's
638
+ * browsingContext.setViewport is a real equivalent and the Firefox driver declares it too. Both
639
+ * drivers already declare emulate.deviceMetrics on the very same primitive, so a backend that could
640
+ * not honour this would be a backend that cannot emulate a viewport at all.
641
+ * screenshot.tile rides the SAME clip.scale primitive as screenshot.scale, sent once per band with
642
+ * clip.y walked down the document, plus ../png.ts's stitcher. It is declared here and NOT for BiDi,
643
+ * for the honest reason rather than a protocol one: band tiling was measured against a real Chrome
644
+ * (fixed and sticky elements do not repeat per band, seams are pixel-exact, clipped bands are
645
+ * scroll-independent, and clip.y/clip.height stay CSS px while scale only changes resolution), and
646
+ * no Firefox was driven to measure the same for browsingContext.captureScreenshot's clip:{type:"box"}.
647
+ * Declaring an unmeasured tiling would be claiming a capability on the strength of a spec reading.
345
648
  */
346
649
  const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
347
650
  "trace.performance", "heap.snapshot", "audit.lighthouse", "emulate.cpuThrottling",
348
651
  "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
349
- "screenshot.fullPage", "screenshot.element", "network.intercept",
652
+ "screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
350
653
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
351
654
  "capture.screencast", "input.raw", "input.html5Drag",
352
655
  "browser.downloads", "browser.permissions", "worker.targets",
@@ -718,27 +1021,272 @@ class CdpPageDriver implements PageDriver {
718
1021
  const { objectId } = await resolveElementLocator(this.conn, loc);
719
1022
  await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
720
1023
  }
721
- async screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }> {
1024
+ /**
1025
+ * A capture, optionally rendered at an emulated viewport that is put back afterwards.
1026
+ *
1027
+ * ORDERING IS THE WHOLE CORRECTNESS ARGUMENT. The override goes on FIRST, before anything is
1028
+ * measured, because narrowing the viewport reflows the document: capture() reads
1029
+ * Page.getLayoutMetrics for fullPage's content rect and for the device-pixel projection, and
1030
+ * elementClip reads a box model, and all three answer differently at 1920 wide than at 390. That
1031
+ * also puts assertClipEncodable on the POST-override numbers, so a 1920-wide render at scale 4 is
1032
+ * refused on the size it would really encode rather than on the tab's old size.
1033
+ *
1034
+ * NO REFLOW WAIT, and that is measured rather than assumed. Emulation.setDeviceMetricsOverride
1035
+ * resolves only once layout has settled: on a page with a `@media (max-width:900px)` rule, an
1036
+ * IMMEDIATE getLayoutMetrics + captureScreenshot after the override matched one taken after a
1037
+ * double-requestAnimationFrame round-trip on every field — content rect, computed background
1038
+ * colour (the media query had already flipped), a grid child's height (236.47 vs 444.46 px across
1039
+ * the breakpoint), and the PNG's own sha256. Three transitions, 1920x1080 -> 420x900 -> 1920x1080,
1040
+ * byte-identical each time. So there is deliberately no sleep here; adding a speculative one would
1041
+ * be untestable latency on every capture.
1042
+ *
1043
+ * RESTORE RUNS ON EVERY EXIT. A thrown guard (assertClipEncodable), a per-command timeout, a
1044
+ * page-side failure — the capture's error is held, the viewport is put back, and only then does
1045
+ * the error propagate. Leaving it unrestored is the exact failure this feature exists to avoid:
1046
+ * an aborted capture that strands a device-metrics override poisons every later measurement on
1047
+ * that tab (innerHeight stuck at the content height) and is expensive to even diagnose. When the
1048
+ * restore ITSELF fails there is no result to carry the bad news, so the fact is appended to the
1049
+ * error message instead — the caller's page really is still emulated and silence would be a lie.
1050
+ *
1051
+ * A BANDED CAPTURE HOLDS THE OVERRIDE FOR ITS WHOLE LENGTH, deliberately. capture() awaits the
1052
+ * band consumer, so this method does not reach its restore until the last band has been captured
1053
+ * AND written. That is the only correct ordering: every band has to be rendered at the same
1054
+ * emulated viewport, and putting the viewport back halfway would reflow the document under the
1055
+ * remaining bands and stitch two different layouts into one image.
1056
+ */
1057
+ async screenshot<T>(opts?: ScreenshotOptions, consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>> {
1058
+ const renderSize =
1059
+ opts?.renderWidth !== undefined && opts?.renderHeight !== undefined
1060
+ ? { width: opts.renderWidth, height: opts.renderHeight }
1061
+ : undefined;
1062
+ if (!renderSize) return this.capture(opts, consumeBands);
1063
+ // Read BEFORE applying ours: our own setDeviceMetricsOverride overwrites the browser's state
1064
+ // and there is no CDP command that reads a device-metrics override back out.
1065
+ const previous = await readViewportRecord(this.browser.scheme, this.info.id);
1066
+ await this.conn.send("Emulation.setDeviceMetricsOverride", {
1067
+ width: renderSize.width,
1068
+ height: renderSize.height,
1069
+ // Inherited, not reset: the caller asked to change the render SIZE. Dropping a
1070
+ // deviceScaleFactor or mobile flag they set with emulate would be a second, silent change.
1071
+ deviceScaleFactor: previous?.deviceScaleFactor ?? 0,
1072
+ mobile: previous?.mobile ?? false,
1073
+ });
1074
+ let shot: CaptureDelivery<T> | undefined;
1075
+ let captureError: unknown;
1076
+ try {
1077
+ shot = await this.capture(opts, consumeBands);
1078
+ } catch (e) {
1079
+ captureError = e;
1080
+ }
1081
+ let restoreError: string | undefined;
1082
+ try {
1083
+ // Re-apply, never clear, when we know what was there: clearDeviceMetricsOverride resets to the
1084
+ // REAL device (measured — see ViewportRecord's header), so clearing here would silently
1085
+ // destroy an override the caller set with emulate earlier and believes is still in force.
1086
+ if (previous) {
1087
+ await this.conn.send("Emulation.setDeviceMetricsOverride", {
1088
+ width: previous.width,
1089
+ height: previous.height,
1090
+ deviceScaleFactor: previous.deviceScaleFactor ?? 0,
1091
+ mobile: previous.mobile ?? false,
1092
+ });
1093
+ } else {
1094
+ await this.conn.send("Emulation.clearDeviceMetricsOverride");
1095
+ }
1096
+ } catch (e) {
1097
+ restoreError = e instanceof Error ? e.message : String(e);
1098
+ }
1099
+ if (!shot) {
1100
+ // Mutate rather than re-wrap: a DriverError's `code` ("timeout", "page-error", ...) is what
1101
+ // callers branch on, and a fresh error would throw that away to add one sentence.
1102
+ if (restoreError !== undefined && captureError instanceof Error) {
1103
+ captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
1104
+ }
1105
+ throw captureError;
1106
+ }
1107
+ // Object.assign onto the result we just built, rather than a spread: `shot` is a union
1108
+ // (bytes or bands) and mutating it keeps whichever arm it is, discriminant and all.
1109
+ const result = Object.assign(shot, { renderSize, renderRestored: restoreError === undefined });
1110
+ if (restoreError !== undefined) result.renderRestoreError = restoreError;
1111
+ return result;
1112
+ }
1113
+ /** The capture itself, with no viewport emulation of its own. Split out so screenshot()'s restore
1114
+ * wraps EVERY way this can fail, the throwing paths included. */
1115
+ private async capture<T>(opts?: ScreenshotOptions, consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>> {
722
1116
  await this.ensureDomain("Page");
723
1117
  const format = opts?.format ?? "png";
724
1118
  const quality = format === "jpeg" ? (opts?.quality ?? 80) : undefined;
1119
+ // scale absent (or exactly 1) must leave this call byte-for-byte what it was: clip.scale stays
1120
+ // 1 on the element/fullPage paths and the plain no-clip path still sends NO clip at all.
1121
+ const scale = opts?.scale ?? 1;
1122
+ const renderSize =
1123
+ opts?.renderWidth !== undefined && opts?.renderHeight !== undefined
1124
+ ? { width: opts.renderWidth, height: opts.renderHeight }
1125
+ : undefined;
1126
+ // ONE Page.getLayoutMetrics, now fetched on EVERY capture, and the plain path's round trip is
1127
+ // bought deliberately. It used to be skipped when nothing needed a clip — which meant the guard
1128
+ // below never ran there, and that path is NOT the viewport capture it reads as:
1129
+ // captureBeyondViewport is hardcoded true, so a plain take_screenshot already captures the whole
1130
+ // document. MEASURED consequence of the old skip: a plain capture of any page past ~8192 css px
1131
+ // (on a ratio-2 display) hangs Page.captureScreenshot and leaves the tab stuck at the content
1132
+ // size, no flags required. Auto-tiling has to know the projection anyway, so one round trip now
1133
+ // decides all three outcomes — one shot, bands, or a refusal — instead of none of them.
1134
+ //
1135
+ // AND THE ELEMENT BOX IS READ FIRST, BEFORE THE METRICS. That ordering is load-bearing, not
1136
+ // style: elementBox calls DOM.scrollIntoViewIfNeeded, which MOVES the scroll offset that
1137
+ // documentClip then has to add. Metrics read before the scroll carry the OLD offset (measured:
1138
+ // 0 on a freshly navigated tab where the scroll then lands at 4593), and correcting by a stale
1139
+ // offset is the same bug with a smaller error. Read after, and one round trip still serves both
1140
+ // the projection and the correction — no second Page.getLayoutMetrics for the element path.
1141
+ const box = opts?.clip ? await elementBox(this.conn, opts.clip) : undefined;
1142
+ const metrics = await layoutMetrics(this.conn);
1143
+ const devicePx = devicePixelsPerCssPx(metrics);
1144
+ // The rect this capture covers, ALWAYS computed even when it is not sent. For the plain path
1145
+ // this is the clip Chrome infers for itself (measured byte-identical to fullPage, see
1146
+ // contentClip), so projecting it is projecting what Chrome is really about to encode.
1147
+ const clip = box ? documentClip(box, metrics, devicePx) : contentClip(metrics);
1148
+ // Which calls put a clip ON THE WIRE is unchanged, and that is what keeps a fitting capture
1149
+ // byte-for-byte what it was: the plain no-clip call still sends no clip at all, and only the
1150
+ // element / fullPage / scale / render-size paths synthesize one (clip.scale being the only
1151
+ // place scale can ride).
1152
+ const sendsClip = opts?.clip !== undefined || opts?.fullPage === true || scale !== 1 || renderSize !== undefined;
1153
+ // A caller with nowhere to put bands cannot be given any: it gets the encode guard's refusal
1154
+ // instead, which is the pre-tiling behaviour and still keeps the tab alive.
1155
+ if (opts?.tile === true && !consumeBands) {
1156
+ throw driverError("unsupported", "screenshot tile:true needs a caller that can consume bands; this one passed no band consumer");
1157
+ }
1158
+ const plan = planTiledCapture(clip, scale, devicePx, consumeBands ? opts?.tile : false);
1159
+ if (plan.tiled) return this.captureTiled(clip, plan, scale, format, consumeBands!);
1160
+ // Guarded here, not in the tool layer: the clip is only known once it has been measured.
1161
+ //
1162
+ // DELIBERATELY NOT CONDITIONED ON scale !== 1, NOR ON A CLIP BEING SENT, and both were live-fire
1163
+ // bugs rather than tidy-ups. `--fullPage --renderWidth 420 --renderHeight 900` at scale 1 on a
1164
+ // 4-column grid page reflowed the document from 2880 to 10800 css px tall, projected to
1165
+ // 840x21600 device px, sailed past this guard because the scale was 1, and WEDGED the tab. The
1166
+ // no-clip default path wedged it the same way for the same reason: no clip sent, so no guard.
1167
+ // Guard the PROJECTION, never the knob that happened to motivate the guard.
1168
+ //
1169
+ // And the restore could not save it. Measured with CDP_TIMEOUT_MS=8000: the capture timed out
1170
+ // client-side, the restore ran and SUCCEEDED (the error carried no restore-failure note), and
1171
+ // the page was still 420x10800 afterwards — because the timeout is only local, Chrome's capture
1172
+ // is still running, and it re-resizes the page after the restore lands. A finally cannot beat a
1173
+ // command that is still executing, so this pre-flight refusal is the only real protection.
1174
+ assertClipEncodable(clip, scale, devicePx, renderSize);
725
1175
  const params: Record<string, unknown> = { format, captureBeyondViewport: true };
726
1176
  if (quality != null) params.quality = quality;
727
- if (opts?.clip) params.clip = { ...(await elementClip(this.conn, opts.clip)), scale: 1 };
728
- else if (opts?.fullPage) params.clip = { ...(await fullPageClip(this.conn)), scale: 1 };
1177
+ if (sendsClip) params.clip = { ...clip, scale };
729
1178
  const { data } = await this.conn.send<{ data: string }>("Page.captureScreenshot", params);
730
1179
  return { data: new Uint8Array(Buffer.from(data, "base64")), format };
731
1180
  }
1181
+ /** Hand the consumer a lazy band sequence and wait for it to finish with it. */
1182
+ private async captureTiled<T>(
1183
+ clip: { x: number; y: number; width: number; height: number },
1184
+ plan: TilePlan,
1185
+ scale: number,
1186
+ format: "png" | "jpeg",
1187
+ consumeBands: BandConsumer<T>,
1188
+ ): Promise<TiledScreenshotResult<T>> {
1189
+ const capture: BandedCapture = {
1190
+ bands: this.bands(clip, plan, scale, format),
1191
+ bandCount: plan.bandCount,
1192
+ bandHeightCss: plan.bandHeightCss,
1193
+ clip,
1194
+ format: "png",
1195
+ };
1196
+ const consumed = await consumeBands(capture);
1197
+ return { tiled: true, format: "png", bandCount: plan.bandCount, bandHeightCss: plan.bandHeightCss, consumed };
1198
+ }
1199
+ /**
1200
+ * The bands themselves, one Page.captureScreenshot at a time and only when pulled.
1201
+ *
1202
+ * clip.y advances in CSS px and the last band is the REMAINDER, not a full band: asking for a
1203
+ * full-height last band would extend the clip past the document, and the sum of the band heights
1204
+ * has to be the region's height exactly or the stitched image is taller than the page.
1205
+ *
1206
+ * No scroll management, deliberately: measured on this Chrome, the identical clip captured at
1207
+ * three different real scroll positions produced three byte-identical PNGs, so a band is a pure
1208
+ * function of its clip. Nothing here has to move, restore, or even read the page's scroll.
1209
+ */
1210
+ private async *bands(
1211
+ clip: { x: number; y: number; width: number; height: number },
1212
+ plan: TilePlan,
1213
+ scale: number,
1214
+ format: "png" | "jpeg",
1215
+ ): AsyncGenerator<Uint8Array> {
1216
+ // Backstop, and it lives INSIDE the generator on purpose: shared-tools.ts refuses format:"jpeg"
1217
+ // with a message naming the fix, from the band consumer, which runs before the first band is
1218
+ // pulled. Checking earlier than this made the driver's terse message win over the tool layer's
1219
+ // useful one (live-fired: `--format jpeg` on a 140,982 px page reported "got format jpeg" and
1220
+ // nothing about what to do). Here it only fires for a consumer that ACCEPTED a jpeg banded
1221
+ // capture, which no shipping caller does — it exists so a future one cannot get a sequence of
1222
+ // JPEGs that the PNG stitcher would reject with a confusing parse error instead.
1223
+ if (format !== "png") {
1224
+ throw driverError("unsupported", `a tiled capture is PNG-only (bands are stitched as PNG scanlines); got format "${format}"`);
1225
+ }
1226
+ const bottom = clip.y + clip.height;
1227
+ for (let i = 0; i < plan.bandCount; i++) {
1228
+ const y = clip.y + i * plan.bandHeightCss;
1229
+ const height = Math.min(plan.bandHeightCss, bottom - y);
1230
+ const { data } = await this.conn.send<{ data: string }>("Page.captureScreenshot", {
1231
+ format: "png",
1232
+ captureBeyondViewport: true,
1233
+ clip: { x: clip.x, y, width: clip.width, height, scale },
1234
+ });
1235
+ yield new Uint8Array(Buffer.from(data, "base64"));
1236
+ }
1237
+ }
732
1238
  // Adapted from src/tools/emulation.ts `emulate`.
733
1239
  async emulate(opts: EmulationOptions): Promise<{ applied: string[] }> {
734
1240
  if (opts.clearOverrides) {
735
1241
  const c = () => undefined;
736
- await this.conn.send("Emulation.clearDeviceMetricsOverride").catch(c);
1242
+ // MEASURED BUG, now fixed: a bare Emulation.clearDeviceMetricsOverride on a FRESH connection
1243
+ // is a no-op — it resolves with {} (success) while a stale override some OTHER, now-
1244
+ // disconnected process set stays fully in force (see ViewportRecord's header comment in
1245
+ // ../driver.ts for the two-process proof). Under this toolkit's per-call driver lifetime, the
1246
+ // connection that runs clearOverrides is essentially NEVER the connection that ran the
1247
+ // original emulate, so this was the COMMON case, not an edge case — `emulate` was the
1248
+ // documented wedged-tab recovery procedure, and it was silently doing nothing.
1249
+ //
1250
+ // Remedy, measured to work every time (../driver.ts's ViewportRecord header, the A/B/C/D/E/F
1251
+ // trace): "set an override, then clear it" — clearDeviceMetricsOverride only reverts to the
1252
+ // REAL device when the clearing connection is ALSO the one that set the override, so setting
1253
+ // one first on THIS connection makes it the override's owner and turns the clear that follows
1254
+ // into a real one. The numbers used to "set" do not matter for correctness (clearing always
1255
+ // lands on the real device once this connection owns an override) — but they matter for being
1256
+ // side-effect-free, so read the layout's CURRENT size first and re-apply exactly that: the
1257
+ // page never visibly moves, only this connection's ownership of the override changes.
1258
+ let metricsClearError: string | undefined;
1259
+ try {
1260
+ await this.ensureDomain("Page");
1261
+ const metrics = await layoutMetrics(this.conn);
1262
+ const width =
1263
+ metrics.cssVisualViewport?.clientWidth ?? metrics.cssLayoutViewport?.clientWidth ??
1264
+ metrics.visualViewport?.clientWidth ?? metrics.layoutViewport?.clientWidth ?? 1;
1265
+ const height =
1266
+ metrics.cssVisualViewport?.clientHeight ?? metrics.cssLayoutViewport?.clientHeight ??
1267
+ metrics.visualViewport?.clientHeight ?? metrics.layoutViewport?.clientHeight ?? 1;
1268
+ await this.conn.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 0, mobile: false });
1269
+ await this.conn.send("Emulation.clearDeviceMetricsOverride");
1270
+ } catch (e) {
1271
+ metricsClearError = e instanceof Error ? e.message : String(e);
1272
+ }
737
1273
  await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
738
1274
  await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
739
1275
  await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
740
1276
  await this.ensureDomainSoft("Network");
741
1277
  await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
1278
+ if (metricsClearError !== undefined) {
1279
+ // Honest failure, not a lie wearing success's shape: `cleared: true` must mean the page
1280
+ // really is back at the real device, and CONTRACT.md #65 says throw on failure rather than
1281
+ // return an error sentinel. The record is deliberately LEFT IN PLACE: we could not confirm
1282
+ // the browser-side override is gone, so it remains the best available description of what
1283
+ // the page may still be showing.
1284
+ throw driverError("page-error", `emulate: could not verify the device-metrics override was cleared (${metricsClearError})`);
1285
+ }
1286
+ // Only forget the recorded override once the clear that supersedes it is confirmed to have
1287
+ // actually run — a record dropped before that point could make a later render-size capture
1288
+ // "restore" an override that never left.
1289
+ await clearViewportRecord(this.browser.scheme, this.info.id);
742
1290
  return { applied: [] };
743
1291
  }
744
1292
  const applied: string[] = [];
@@ -754,6 +1302,15 @@ class CdpPageDriver implements PageDriver {
754
1302
  deviceScaleFactor: opts.deviceScaleFactor ?? 0,
755
1303
  mobile: opts.mobile ?? false,
756
1304
  });
1305
+ // Recorded so a later render-size capture puts THIS back instead of clearing to the real
1306
+ // device. Written after the command succeeds, so a rejected override leaves no record
1307
+ // claiming an override that is not there. See ViewportRecord in ../driver.ts.
1308
+ await writeViewportRecord(this.browser.scheme, this.info.id, {
1309
+ width: opts.width,
1310
+ height: opts.height,
1311
+ deviceScaleFactor: opts.deviceScaleFactor ?? 0,
1312
+ mobile: opts.mobile ?? false,
1313
+ });
757
1314
  applied.push("deviceMetrics");
758
1315
  }
759
1316
  if (opts.userAgent != null) {
@@ -1266,4 +1823,32 @@ export { CdpBrowserDriver, CdpPageDriver };
1266
1823
  * startBodyCapture / emulate's networkConditions branch), never eagerly by page(), so a
1267
1824
  * body is only readable for a request that occurred after something armed capture first.
1268
1825
  * That is arming by another name, not "not armed in advance".
1826
+ * - screenshot.scale rides clip.scale, so a scaled no-clip capture has to synthesize the clip
1827
+ * Page.captureScreenshot otherwise infers (contentClip, off Page.getLayoutMetrics — measured
1828
+ * to be the same picture as fullPage, because captureBeyondViewport is always true here).
1829
+ * Output px is ceil(cssPx * scale * devicePixelRatio) — the ratio multiplies in, measured —
1830
+ * and anything past 16384 px a side is refused BEFORE the call, because Chrome's own answer
1831
+ * there is either a hang or an error naming no dimension (see MAX_ENCODED_SIDE_PX).
1832
+ * - screenshot.renderSize rides Emulation.setDeviceMetricsOverride, applied before ANYTHING is
1833
+ * measured (a narrower viewport reflows the document, so the content rect, the element box and
1834
+ * the encode guard must all be read after it) and undone on every exit path. Restore re-applies
1835
+ * the record ../driver.ts's writeViewportRecord kept, never a bare clear: measured,
1836
+ * Emulation.clearDeviceMetricsOverride is a NO-OP for an override another session set, and once
1837
+ * this session sets one it reverts to the REAL device — so clearing would silently destroy a
1838
+ * caller's earlier emulate. NOT a Chrome-only capability: the BiDi driver declares it too.
1839
+ * - The encode guard is deliberately NOT conditioned on scale, since 1.9.2: a scale-1 fullPage at
1840
+ * a narrow render size reflowed a page from 2880 to 10800 css px tall and wedged the tab. And a
1841
+ * wedge is not recoverable by the restore — the per-command timeout is client-side only, so
1842
+ * Chrome's capture is still running and re-resizes the page after the restore lands (measured
1843
+ * with CDP_TIMEOUT_MS=8000: the restore SUCCEEDED and the tab was still 420x10800). The
1844
+ * pre-flight refusal is the only real protection; an already-wedged tab is recovered by an
1845
+ * explicit emulate{width,height}, verified.
1846
+ * - screenshot.tile rides the same clip primitive, sent once per band with clip.y walked down the
1847
+ * document in CSS px (measured: clip.y/clip.height are CSS px at every scale), the bands piped
1848
+ * into ../png.ts's streaming stitcher by the tool layer. Page.getLayoutMetrics is now fetched on
1849
+ * EVERY capture, including the plain default one, because the projection is what decides one
1850
+ * shot vs bands vs refusal — and because the plain path was the one place the encode guard could
1851
+ * not run, which made a plain take_screenshot of a long page a tab-wedger with no flags set.
1852
+ * Vertical only: a projection too WIDE is refused by name, since PNG scanline concatenation
1853
+ * cannot join columns.
1269
1854
  * ---------------------------------------------------------------------------- */