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/cli.ts CHANGED
@@ -23,17 +23,20 @@
23
23
  * - --list -> all tool names (one per line) to stdout, exit 0.
24
24
  */
25
25
  import { TOOLS, TOOL_NAMES, type ToolName } from "./index.ts";
26
- import { resolveBrowserKind, stripBrowserFlag, startFirefoxSession } from "./backend.ts";
26
+ import { resolveBackend, stripBrowserFlag, stripConnectFlag, startFirefoxSession } from "./backend.ts";
27
27
  import { toolAvailability } from "./capabilities.ts";
28
28
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
29
29
  import { leaseFromArgs, withLeaseScope } from "./leases.ts";
30
+ import { MANIFEST } from "./manifest.ts";
30
31
 
31
32
  const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
32
33
 
33
34
  Usage:
34
- bun run src/cli.ts <tool> [--browser chrome|firefox] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
35
+ bun run src/cli.ts <tool> [--browser chrome|firefox] [--connect <port|host:port|ws-url>] [--target <sel>] [--json '<obj>'] [--<key> <value> ...]
35
36
  bun run src/cli.ts --list [--browser chrome|firefox]
36
37
  bun run src/cli.ts --capabilities [--browser chrome|firefox]
38
+ bun run src/cli.ts --help | -h # this message
39
+ bun run src/cli.ts <tool> --help | -h # that tool's arguments, sourced from its schema
37
40
 
38
41
  Examples:
39
42
  bun run src/cli.ts list_pages
@@ -43,14 +46,30 @@ Examples:
43
46
  bun run src/cli.ts take_screenshot --target url:example --fullPage true
44
47
  bun run src/cli.ts --browser firefox take_snapshot
45
48
  bun run src/cli.ts --capabilities --browser firefox
49
+ bun run src/cli.ts take_screenshot --help
50
+ bun run src/cli.ts --connect 9223 take_snapshot # attach to your own logged-in Firefox (implies --browser firefox)
51
+
52
+ --help/-h is recognised ANYWHERE in argv, before any tool dispatch: it never touches the
53
+ browser (no CDP connection, no lease, no file written) and is never passed through as a tool
54
+ argument, even when it appears after a tool name.
46
55
 
47
56
  Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else "chrome" (default,
48
- zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
49
- to), used for exactly one tool call, then disposed before the process exits.
57
+ zero behavior change for existing users). Firefox runs in one of two modes:
58
+ - LAUNCH (default): a fresh throwaway-profile Firefox is spawned for this one invocation, used
59
+ for exactly one tool call, then disposed (session ended, process killed) before the process
60
+ exits. This is a login wall for anything that needs your real session.
61
+ - ATTACH (--connect <port|host:port|ws-url>, or the CDP_FIREFOX_ENDPOINT env var): connects to a
62
+ Firefox YOU already started with --remote-debugging-port, so tools see your real logged-in
63
+ profile. This process never launches or kills that Firefox; dispose only ends the BiDi session.
64
+ --connect implies --browser firefox and conflicts with an explicit --browser chrome. Firefox
65
+ allows only one active BiDi session at a time, so a client that dies without disposing cleanly
66
+ (e.g. SIGKILL) can leave the slot occupied until Firefox restarts.
50
67
 
51
68
  Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
52
- plus worker:<substr> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
53
- worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
69
+ plus worker:<substr> (Chrome only) for evaluate_script, list_network_requests, get_network_request
70
+ and list_console_messages ONLY: an MV3 extension's background service worker, e.g.
71
+ worker:<extension-id>. An idle-evicted worker is started first unless wake:false. Recording a
72
+ worker keeps it alive for the length of the capture.
54
73
  Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
55
74
  Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
56
75
  Run with --list to print every tool name available under the selected backend.
@@ -72,12 +91,13 @@ interface ParsedArgs {
72
91
  tool?: string;
73
92
  list: boolean;
74
93
  capabilities: boolean;
94
+ help: boolean;
75
95
  args: Record<string, unknown>;
76
96
  }
77
97
 
78
98
  /** Parse process argv (already sliced to drop the runtime + script, and with --browser already stripped). */
79
99
  function parseArgv(argv: string[]): ParsedArgs {
80
- const out: ParsedArgs = { list: false, capabilities: false, args: {} };
100
+ const out: ParsedArgs = { list: false, capabilities: false, help: false, args: {} };
81
101
  // Collect flag pairs separately so --json can be merged BEFORE individual
82
102
  // flags override it, regardless of token order.
83
103
  let jsonObj: Record<string, unknown> | undefined;
@@ -93,6 +113,16 @@ function parseArgv(argv: string[]): ParsedArgs {
93
113
  out.capabilities = true;
94
114
  continue;
95
115
  }
116
+ // Recognised ANYWHERE in argv (not just as the first token), and BEFORE the
117
+ // generic "--key value" branch below, so --help/-h can never be swallowed as
118
+ // a tool argument (the bug: `take_screenshot --help` used to run the tool
119
+ // with args.help=true; `-h` doesn't even start with "--", so without this
120
+ // explicit check it fell through to the positional branch and, if a tool
121
+ // name was already set, was silently dropped and the tool ran anyway).
122
+ if (token === "--help" || token === "-h") {
123
+ out.help = true;
124
+ continue;
125
+ }
96
126
  if (token.startsWith("--")) {
97
127
  const key = token.slice(2);
98
128
  const next = argv[i + 1];
@@ -130,10 +160,78 @@ function isToolName(name: string): name is ToolName {
130
160
  return Object.prototype.hasOwnProperty.call(TOOLS, name);
131
161
  }
132
162
 
163
+ /** A JSON-Schema property shape narrow enough for the fields --help prints. */
164
+ interface HelpProp {
165
+ type?: string | string[];
166
+ enum?: unknown[];
167
+ description?: string;
168
+ }
169
+
170
+ /** "string", "string|array" (a union type array), or an enum rendered as "png|jpeg". */
171
+ function formatType(prop: HelpProp | undefined): string {
172
+ if (!prop) return "any";
173
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) return prop.enum.map(String).join("|");
174
+ if (Array.isArray(prop.type)) return prop.type.join("|");
175
+ return prop.type ?? "any";
176
+ }
177
+
178
+ /**
179
+ * Print one tool's usage sourced ENTIRELY from MANIFEST's inputSchema (src/manifest.ts) — the
180
+ * same JSON Schema the MCP server advertises via tools/list. This is a read of that single
181
+ * source of truth, not a second hand-maintained argument list that could drift from it.
182
+ */
183
+ function printToolHelp(name: ToolName): void {
184
+ const spec = MANIFEST.find((t) => t.name === name);
185
+ if (!spec) {
186
+ // Every TOOLS entry has a MANIFEST entry (auditCoverage() in mcp.ts warns otherwise); this
187
+ // is a defensive fallback, not an expected path.
188
+ process.stdout.write(`${name}\n`);
189
+ return;
190
+ }
191
+ const required = new Set(spec.inputSchema.required ?? []);
192
+ const props = (spec.inputSchema.properties ?? {}) as Record<string, HelpProp>;
193
+ const keys = Object.keys(props);
194
+
195
+ const lines = [spec.name, "", spec.description, ""];
196
+ if (keys.length === 0) {
197
+ lines.push("Arguments: none.");
198
+ } else {
199
+ lines.push("Arguments:");
200
+ for (const key of keys) {
201
+ const prop = props[key];
202
+ const reqTag = required.has(key) ? "required" : "optional";
203
+ lines.push(` --${key} <${formatType(prop)}> (${reqTag})`);
204
+ if (prop?.description) lines.push(` ${prop.description}`);
205
+ }
206
+ }
207
+ lines.push("", `Usage: cdp ${spec.name} [--target <sel>] [--json '<obj>'] [--<key> <value> ...]`);
208
+ process.stdout.write(`${lines.join("\n")}\n`);
209
+ }
210
+
133
211
  async function main(): Promise<number> {
134
212
  const rawArgv = process.argv.slice(2);
135
- const browser = resolveBrowserKind(rawArgv);
136
- const parsed = parseArgv(stripBrowserFlag(rawArgv));
213
+ const { kind: browser, endpoint } = resolveBackend(rawArgv);
214
+ const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
215
+
216
+ // --help/-h short-circuits BEFORE any availability lookup or backend touch — no CDP
217
+ // connection, no lease, no file written. This must be the very first thing checked in
218
+ // main(), ahead of --list/--capabilities, so a --help gesture can never be downgraded
219
+ // into a real dispatch by any branch below it.
220
+ if (parsed.help) {
221
+ if (!parsed.tool) {
222
+ process.stdout.write(`${USAGE}\n`);
223
+ return 0;
224
+ }
225
+ if (!isToolName(parsed.tool)) {
226
+ process.stderr.write(
227
+ `${JSON.stringify({ error: `unknown tool '${parsed.tool}'. Run --list to see all ${TOOL_NAMES.length} tools.` })}\n`,
228
+ );
229
+ return 1;
230
+ }
231
+ printToolHelp(parsed.tool);
232
+ return 0;
233
+ }
234
+
137
235
  const availability = toolAvailability(browser);
138
236
  const availableSet = new Set(availability.available);
139
237
 
@@ -203,14 +301,15 @@ async function main(): Promise<number> {
203
301
  return 0;
204
302
  }
205
303
 
206
- // Firefox: one process per invocation. Launch, run exactly one tool call, always dispose,
207
- // even if the tool call itself throws, so the spawned Firefox process never leaks.
304
+ // Firefox: one process per invocation. Launch (or, with --connect/CDP_FIREFOX_ENDPOINT,
305
+ // attach), run exactly one tool call, always dispose, even if the tool call itself throws —
306
+ // in launch mode that reaps the spawned process; in attach mode it only ends the BiDi session.
208
307
  const neutralFn = FIREFOX_TOOLS[tool];
209
308
  if (!neutralFn) {
210
309
  process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}\n`);
211
310
  return 1;
212
311
  }
213
- const session = await startFirefoxSession();
312
+ const session = await startFirefoxSession({ endpoint });
214
313
  try {
215
314
  const result = await neutralFn(session.driver, parsed.args as never);
216
315
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
package/src/driver.ts CHANGED
@@ -56,7 +56,13 @@
56
56
  * breaks the CLI.
57
57
  */
58
58
 
59
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
60
+ import { join } from "node:path";
59
61
  import type { ToolName } from "./index.ts";
62
+ // leaseDir only, for the viewport-override record below. leases.ts imports nothing from this file
63
+ // (node builtins only), so this is not a cycle; the record deliberately shares the lease directory
64
+ // rather than minting a second state location.
65
+ import { leaseDir } from "./leases.ts";
60
66
  import type { TargetSelector } from "./types.ts";
61
67
 
62
68
  /* ---------------------------------- uids ---------------------------------- */
@@ -133,6 +139,9 @@ export type Capability =
133
139
  | "emulate.networkConditions" // throttled or offline conditions
134
140
  | "screenshot.fullPage" // one-shot full scrollable page, not just the viewport
135
141
  | "screenshot.element" // capture clipped to a single element
142
+ | "screenshot.scale" // per-capture output-pixel multiplier; no permanent emulation change
143
+ | "screenshot.renderSize" // emulate a viewport for the duration of ONE capture, then put the old one back
144
+ | "screenshot.tile" // capture past the backend's one-image encode limit as vertical bands, stitched losslessly
136
145
  | "network.intercept" // fulfill, fail, or continue a matched request
137
146
  | "network.responseBodyPostHoc" // read a body for a request not armed in advance
138
147
  | "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
@@ -344,6 +353,236 @@ export interface ScreenshotOptions {
344
353
  quality?: number;
345
354
  fullPage?: boolean; // requires "screenshot.fullPage"
346
355
  clip?: ElementLocator; // requires "screenshot.element"
356
+ scale?: number; // requires "screenshot.scale"; absent or 1 leaves the capture byte-for-byte as it was
357
+ /**
358
+ * Render this capture at an emulated viewport, then put the previous one back. Both fields are
359
+ * required TOGETHER (the tool layer enforces it; a driver trusts its caller) and both require
360
+ * Capability "screenshot.renderSize". Absent, the capture is byte-for-byte what it was.
361
+ *
362
+ * NOT the same knob as `scale`, and the difference is measured, not asserted. `scale` multiplies
363
+ * output pixels while the page keeps believing it is its old size: window.devicePixelRatio read 2
364
+ * both before and after a --scale 3 capture, and innerWidth/innerHeight stayed 1390x1064. This
365
+ * pair changes what the page BELIEVES for the length of one capture, so media queries flip and
366
+ * the document re-lays-out — which is the point (capture a responsive page at desktop 1920x1080
367
+ * from a tab that is not that size) and also why it must be undone afterwards.
368
+ *
369
+ * `deviceScaleFactor` and `mobile` are deliberately INHERITED from whatever override was already
370
+ * in place rather than reset: the caller asked to change the render SIZE, and silently dropping
371
+ * a device pixel ratio they set earlier would be a second, unrequested change.
372
+ */
373
+ renderWidth?: number; // requires "screenshot.renderSize"
374
+ renderHeight?: number; // requires "screenshot.renderSize"
375
+ /**
376
+ * Whether this capture may be taken as vertical BANDS and stitched, instead of as one image.
377
+ * Three-valued, and the middle value is the default:
378
+ *
379
+ * undefined AUTO. One shot when the projected output fits under the backend's per-image encode
380
+ * limit, banded when it does not. A capture that fits is byte-for-byte the call it
381
+ * always was — the same params on the wire, no clip synthesized that was not there
382
+ * before — so auto costs the common path nothing but the layout-metrics round trip
383
+ * the decision itself needs.
384
+ * false NEVER band. A capture past the limit is REFUSED by the encode guard instead. This
385
+ * is the escape hatch back to the exact pre-tiling behaviour.
386
+ * true ALWAYS band, even when one shot would have fitted. Mainly so the banded path is
387
+ * directly testable on an ordinary page; see the driver for why "always" is made to
388
+ * mean at least two bands rather than a one-band no-op.
389
+ *
390
+ * AUTO IS THE DEFAULT BECAUSE THE ALTERNATIVE IS A WEDGED TAB, not because banding is free.
391
+ * Measured (see MAX_ENCODED_SIDE_PX in ../cdp/driver.ts): a plain capture of a document past the
392
+ * limit does not fail fast — Page.captureScreenshot never answers, and because captureBeyondViewport
393
+ * resizes the page to the clip for the length of the capture, the tab is left stuck at that size
394
+ * with every later screenshot timing out too.
395
+ *
396
+ * Requires Capability "screenshot.tile" only for `true`. `undefined` and `false` ask a backend for
397
+ * nothing it cannot already do.
398
+ */
399
+ tile?: boolean; // `true` requires "screenshot.tile"
400
+ }
401
+
402
+ /**
403
+ * A capture too big for one image, handed to the tool layer band by band.
404
+ *
405
+ * LAZY BY CONTRACT, not by convenience: the Nth band's Page.captureScreenshot runs only when the
406
+ * consumer pulls the Nth band, so a driver never holds two bands at once and a whole 1.4 GB image
407
+ * never exists in memory. Accumulating the bands into an array first would typecheck, work on a
408
+ * small page, and defeat the entire point of the streaming stitcher on a large one.
409
+ *
410
+ * VERTICAL ONLY, AND THAT IS A SCOPE DECISION RATHER THAN AN OVERSIGHT. Bands stack top to bottom
411
+ * because that is the one direction a PNG can be concatenated without decoding it whole: scanlines
412
+ * are the unit, so appending rows is cheap and appending columns is not. A region whose projected
413
+ * WIDTH alone is past the limit is therefore refused, naming the width, rather than silently
414
+ * truncated or split into a grid this stitcher cannot reassemble.
415
+ */
416
+ export interface BandedCapture {
417
+ /** The bands, top to bottom, produced one at a time. */
418
+ bands: AsyncIterable<Uint8Array>;
419
+ /** How many bands the sequence will yield. Known before the first one is captured. */
420
+ bandCount: number;
421
+ /** Height of every band but the last, in CSS px. The last is the remainder. */
422
+ bandHeightCss: number;
423
+ /** The whole region being tiled, CSS px, in document coordinates. */
424
+ clip: { x: number; y: number; width: number; height: number };
425
+ /** Always "png": the stitch is a PNG-scanline concatenation, and there is no lossless JPEG one. */
426
+ format: "png";
427
+ }
428
+
429
+ /**
430
+ * The tool layer's half of a banded capture: consume the bands and return whatever was made of
431
+ * them. Called at most once per capture, BEFORE the first band is taken, so a consumer that cannot
432
+ * accept a banded capture at all (it was asked for JPEG, or for base64 of a 300 MB image) refuses
433
+ * by throwing here and no band is ever captured.
434
+ *
435
+ * This is why the driver takes a callback rather than returning the iterable: a banded capture may
436
+ * hold a viewport override for its whole duration, and the driver can only put that back once it
437
+ * knows the consumer is finished. Handing the iterable out would end the driver's call while the
438
+ * page was still emulated.
439
+ */
440
+ export type BandConsumer<T> = (capture: BandedCapture) => Promise<T>;
441
+
442
+ /** What a banded capture returns in place of bytes: the consumer's own answer, plus how it was split. */
443
+ export interface TiledScreenshotResult<T> {
444
+ tiled: true;
445
+ format: "png";
446
+ bandCount: number;
447
+ bandHeightCss: number;
448
+ /** Whatever the BandConsumer returned — for take_screenshot, the stitcher's measured report. */
449
+ consumed: T;
450
+ /** Same three render-size fields a one-shot capture carries, same meaning. */
451
+ renderSize?: { width: number; height: number };
452
+ renderRestored?: boolean;
453
+ renderRestoreError?: string;
454
+ }
455
+
456
+ /** One capture, delivered whole or in bands. Discriminate with isTiledCapture. */
457
+ export type CaptureDelivery<T> = ScreenshotResult | TiledScreenshotResult<T>;
458
+
459
+ export function isTiledCapture<T>(delivery: CaptureDelivery<T>): delivery is TiledScreenshotResult<T> {
460
+ return (delivery as { tiled?: unknown }).tiled === true;
461
+ }
462
+
463
+ /**
464
+ * What a capture returns. `data`/`format` are the whole answer for an ordinary capture; the three
465
+ * render-size fields are present only when renderWidth/renderHeight were applied.
466
+ *
467
+ * `renderRestored:false` IS THE LOUD PART, and it is why this is a result shape rather than a bare
468
+ * byte buffer. A failed restore means the caller's page is still emulated at the render size after
469
+ * this call returns — a mutation they did not ask to keep — so it has to travel back with the image
470
+ * instead of being swallowed. On the THROWING path there is no result to carry it, so a driver
471
+ * appends the same fact to the error message instead (see CdpPageDriver.screenshot).
472
+ */
473
+ export interface ScreenshotResult {
474
+ data: Uint8Array;
475
+ format: "png" | "jpeg";
476
+ /** The emulated viewport this capture ran at. Present only when renderWidth/renderHeight were given. */
477
+ renderSize?: { width: number; height: number };
478
+ /** Whether the previous viewport was put back. Present only when a render size was applied. */
479
+ renderRestored?: boolean;
480
+ /** Why the restore failed. Present only when renderRestored is false. */
481
+ renderRestoreError?: string;
482
+ }
483
+
484
+ /* --------------------- the viewport-override record (both drivers) --------------------- */
485
+
486
+ /**
487
+ * The device-metrics override this toolkit last applied to a target, as a file under the lease
488
+ * directory. Written by PageDriver.emulate, removed by emulate{clearOverrides}, read by
489
+ * PageDriver.screenshot so a render-size capture can put back what it displaced.
490
+ *
491
+ * WHY A FILE AND NOT A FIELD ON THE DRIVER. The spec for this feature called for a private field on
492
+ * the page driver, and that is provably not enough here — MEASURED against Chrome/151.0.7922.76:
493
+ *
494
+ * $ cli emulate --target T --width 800 --height 600 # one process, socket closes on exit
495
+ * $ cli evaluate_script --target T ...innerWidth... # a DIFFERENT process
496
+ * "[800,600,2]" # the override OUTLIVED the connection
497
+ *
498
+ * The CDP driver's LifetimeModel is "per-call": a PageDriver instance lives for exactly one tool
499
+ * call, so a field on it is empty by construction at the start of the screenshot call that needs
500
+ * it, and the override it must not destroy was set by a process that has already exited. Same
501
+ * reasoning leases.ts and origins.ts give for their own files, arrived at the same way.
502
+ *
503
+ * WHY THE RECORD IS LOAD-BEARING AND NOT A CONVENIENCE. Emulation.clearDeviceMetricsOverride does
504
+ * NOT mean "undo the override" — measured, twice, on the same tab:
505
+ *
506
+ * fresh connection, never set an override, clear it -> returns {}, page STAYS 800x600
507
+ * fresh connection, set 1920x1080, then clear -> page becomes 1390x1064, the REAL device
508
+ *
509
+ * So clearing is a no-op for an override this session did not set, and once a session DOES set one,
510
+ * clearing reverts to the physical device and takes any earlier override down with it. A
511
+ * render-size capture always sets one, so its clear always lands — which is exactly why restoring
512
+ * by clearing would silently destroy a caller's earlier `emulate` and why this record exists.
513
+ *
514
+ * WHAT IT CANNOT SEE, and this is a real limit rather than a caveat: an override applied by
515
+ * something that is not this toolkit — the DevTools UI, Puppeteer, another automation client, or a
516
+ * toolkit build older than this record — leaves no file, so a render-size capture restores by
517
+ * clearing and that foreign override is reset to the real device. There is no CDP or BiDi command
518
+ * that reads a device-metrics override back, so a driver genuinely cannot know it was there.
519
+ */
520
+ export interface ViewportRecord {
521
+ width: number;
522
+ height: number;
523
+ /** CDP's Emulation.setDeviceMetricsOverride sense: 0 means "the real device's". */
524
+ deviceScaleFactor?: number;
525
+ mobile?: boolean;
526
+ }
527
+
528
+ /** Sibling of leases.ts's lease-<backend>-<targetId>.json, in the same directory and with the same
529
+ * filename-safety policy. Keyed by the driver's uid SCHEME ("cdp" / "bidi") because a CDP targetId
530
+ * and a BiDi context id are not disjoint by construction — the same reason lease files carry a
531
+ * backend in the name. */
532
+ export function viewportRecordFile(scheme: string, targetId: string): string {
533
+ return join(leaseDir(), `viewport-${scheme}-${targetId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
534
+ }
535
+
536
+ /** The override this toolkit last applied to `targetId`, or undefined when it has applied none.
537
+ * A missing, unreadable, or malformed file all read as undefined: "we do not know" is the honest
538
+ * answer, and it is the one that makes the caller restore by clearing rather than by guessing. */
539
+ export async function readViewportRecord(scheme: string, targetId: string): Promise<ViewportRecord | undefined> {
540
+ try {
541
+ const raw = JSON.parse(await readFile(viewportRecordFile(scheme, targetId), "utf8")) as Partial<ViewportRecord>;
542
+ if (typeof raw?.width !== "number" || typeof raw?.height !== "number") return undefined;
543
+ return {
544
+ width: raw.width,
545
+ height: raw.height,
546
+ ...(typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {}),
547
+ ...(typeof raw.mobile === "boolean" ? { mobile: raw.mobile } : {}),
548
+ };
549
+ } catch {
550
+ return undefined;
551
+ }
552
+ }
553
+
554
+ /** Record an override this toolkit just applied. Deliberately best-effort: emulate's job is to
555
+ * emulate, and a full disk must not turn a working override into a thrown error. The cost of a
556
+ * lost write is stated above — a later render-size capture restores by clearing instead. */
557
+ export async function writeViewportRecord(scheme: string, targetId: string, rec: ViewportRecord): Promise<void> {
558
+ try {
559
+ await mkdir(leaseDir(), { recursive: true });
560
+ await writeFile(viewportRecordFile(scheme, targetId), JSON.stringify({ scheme, targetId, ...rec }), "utf8");
561
+ } catch {
562
+ /* best-effort, see above */
563
+ }
564
+ }
565
+
566
+ /**
567
+ * What a driver appends to a capture's error when the render-size restore ALSO failed. Shared by
568
+ * both drivers because the sentence is the caller's only warning that their page is still emulated,
569
+ * and it must not read differently depending on which browser stranded it. Says what is wrong AND
570
+ * how to undo it by hand, because at this point the toolkit has already failed to.
571
+ */
572
+ export function renderRestoreFailureNote(size: { width: number; height: number }, why: string): string {
573
+ return (
574
+ `— AND the ${size.width}x${size.height} render-size override could NOT be restored (${why}), so this page is STILL ` +
575
+ `emulated at ${size.width}x${size.height}: put it back with emulate{width,height} or emulate{clearOverrides:true}.`
576
+ );
577
+ }
578
+
579
+ /** Forget the recorded override. Best-effort for the same reason, and a missing file is success. */
580
+ export async function clearViewportRecord(scheme: string, targetId: string): Promise<void> {
581
+ try {
582
+ await unlink(viewportRecordFile(scheme, targetId));
583
+ } catch {
584
+ /* best-effort, see above */
585
+ }
347
586
  }
348
587
 
349
588
  /** Each optional field names the Capability it requires, if any. */
@@ -642,9 +881,21 @@ export interface PageDriver {
642
881
  setFiles(loc: ElementLocator, files: readonly string[]): Promise<void>;
643
882
 
644
883
  /* rendering */
645
- /** Returns raw image bytes. Artifact naming and writing stay in the tool. */
646
- screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }>;
647
- /** Apply overrides. Returns the field names actually applied. */
884
+ /** Returns raw image bytes. Artifact naming and writing stay in the tool. Under
885
+ * renderWidth/renderHeight it also reports the emulated size it ran at and whether the previous
886
+ * viewport was put back — see ScreenshotResult.
887
+ *
888
+ * `consumeBands` is how a caller says it can accept a capture too big to encode as one image
889
+ * (see BandedCapture). Omitting it is a real and honest state, not a shortcut: a caller with
890
+ * nowhere to put a band sequence gets the encode guard's refusal instead of a wedged tab, which
891
+ * is exactly the pre-tiling behaviour. Supplying it lets the driver band the capture when the
892
+ * projection demands it (the default) or when `tile:true` asks for it. The driver calls it at
893
+ * most once and awaits it, so the whole capture — every band — is finished before this method
894
+ * resolves and before any viewport override is put back. */
895
+ screenshot<T>(opts?: ScreenshotOptions, consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>>;
896
+ /** Apply overrides. Returns the field names actually applied. An implementation that applies
897
+ * device metrics MUST record them with writeViewportRecord (and clear the record under
898
+ * clearOverrides), or a later render-size capture will restore by clearing and destroy them. */
648
899
  emulate(opts: EmulationOptions): Promise<{ applied: string[] }>;
649
900
 
650
901
  /* events */