@crouton-kit/capture 1.3.19 → 1.3.21
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/bin/capture +159 -45
- package/package.json +1 -1
- package/src/cdp/args.ts +8 -1
- package/src/cdp/color-scheme.ts +47 -0
- package/src/cdp/commands/measure/index.ts +1 -1
- package/src/cdp/commands/measure/snap.ts +13 -6
- package/src/cdp/commands/page/click.ts +32 -4
- package/src/cdp/commands/page/index.ts +1 -1
- package/src/cdp/commands/page/scroll.ts +6 -4
- package/src/cdp/commands/page/shot.ts +33 -9
- package/src/cdp/commands/page/type.ts +6 -4
- package/src/cdp/measure/collectors/styles.ts +67 -43
- package/src/cdp/measure/snapshot.ts +26 -13
- package/src/cdp/measure/types.ts +4 -0
- package/src/cdp/types.ts +2 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CDPClient } from './client.js';
|
|
2
|
+
|
|
3
|
+
export type ColorScheme = 'dark' | 'light';
|
|
4
|
+
|
|
5
|
+
type MediaClient = Pick<CDPClient, 'send'>;
|
|
6
|
+
|
|
7
|
+
export function parseColorScheme(value: string | undefined): ColorScheme | undefined {
|
|
8
|
+
if (value === undefined) return undefined;
|
|
9
|
+
if (value === 'dark' || value === 'light') return value;
|
|
10
|
+
throw new Error('--color-scheme must be dark or light');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Applies one media-feature override only for the supplied capture operation. */
|
|
14
|
+
export async function withAppliedColorScheme<T>(client: MediaClient, colorScheme: ColorScheme | undefined, fn: () => Promise<T>): Promise<T> {
|
|
15
|
+
if (!colorScheme) return fn();
|
|
16
|
+
let ownsMediaOverride = false;
|
|
17
|
+
let primaryFailed = false;
|
|
18
|
+
let primaryError: unknown;
|
|
19
|
+
try {
|
|
20
|
+
// A rejected response does not prove Chrome rejected the request. Claim
|
|
21
|
+
// cleanup responsibility before awaiting it, just as viewport capture does.
|
|
22
|
+
ownsMediaOverride = true;
|
|
23
|
+
await client.send('Emulation.setEmulatedMedia', {
|
|
24
|
+
features: [{ name: 'prefers-color-scheme', value: colorScheme }],
|
|
25
|
+
});
|
|
26
|
+
return await fn();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
primaryFailed = true;
|
|
29
|
+
primaryError = error;
|
|
30
|
+
throw error;
|
|
31
|
+
} finally {
|
|
32
|
+
if (ownsMediaOverride) {
|
|
33
|
+
try {
|
|
34
|
+
await client.send('Emulation.setEmulatedMedia', { features: [] });
|
|
35
|
+
} catch (cleanupError) {
|
|
36
|
+
if (primaryFailed) {
|
|
37
|
+
throw new AggregateError(
|
|
38
|
+
[primaryError, cleanupError],
|
|
39
|
+
'Color-scheme capture failed and media-emulation cleanup also failed.',
|
|
40
|
+
{ cause: primaryError },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
throw cleanupError;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -36,7 +36,7 @@ Every leaf defaults to rendered prose; --json mirrors the same result.
|
|
|
36
36
|
Findings exit 0 — a report, not a failure.
|
|
37
37
|
\`--gate\` (exit 2 on findings/changes) is accepted only by check and diff.
|
|
38
38
|
|
|
39
|
-
<subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]..." whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
|
|
39
|
+
<subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]... [--color-scheme <dark|light>]" whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
|
|
40
40
|
<subcommand name="check" args="[url|snap] [--for <checks>] [--gate]" whenToUse="read threshold/fact measurements from one snapshot"/>
|
|
41
41
|
<subcommand name="diff" args="--before <snap> --after <snap> [--pixels] [--full] [--gate]" whenToUse="structured before/after delta between two snapshots"/>
|
|
42
42
|
<subcommand name="census" args="[--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>" whenToUse="value distributions across one or more snapshots"/>
|
|
@@ -6,6 +6,7 @@ import { type ParsedArgs } from '../../types.js';
|
|
|
6
6
|
import { withConnection } from '../../connection.js';
|
|
7
7
|
import { isRecorderHeldClient } from '../../recorder-client.js';
|
|
8
8
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
9
|
+
import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
|
|
9
10
|
import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
|
|
10
11
|
import type { CollectorOutcome } from '../../measure/types.js';
|
|
11
12
|
import { sanitizeString } from '../../measure/redaction.js';
|
|
@@ -36,8 +37,9 @@ input:
|
|
|
36
37
|
--pixels also write per-element raster crops
|
|
37
38
|
--state <state[:selector]> force a pseudo-state or real control state (repeatable)
|
|
38
39
|
--viewport <WxH> temporarily capture at a CSS-pixel viewport (repeatable); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
|
|
40
|
+
--color-scheme <dark|light> temporarily capture under this prefers-color-scheme value; cleared afterward
|
|
39
41
|
output: <snapshot id=… path=…> — the settled artifact directory (geometry, styles, hit-test, text, forms, animation, ax, queries, focus, scroll, layers; per-element crops with --pixels) every other measure/motion query leaf reads instead of re-driving the browser; --json mirrors
|
|
40
|
-
effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session
|
|
42
|
+
effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session. --color-scheme transiently applies Emulation.setEmulatedMedia and clears it after capture.`;
|
|
41
43
|
|
|
42
44
|
interface SnapshotMetaForBase {
|
|
43
45
|
readonly url?: string | null;
|
|
@@ -216,7 +218,7 @@ function settledNote(settled: boolean, captured: boolean, settleMs: number, sett
|
|
|
216
218
|
* seam rather than importing the substrate directly, preserving snap as the
|
|
217
219
|
* sole live-driving primitive.
|
|
218
220
|
*/
|
|
219
|
-
export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.positional[0], viewportValue = parsed.viewport): Promise<MeasureSnapCapture> {
|
|
221
|
+
export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.positional[0], viewportValue = parsed.viewport, colorSchemeValue = parsed.colorScheme): Promise<MeasureSnapCapture> {
|
|
220
222
|
if (parsed.positional.length > 1) {
|
|
221
223
|
throw invalidInput('measure snap accepts at most one positional URL or snapshot reference');
|
|
222
224
|
}
|
|
@@ -231,6 +233,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
231
233
|
const viewport: ViewportSpec | undefined = viewportValue === undefined
|
|
232
234
|
? undefined
|
|
233
235
|
: { label: viewportValue, ...parseViewport(viewportValue) };
|
|
236
|
+
const colorScheme = parseColorScheme(colorSchemeValue);
|
|
234
237
|
|
|
235
238
|
let base: SnapRef | undefined;
|
|
236
239
|
let baseUrl: string | null = null;
|
|
@@ -256,7 +259,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
256
259
|
try {
|
|
257
260
|
let artifacts: readonly string[] = [];
|
|
258
261
|
await withConnection(connectionArgs, async (client, tab) => {
|
|
259
|
-
await withAppliedViewport(client, viewport, async () => {
|
|
262
|
+
await withAppliedColorScheme(client, colorScheme, async () => withAppliedViewport(client, viewport, async () => {
|
|
260
263
|
const oneshot = active ? undefined : createOneshotSession('measure');
|
|
261
264
|
const destination = active ? path.join(active.dir, 'measure', 'snaps') : oneshot!.artifactsDir;
|
|
262
265
|
snapDir = path.join(destination, snapId);
|
|
@@ -272,10 +275,11 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
272
275
|
pixels: parsed.pixels,
|
|
273
276
|
state: parsed.state ?? [],
|
|
274
277
|
viewport: viewport?.label ?? null,
|
|
278
|
+
colorScheme,
|
|
275
279
|
skipCollectors: parsed.skipCollector ?? [],
|
|
276
280
|
});
|
|
277
281
|
artifacts = result.artifacts;
|
|
278
|
-
});
|
|
282
|
+
}));
|
|
279
283
|
}, { settle: 0 });
|
|
280
284
|
|
|
281
285
|
if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
|
|
@@ -290,6 +294,7 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
290
294
|
const meta = readMeta<{
|
|
291
295
|
url: string | null;
|
|
292
296
|
viewport: string | null;
|
|
297
|
+
colorScheme?: ColorScheme;
|
|
293
298
|
settled: boolean;
|
|
294
299
|
settleMs: number;
|
|
295
300
|
settleTimeoutMs: number;
|
|
@@ -313,6 +318,7 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
313
318
|
attrs: {
|
|
314
319
|
url: meta.url ?? undefined,
|
|
315
320
|
viewport: meta.viewport ?? undefined,
|
|
321
|
+
'color-scheme': meta.colorScheme,
|
|
316
322
|
elements,
|
|
317
323
|
settled: meta.settled,
|
|
318
324
|
'settle-ms': meta.settleMs,
|
|
@@ -358,7 +364,7 @@ export function classifySnapError(err: unknown): SnapErrorDetails {
|
|
|
358
364
|
if (message.startsWith('--viewport cannot ')) {
|
|
359
365
|
return { status: 'viewport_unavailable', message };
|
|
360
366
|
}
|
|
361
|
-
if (message.startsWith('measure snap accepts') || message.startsWith('--settle-timeout') || message.startsWith('--viewport')) {
|
|
367
|
+
if (message.startsWith('measure snap accepts') || message.startsWith('--settle-timeout') || message.startsWith('--viewport') || message.startsWith('--color-scheme')) {
|
|
362
368
|
return { status: 'invalid_input', message };
|
|
363
369
|
}
|
|
364
370
|
if (message.includes('No tab found') || message.includes('Use --target') || message.includes('Tab has no WebSocket debugger URL')) {
|
|
@@ -408,9 +414,10 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
|
|
|
408
414
|
if (value === undefined) return undefined;
|
|
409
415
|
return { label: value, ...parseViewport(value) };
|
|
410
416
|
});
|
|
417
|
+
const colorScheme = parseColorScheme(parsed.colorScheme);
|
|
411
418
|
const results = [] as RenderableResult[];
|
|
412
419
|
for (const viewport of viewports) {
|
|
413
|
-
const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label);
|
|
420
|
+
const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label, colorScheme);
|
|
414
421
|
captures.push(capture);
|
|
415
422
|
results.push(buildSnapshotResult(capture));
|
|
416
423
|
}
|
|
@@ -52,6 +52,33 @@ export function pageInputDeps(): PageInputDeps {
|
|
|
52
52
|
return deps;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface PageInputScreenshot {
|
|
56
|
+
readonly screenshot: string | null;
|
|
57
|
+
readonly screenshotWarning: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A dispatched page action stays successful when only its follow-up screenshot times out. */
|
|
61
|
+
export async function capturePageInputScreenshot(
|
|
62
|
+
client: Parameters<typeof autoScreenshot>[0],
|
|
63
|
+
action: string,
|
|
64
|
+
label: string,
|
|
65
|
+
noScreenshot?: boolean,
|
|
66
|
+
): Promise<PageInputScreenshot> {
|
|
67
|
+
try {
|
|
68
|
+
return {
|
|
69
|
+
screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
|
|
70
|
+
screenshotWarning: null,
|
|
71
|
+
};
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
if (!/^CDP request timeout \(\d+ms\): Page\.captureScreenshot$/.test(message)) throw error;
|
|
75
|
+
return {
|
|
76
|
+
screenshot: null,
|
|
77
|
+
screenshotWarning: `automatic screenshot timed out after the page action completed; no screenshot was saved (${message})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
/**
|
|
56
83
|
* The verb's settle window: `--settle <ms>` (including 0) wins outright;
|
|
57
84
|
* otherwise the default is keyed off active-session presence (the in-session
|
|
@@ -147,7 +174,7 @@ input:
|
|
|
147
174
|
--settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
|
|
148
175
|
--no-screenshot skip the auto-screenshot
|
|
149
176
|
output:
|
|
150
|
-
<clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
|
|
177
|
+
<clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
|
|
151
178
|
effects:
|
|
152
179
|
scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
153
180
|
|
|
@@ -177,8 +204,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
177
204
|
const resolved = await resolveLiveTarget(live, target);
|
|
178
205
|
if (!resolved.ok) return { failure: resolved } as const;
|
|
179
206
|
const dispatch = await clickResolved(live, resolved);
|
|
180
|
-
const
|
|
181
|
-
return { dispatch,
|
|
207
|
+
const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
|
|
208
|
+
return { dispatch, ...screenshotResult } as const;
|
|
182
209
|
},
|
|
183
210
|
);
|
|
184
211
|
|
|
@@ -186,12 +213,13 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
186
213
|
return emitResolutionError(parsed, 'page click', outcome.failure);
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
const { dispatch, screenshot } = outcome;
|
|
216
|
+
const { dispatch, screenshot, screenshotWarning } = outcome;
|
|
190
217
|
const rows: FactLine[] = [
|
|
191
218
|
fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
|
|
192
219
|
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
193
220
|
];
|
|
194
221
|
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
222
|
+
if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
|
|
195
223
|
|
|
196
224
|
emitResult(
|
|
197
225
|
{
|
|
@@ -39,7 +39,7 @@ During a live composed recording every verb routes through the recorder.
|
|
|
39
39
|
<subcommand name="scroll" args="<target> --to <top|bottom|px> [--no-screenshot]" whenToUse="scroll one resolved container to a position"/>
|
|
40
40
|
<subcommand name="navigate" args="<url> [--settle <ms>]" whenToUse="navigate the tab and wait for load + settle"/>
|
|
41
41
|
<subcommand name="exec" args="<code> | --file <path>" whenToUse="run arbitrary JS in the tab (expressions, return, await)"/>
|
|
42
|
-
<subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--out <path>]" whenToUse="look at the page right now without acting"/>
|
|
42
|
+
<subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]" whenToUse="look at the page right now without acting"/>
|
|
43
43
|
<subcommand name="elements" args="[--all] [--limit <n>]" whenToUse="list what can be acted on — role, name, backend:<id> per element"/>
|
|
44
44
|
|
|
45
45
|
capture page <leaf> -h Per-leaf usage`;
|
|
@@ -13,6 +13,7 @@ import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../int
|
|
|
13
13
|
import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
|
|
14
14
|
import {
|
|
15
15
|
pageInputDeps,
|
|
16
|
+
capturePageInputScreenshot,
|
|
16
17
|
effectiveSettle,
|
|
17
18
|
emitInvalidInput,
|
|
18
19
|
emitResolutionError,
|
|
@@ -26,7 +27,7 @@ input:
|
|
|
26
27
|
--settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
|
|
27
28
|
--no-screenshot skip the auto-screenshot
|
|
28
29
|
output:
|
|
29
|
-
<scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
|
|
30
|
+
<scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
|
|
30
31
|
effects:
|
|
31
32
|
assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
32
33
|
|
|
@@ -74,8 +75,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
74
75
|
// Same landmark shape `motion rec --do scroll:` records; carried by
|
|
75
76
|
// the one mutating call when the transport records landmarks.
|
|
76
77
|
const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
|
|
77
|
-
const
|
|
78
|
-
return { dispatch,
|
|
78
|
+
const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
|
|
79
|
+
return { dispatch, ...screenshotResult } as const;
|
|
79
80
|
},
|
|
80
81
|
);
|
|
81
82
|
|
|
@@ -83,12 +84,13 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
83
84
|
return emitResolutionError(parsed, 'page scroll', outcome.failure);
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
const { dispatch, screenshot } = outcome;
|
|
87
|
+
const { dispatch, screenshot, screenshotWarning } = outcome;
|
|
87
88
|
const rows: FactLine[] = [
|
|
88
89
|
fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
|
|
89
90
|
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
90
91
|
];
|
|
91
92
|
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
93
|
+
if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
|
|
92
94
|
|
|
93
95
|
emitResult(
|
|
94
96
|
{
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* The navigational look: vision as navigator, never inspector — cheap, no
|
|
5
5
|
* settling, no collectors. With no flags it captures the browser's ACTUAL
|
|
6
6
|
* current viewport and performs zero `Emulation.*` calls; emulation exists
|
|
7
|
-
* only when explicitly asked (`--viewport <WxH>` / `--full-page`
|
|
8
|
-
*
|
|
7
|
+
* only when explicitly asked (`--viewport <WxH>` / `--full-page` /
|
|
8
|
+
* `--color-scheme dark|light`), applied transiently; viewport emulation uses
|
|
9
|
+
* `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
|
|
9
10
|
* and cleared after — two page-observable resizes, declared as the leaf's
|
|
10
11
|
* effect in `-h` (I-1/I-2 posture). Explicit viewport emulation uses the
|
|
11
12
|
* single `WxH` grammar (D10).
|
|
@@ -20,6 +21,7 @@ import { type ParsedArgs } from '../../types.js';
|
|
|
20
21
|
import { withConnection } from '../../connection.js';
|
|
21
22
|
import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
|
|
22
23
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
24
|
+
import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
|
|
23
25
|
import { nextStepPath } from '../../../session-context.js';
|
|
24
26
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
25
27
|
import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
|
|
@@ -36,13 +38,14 @@ const USAGE = `capture page shot — capture a PNG of the tab as it is right now
|
|
|
36
38
|
input:
|
|
37
39
|
--viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
|
|
38
40
|
--full-page transient override to the full scrollable content height for this capture
|
|
41
|
+
--color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
|
|
39
42
|
--out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
|
|
40
43
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
41
44
|
--json mirror the result as JSON
|
|
42
45
|
output:
|
|
43
|
-
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and
|
|
46
|
+
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and any requested transient emulation
|
|
44
47
|
effects:
|
|
45
|
-
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls.
|
|
48
|
+
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture.`;
|
|
46
49
|
|
|
47
50
|
// ---------------------------------------------------------------------------
|
|
48
51
|
// Test-injectable dependency seam (the CDP-stub test pattern; the capture
|
|
@@ -128,6 +131,7 @@ export function buildScreenshotResult(f: {
|
|
|
128
131
|
cssViewport?: CapturedRegion;
|
|
129
132
|
emulation: EmulationMode;
|
|
130
133
|
viewport?: { width: number; height: number };
|
|
134
|
+
colorScheme?: ColorScheme;
|
|
131
135
|
}): RenderableResult {
|
|
132
136
|
// The denominator is the CSS region the image was actually cut from, so the
|
|
133
137
|
// ratio stays true under page zoom, device pixel ratio, and the local
|
|
@@ -140,12 +144,15 @@ export function buildScreenshotResult(f: {
|
|
|
140
144
|
? fact`CSS-to-image scale: ${scale.x.toFixed(6)} image px/CSS px horizontally and ${scale.y.toFixed(6)} image px/CSS px vertically (captured CSS region ${round6(f.cssViewport.width)}×${round6(f.cssViewport.height)} at page origin ${round6(f.cssViewport.x)},${round6(f.cssViewport.y)}).`
|
|
141
145
|
: text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
|
|
142
146
|
f.emulation === 'none'
|
|
143
|
-
? text`emulation: none — the browser's actual current viewport was captured
|
|
147
|
+
? text`viewport emulation: none — the browser's actual current viewport was captured.`
|
|
144
148
|
: f.emulation === 'viewport'
|
|
145
149
|
? fact`emulation: transient ${f.viewport!.width}x${f.viewport!.height} device-metrics override applied for the capture and cleared after — two page-observable resizes.`
|
|
146
150
|
: f.viewport
|
|
147
151
|
? fact`emulation: transient full-page device-metrics override (from ${f.viewport.width}x${f.viewport.height}) applied for the capture and cleared after — two page-observable resizes.`
|
|
148
152
|
: text`emulation: transient full-page device-metrics override applied for the capture and cleared after — two page-observable resizes.`,
|
|
153
|
+
f.colorScheme
|
|
154
|
+
? fact`color scheme: transient prefers-color-scheme=${f.colorScheme} override applied for the capture and cleared after.`
|
|
155
|
+
: text`color scheme: browser default — no Emulation.setEmulatedMedia call was made.`,
|
|
149
156
|
];
|
|
150
157
|
return {
|
|
151
158
|
tag: 'screenshot',
|
|
@@ -160,6 +167,7 @@ export function buildScreenshotResult(f: {
|
|
|
160
167
|
'css-to-image-x': scale?.x.toFixed(6),
|
|
161
168
|
'css-to-image-y': scale?.y.toFixed(6),
|
|
162
169
|
emulation: f.emulation,
|
|
170
|
+
'color-scheme': f.colorScheme,
|
|
163
171
|
},
|
|
164
172
|
summary: f.dimensions
|
|
165
173
|
? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
|
|
@@ -183,7 +191,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
183
191
|
{
|
|
184
192
|
tag: 'error',
|
|
185
193
|
attrs: { command: 'page shot', code: 'invalid_input' },
|
|
186
|
-
summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--out <path>]\`.`,
|
|
194
|
+
summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]\`.`,
|
|
187
195
|
},
|
|
188
196
|
{ json: parsed.json },
|
|
189
197
|
);
|
|
@@ -209,6 +217,22 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
209
217
|
}
|
|
210
218
|
}
|
|
211
219
|
|
|
220
|
+
let colorScheme: ColorScheme | undefined;
|
|
221
|
+
try {
|
|
222
|
+
colorScheme = parseColorScheme(parsed.colorScheme);
|
|
223
|
+
} catch {
|
|
224
|
+
emitResult(
|
|
225
|
+
{
|
|
226
|
+
tag: 'error',
|
|
227
|
+
attrs: { command: 'page shot', code: 'invalid_color_scheme' },
|
|
228
|
+
summary: fact`received: --color-scheme ${parsed.colorScheme}; expected dark or light.`,
|
|
229
|
+
},
|
|
230
|
+
{ json: parsed.json },
|
|
231
|
+
);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
|
|
213
237
|
|
|
214
238
|
// connection.ts derives the recorder-routed action label from
|
|
@@ -216,14 +240,14 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
216
240
|
// restore the verb so stderr diagnostics identify this leaf.
|
|
217
241
|
const result = await deps.withConnection(
|
|
218
242
|
{ ...parsed, command: 'shot' },
|
|
219
|
-
async (client) => {
|
|
243
|
+
async (client) => withAppliedColorScheme(client, colorScheme, async () => {
|
|
220
244
|
const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
|
|
221
245
|
const outPath = await resolveOutPath(parsed);
|
|
222
246
|
writeScreenshot(outPath, screenshot.png);
|
|
223
247
|
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
|
|
224
|
-
},
|
|
248
|
+
}),
|
|
225
249
|
{ settle: 0 },
|
|
226
250
|
);
|
|
227
251
|
|
|
228
|
-
emitResult(buildScreenshotResult({ ...result, emulation, viewport }), { json: parsed.json });
|
|
252
|
+
emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
|
|
229
253
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
|
|
19
19
|
import {
|
|
20
20
|
pageInputDeps,
|
|
21
|
+
capturePageInputScreenshot,
|
|
21
22
|
effectiveSettle,
|
|
22
23
|
emitInvalidInput,
|
|
23
24
|
emitResolutionError,
|
|
@@ -31,7 +32,7 @@ input:
|
|
|
31
32
|
--settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
|
|
32
33
|
--no-screenshot skip the auto-screenshot
|
|
33
34
|
output:
|
|
34
|
-
<typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
|
|
35
|
+
<typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
|
|
35
36
|
effects:
|
|
36
37
|
dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
37
38
|
|
|
@@ -69,8 +70,8 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
69
70
|
await typeText(live, textArg);
|
|
70
71
|
}
|
|
71
72
|
// Screenshot label identifies the field, never the typed content.
|
|
72
|
-
const
|
|
73
|
-
return { dispatch,
|
|
73
|
+
const screenshotResult = await capturePageInputScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
|
|
74
|
+
return { dispatch, ...screenshotResult } as const;
|
|
74
75
|
},
|
|
75
76
|
);
|
|
76
77
|
|
|
@@ -78,7 +79,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
78
79
|
return emitResolutionError(parsed, 'page type', outcome.failure);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const { dispatch, screenshot } = outcome;
|
|
82
|
+
const { dispatch, screenshot, screenshotWarning } = outcome;
|
|
82
83
|
const rows: FactLine[] = [
|
|
83
84
|
dispatch
|
|
84
85
|
? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
|
|
@@ -86,6 +87,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
86
87
|
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
87
88
|
];
|
|
88
89
|
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
90
|
+
if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
|
|
89
91
|
|
|
90
92
|
emitResult(
|
|
91
93
|
{
|
|
@@ -35,6 +35,8 @@ export type { Specificity, CSSRange, WinningDeclaration } from './style-provenan
|
|
|
35
35
|
|
|
36
36
|
const STYLES_MAX_ELEMENTS = 150;
|
|
37
37
|
const PROVENANCE_CONCURRENCY = 8;
|
|
38
|
+
/** Bounds queued provenance work without abandoning in-flight CDP requests on the shared snap connection. Matches snapshot.ts's per-request bound but remains local to avoid a collector/orchestrator import cycle. */
|
|
39
|
+
const PROVENANCE_BUDGET_MS = 5_000;
|
|
38
40
|
|
|
39
41
|
/** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
|
|
40
42
|
const COMPUTED_PROPERTIES = [
|
|
@@ -95,6 +97,10 @@ export interface StylesCoverage {
|
|
|
95
97
|
keptElements: number;
|
|
96
98
|
/** `true` when {@link STYLES_MAX_ELEMENTS} dropped one or more candidate elements from enumeration — an explicit fact (I-5), never a silent cap. */
|
|
97
99
|
elementsTruncated: boolean;
|
|
100
|
+
/** `true` when the bounded provenance pass stopped starting element reads before every enumerated element was inspected; completed in-flight reads are still included. */
|
|
101
|
+
provenanceTruncated: boolean;
|
|
102
|
+
/** Present only when {@link provenanceTruncated} is true; names the factual bound that stopped additional provenance requests. */
|
|
103
|
+
provenanceTruncationReason?: 'collector-time-budget';
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
export interface StylesReport {
|
|
@@ -205,18 +211,33 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
|
|
|
205
211
|
return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Limits expensive per-element CDP provenance reads without changing document
|
|
216
|
+
* order. Once its budget expires it starts no more reads, but awaits the
|
|
217
|
+
* current worker batch so no CDP work is left running under the next collector.
|
|
218
|
+
*/
|
|
219
|
+
async function mapWithConcurrency<T, R>(
|
|
220
|
+
values: readonly T[],
|
|
221
|
+
limit: number,
|
|
222
|
+
budgetMs: number,
|
|
223
|
+
fn: (value: T, index: number) => Promise<R>,
|
|
224
|
+
): Promise<{ values: Array<R | undefined>; truncated: boolean }> {
|
|
225
|
+
const result = Array<R | undefined>(values.length).fill(undefined);
|
|
226
|
+
const deadline = Date.now() + budgetMs;
|
|
211
227
|
let next = 0;
|
|
228
|
+
let truncated = false;
|
|
212
229
|
const worker = async (): Promise<void> => {
|
|
213
230
|
while (next < values.length) {
|
|
231
|
+
if (Date.now() >= deadline) {
|
|
232
|
+
truncated = true;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
214
235
|
const index = next++;
|
|
215
236
|
result[index] = await fn(values[index]!, index);
|
|
216
237
|
}
|
|
217
238
|
};
|
|
218
239
|
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
|
|
219
|
-
return result;
|
|
240
|
+
return { values: result, truncated };
|
|
220
241
|
}
|
|
221
242
|
|
|
222
243
|
// ============================================================================
|
|
@@ -305,48 +326,49 @@ async function collectStylesInner(
|
|
|
305
326
|
|
|
306
327
|
const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
|
|
307
328
|
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
winningDeclarations = [];
|
|
334
|
-
provenanceUnavailable = true;
|
|
335
|
-
}
|
|
336
|
-
} else {
|
|
329
|
+
const provenance = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, async (fact, index): Promise<StylesElementRecord> => {
|
|
330
|
+
const ref = resolved[index];
|
|
331
|
+
const computed = normalizeComputed(fact.computed);
|
|
332
|
+
|
|
333
|
+
// A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
|
|
334
|
+
// no candidate for a property — an honest "nothing declares this" fact. When provenance itself
|
|
335
|
+
// couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
|
|
336
|
+
// fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
|
|
337
|
+
// is set, rather than conflating the two into misleading no-declaration records.
|
|
338
|
+
let winningDeclarations: WinningDeclaration[];
|
|
339
|
+
let provenanceUnavailable: boolean | undefined;
|
|
340
|
+
if (ref?.nodeId !== undefined) {
|
|
341
|
+
try {
|
|
342
|
+
const matched = (await client.send('CSS.getMatchedStylesForNode', {
|
|
343
|
+
nodeId: ref.nodeId,
|
|
344
|
+
})) as CDPMatchedStylesResponse;
|
|
345
|
+
winningDeclarations = await buildWinningDeclarations(
|
|
346
|
+
client,
|
|
347
|
+
matched,
|
|
348
|
+
computed,
|
|
349
|
+
sourceCache,
|
|
350
|
+
styleSheetUrls,
|
|
351
|
+
PROVENANCE_PROPERTIES,
|
|
352
|
+
);
|
|
353
|
+
} catch {
|
|
337
354
|
winningDeclarations = [];
|
|
338
355
|
provenanceUnavailable = true;
|
|
339
356
|
}
|
|
357
|
+
} else {
|
|
358
|
+
winningDeclarations = [];
|
|
359
|
+
provenanceUnavailable = true;
|
|
360
|
+
}
|
|
340
361
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
362
|
+
return {
|
|
363
|
+
id: `s-${index}`,
|
|
364
|
+
selector: sanitizeString(fact.cssPath),
|
|
365
|
+
...resolvedIdentity(ref?.backendNodeId),
|
|
366
|
+
computed,
|
|
367
|
+
winningDeclarations,
|
|
368
|
+
provenanceUnavailable,
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
const elements = provenance.values.filter((element): element is StylesElementRecord => element !== undefined);
|
|
350
372
|
|
|
351
373
|
ctx.write.json('styles.json', {
|
|
352
374
|
elements,
|
|
@@ -356,7 +378,9 @@ async function collectStylesInner(
|
|
|
356
378
|
shadowRootsNotWalked: inventory.shadowRootsNotWalked,
|
|
357
379
|
totalCandidateElements: inventory.total,
|
|
358
380
|
keptElements: elements.length,
|
|
359
|
-
elementsTruncated: inventory.total >
|
|
381
|
+
elementsTruncated: inventory.total > facts.length,
|
|
382
|
+
provenanceTruncated: provenance.truncated,
|
|
383
|
+
...(provenance.truncated ? { provenanceTruncationReason: 'collector-time-budget' as const } : {}),
|
|
360
384
|
},
|
|
361
385
|
styleSheetHeaders: styleSheetHeaders,
|
|
362
386
|
identity,
|