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 +434 -0
- package/CONTRACT.md +59 -2
- package/README.md +68 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +230 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
package/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,
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
* ---------------------------------------------------------------------------- */
|