cdp-toolkit 1.9.0 → 1.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
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 {
|
|
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
|
|
49
|
-
|
|
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>
|
|
53
|
-
|
|
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 =
|
|
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
|
|
207
|
-
//
|
|
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
|
-
|
|
647
|
-
|
|
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 */
|