@crouton-kit/capture 1.3.18 → 1.3.20
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 +4204 -3244
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +22 -3
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -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 +50 -8
- package/src/cdp/commands/motion/mask.ts +8 -2
- package/src/cdp/commands/page/click.ts +14 -0
- package/src/cdp/commands/page/elements.ts +2 -2
- package/src/cdp/commands/page/index.ts +1 -1
- package/src/cdp/commands/page/shot.ts +58 -13
- package/src/cdp/commands/tab/index.ts +19 -8
- package/src/cdp/commands/tab/launch.ts +73 -0
- package/src/cdp/commands/tab/list.ts +14 -2
- package/src/cdp/commands/tab/quit.ts +78 -0
- package/src/cdp/har-recorder.ts +14 -20
- package/src/cdp/measure/collectors/ax.ts +3 -4
- package/src/cdp/measure/collectors/forms.ts +4 -6
- package/src/cdp/measure/collectors/media.ts +1 -2
- package/src/cdp/measure/collectors/states.ts +1 -6
- package/src/cdp/measure/collectors/style-provenance.ts +4 -16
- package/src/cdp/measure/collectors/styles.ts +21 -10
- package/src/cdp/measure/collectors/text.ts +3 -5
- package/src/cdp/measure/snapshot.ts +197 -29
- package/src/cdp/measure/types.ts +36 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +6 -0
- package/src/har-manager.ts +16 -15
- package/src/interact.ts +20 -4
- package/src/output/render.ts +2 -0
- package/src/registry.ts +1 -1
- package/src/session/commands.ts +223 -43
- package/src/session/har-redact.ts +125 -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,7 +6,9 @@ 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 {
|
|
9
|
+
import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
|
|
10
|
+
import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
|
|
11
|
+
import type { CollectorOutcome } from '../../measure/types.js';
|
|
10
12
|
import { sanitizeString } from '../../measure/redaction.js';
|
|
11
13
|
import { DEFAULT_SETTLE_TIMEOUT_MS } from '../../measure/settle.js';
|
|
12
14
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
@@ -31,11 +33,13 @@ input:
|
|
|
31
33
|
--freeze-animations pause CSS/WAAPI animation before capture
|
|
32
34
|
--settle-timeout <ms> override the default 5000ms settle wait
|
|
33
35
|
--capture-unsettled write the full substrate despite non-settlement, marking unstable regions
|
|
36
|
+
--skip-collector <name> leave one collector out entirely (repeatable): ax, styles, queries, animation, layers, media, geometry, hittest, text, forms, focus, scroll, states, pixels. A collector that fails is recorded and skipped; every other collector's substrate is still written
|
|
34
37
|
--pixels also write per-element raster crops
|
|
35
38
|
--state <state[:selector]> force a pseudo-state or real control state (repeatable)
|
|
36
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
|
|
37
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
|
|
38
|
-
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.`;
|
|
39
43
|
|
|
40
44
|
interface SnapshotMetaForBase {
|
|
41
45
|
readonly url?: string | null;
|
|
@@ -188,6 +192,17 @@ export async function withAppliedViewport<T>(client: ViewportClient, viewport: V
|
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
/** One line naming every collector whose substrate is absent or degraded, and what to do about it. */
|
|
196
|
+
function collectorNote(incomplete: readonly CollectorOutcome[]) {
|
|
197
|
+
const described = incomplete
|
|
198
|
+
.map((c) => `${sanitizeString(c.name)} (${c.status}${c.requestTimeouts ? `, ${c.requestTimeouts} timed-out request(s)` : ''})`)
|
|
199
|
+
.join(', ');
|
|
200
|
+
const failed = incomplete.some((c) => c.status === 'error' || c.status === 'degraded');
|
|
201
|
+
return fact`collectors incomplete: ${described}. Every other collector's substrate is valid and queryable.${
|
|
202
|
+
failed ? ` Re-run with \`--skip-collector <name>\` to omit one deliberately rather than have it fail.` : ''
|
|
203
|
+
}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
191
206
|
function settledNote(settled: boolean, captured: boolean, settleMs: number, settleTimeoutMs: number, unstableCount: number) {
|
|
192
207
|
if (settled) {
|
|
193
208
|
return fact`Settled after ${settleMs}ms (two consecutive identical captures; DOM quiet ≥300ms).`;
|
|
@@ -203,16 +218,22 @@ function settledNote(settled: boolean, captured: boolean, settleMs: number, sett
|
|
|
203
218
|
* seam rather than importing the substrate directly, preserving snap as the
|
|
204
219
|
* sole live-driving primitive.
|
|
205
220
|
*/
|
|
206
|
-
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> {
|
|
207
222
|
if (parsed.positional.length > 1) {
|
|
208
223
|
throw invalidInput('measure snap accepts at most one positional URL or snapshot reference');
|
|
209
224
|
}
|
|
210
225
|
if (parsed.settleTimeout !== undefined && (!Number.isFinite(parsed.settleTimeout) || parsed.settleTimeout <= 0)) {
|
|
211
226
|
throw invalidInput('--settle-timeout must be a positive number of milliseconds');
|
|
212
227
|
}
|
|
228
|
+
for (const name of parsed.skipCollector ?? []) {
|
|
229
|
+
if (!COLLECTOR_NAMES.includes(name)) {
|
|
230
|
+
throw invalidInput(`--skip-collector must name a collector: ${COLLECTOR_NAMES.join(', ')} (got ${sanitizeString(name)})`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
213
233
|
const viewport: ViewportSpec | undefined = viewportValue === undefined
|
|
214
234
|
? undefined
|
|
215
235
|
: { label: viewportValue, ...parseViewport(viewportValue) };
|
|
236
|
+
const colorScheme = parseColorScheme(colorSchemeValue);
|
|
216
237
|
|
|
217
238
|
let base: SnapRef | undefined;
|
|
218
239
|
let baseUrl: string | null = null;
|
|
@@ -238,7 +259,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
238
259
|
try {
|
|
239
260
|
let artifacts: readonly string[] = [];
|
|
240
261
|
await withConnection(connectionArgs, async (client, tab) => {
|
|
241
|
-
await withAppliedViewport(client, viewport, async () => {
|
|
262
|
+
await withAppliedColorScheme(client, colorScheme, async () => withAppliedViewport(client, viewport, async () => {
|
|
242
263
|
const oneshot = active ? undefined : createOneshotSession('measure');
|
|
243
264
|
const destination = active ? path.join(active.dir, 'measure', 'snaps') : oneshot!.artifactsDir;
|
|
244
265
|
snapDir = path.join(destination, snapId);
|
|
@@ -254,15 +275,17 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
254
275
|
pixels: parsed.pixels,
|
|
255
276
|
state: parsed.state ?? [],
|
|
256
277
|
viewport: viewport?.label ?? null,
|
|
278
|
+
colorScheme,
|
|
279
|
+
skipCollectors: parsed.skipCollector ?? [],
|
|
257
280
|
});
|
|
258
281
|
artifacts = result.artifacts;
|
|
259
|
-
});
|
|
282
|
+
}));
|
|
260
283
|
}, { settle: 0 });
|
|
261
284
|
|
|
262
285
|
if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
|
|
263
286
|
return { id: snapId, dir: snapDir, artifacts, ...(base ? { base } : {}) };
|
|
264
287
|
} catch (err) {
|
|
265
|
-
if (allocatedRoot) removeArtifactTree(allocatedRoot);
|
|
288
|
+
if (allocatedRoot && !(err instanceof SnapshotCaptureTimeout)) removeArtifactTree(allocatedRoot);
|
|
266
289
|
throw err;
|
|
267
290
|
}
|
|
268
291
|
}
|
|
@@ -271,12 +294,15 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
271
294
|
const meta = readMeta<{
|
|
272
295
|
url: string | null;
|
|
273
296
|
viewport: string | null;
|
|
297
|
+
colorScheme?: ColorScheme;
|
|
274
298
|
settled: boolean;
|
|
275
299
|
settleMs: number;
|
|
276
300
|
settleTimeoutMs: number;
|
|
277
301
|
unstableRegionCount: number;
|
|
278
302
|
states: readonly string[];
|
|
303
|
+
collectors?: readonly CollectorOutcome[];
|
|
279
304
|
}>({ kind: 'snap', id: captured.id, dir: captured.dir });
|
|
305
|
+
const incomplete = (meta.collectors ?? []).filter((c) => c.status !== 'ok');
|
|
280
306
|
const elements = readArrayCount(path.join(captured.dir, 'geometry.json'), 'elements');
|
|
281
307
|
const artifacts = artifactEntries(captured.dir, captured.artifacts);
|
|
282
308
|
const capturedFullSubstrate = fs.existsSync(path.join(captured.dir, 'geometry.json'));
|
|
@@ -292,6 +318,7 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
292
318
|
attrs: {
|
|
293
319
|
url: meta.url ?? undefined,
|
|
294
320
|
viewport: meta.viewport ?? undefined,
|
|
321
|
+
'color-scheme': meta.colorScheme,
|
|
295
322
|
elements,
|
|
296
323
|
settled: meta.settled,
|
|
297
324
|
'settle-ms': meta.settleMs,
|
|
@@ -301,6 +328,11 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
301
328
|
summary: meta.settled || capturedFullSubstrate
|
|
302
329
|
? text`Snapshot substrate captured.`
|
|
303
330
|
: text`The evidence snapshot is not queryable because the settledness requirement was not met.`,
|
|
331
|
+
// A collector that did not complete cleanly must be visible in the block,
|
|
332
|
+
// not only in meta.json — its artifact is absent (timeout/error/skipped)
|
|
333
|
+
// or partly built from timed-out reads (degraded), and a caller who reads
|
|
334
|
+
// the remaining substrate as a whole page would be reading a hole.
|
|
335
|
+
...(incomplete.length ? { sections: [collectorNote(incomplete)] } : {}),
|
|
304
336
|
artifacts: formatArtifactList(artifacts),
|
|
305
337
|
followUp: meta.settled || capturedFullSubstrate
|
|
306
338
|
? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.id}\`, \`capture measure census --snap ${captured.id} --axis color\`, or \`capture measure map focus ${captured.id}\`.`
|
|
@@ -312,6 +344,9 @@ interface SnapErrorDetails {
|
|
|
312
344
|
readonly status: string;
|
|
313
345
|
readonly message: string;
|
|
314
346
|
readonly resolution?: ArtifactResolutionError;
|
|
347
|
+
readonly phase?: string;
|
|
348
|
+
readonly method?: string;
|
|
349
|
+
readonly partialPath?: string;
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
function safeErrorDetail(value: unknown): string {
|
|
@@ -319,6 +354,9 @@ function safeErrorDetail(value: unknown): string {
|
|
|
319
354
|
}
|
|
320
355
|
|
|
321
356
|
export function classifySnapError(err: unknown): SnapErrorDetails {
|
|
357
|
+
if (err instanceof SnapshotCaptureTimeout) {
|
|
358
|
+
return { status: 'snapshot_capture_timeout', message: safeErrorDetail(err), phase: err.phase, method: err.method, partialPath: err.partialPath };
|
|
359
|
+
}
|
|
322
360
|
if (err instanceof ArtifactResolutionError) {
|
|
323
361
|
return { status: 'snapshot_ref_unavailable', message: safeErrorDetail(err), resolution: err };
|
|
324
362
|
}
|
|
@@ -326,7 +364,7 @@ export function classifySnapError(err: unknown): SnapErrorDetails {
|
|
|
326
364
|
if (message.startsWith('--viewport cannot ')) {
|
|
327
365
|
return { status: 'viewport_unavailable', message };
|
|
328
366
|
}
|
|
329
|
-
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')) {
|
|
330
368
|
return { status: 'invalid_input', message };
|
|
331
369
|
}
|
|
332
370
|
if (message.includes('No tab found') || message.includes('Use --target') || message.includes('Tab has no WebSocket debugger URL')) {
|
|
@@ -376,9 +414,10 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
|
|
|
376
414
|
if (value === undefined) return undefined;
|
|
377
415
|
return { label: value, ...parseViewport(value) };
|
|
378
416
|
});
|
|
417
|
+
const colorScheme = parseColorScheme(parsed.colorScheme);
|
|
379
418
|
const results = [] as RenderableResult[];
|
|
380
419
|
for (const viewport of viewports) {
|
|
381
|
-
const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label);
|
|
420
|
+
const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label, colorScheme);
|
|
382
421
|
captures.push(capture);
|
|
383
422
|
results.push(buildSnapshotResult(capture));
|
|
384
423
|
}
|
|
@@ -399,6 +438,9 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
|
|
|
399
438
|
attrs: {
|
|
400
439
|
command: 'measure snap',
|
|
401
440
|
status: classified.status,
|
|
441
|
+
...(classified.phase ? { phase: classified.phase } : {}),
|
|
442
|
+
...(classified.method ? { method: classified.method } : {}),
|
|
443
|
+
...(classified.partialPath ? { 'partial-path': classified.partialPath } : {}),
|
|
402
444
|
...(classified.resolution ? {
|
|
403
445
|
recovery: 'artifact-resolution-error',
|
|
404
446
|
ref: sanitizeString(classified.resolution.ref),
|
|
@@ -24,9 +24,15 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
26
|
const ref = resolveRecRef(parsed.positional[0]);
|
|
27
|
-
const meta = readMeta<{ state?: unknown }>(ref);
|
|
27
|
+
const meta = readMeta<{ state?: unknown; reason?: unknown }>(ref);
|
|
28
28
|
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
29
|
-
|
|
29
|
+
// A one-shot recording that captured zero screencast frames is a completed
|
|
30
|
+
// recording, not one awaiting `--stop` (which only finalizes an active
|
|
31
|
+
// composed lifecycle recording) — let it through and rely on
|
|
32
|
+
// createMotionMask's own accurate "needs at least two frames" rejection
|
|
33
|
+
// below, matching the same exception in response.ts's loadResponseTimeline.
|
|
34
|
+
const readablePartial = state === 'partial' && meta.reason === 'no_frames';
|
|
35
|
+
if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
|
|
30
36
|
return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
|
|
31
37
|
}
|
|
32
38
|
const mask = createMotionMask(ref);
|
|
@@ -94,6 +94,20 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
|
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
if (failure.code === 'invalid-css-selector') {
|
|
98
|
+
emitResult(
|
|
99
|
+
{
|
|
100
|
+
tag: 'error',
|
|
101
|
+
attrs: { command, code: 'invalid_css_selector' },
|
|
102
|
+
summary: fact`received: bare target \`${failure.input}\` was rejected by DOM.querySelectorAll as a CSS selector.`,
|
|
103
|
+
followUp: fact`Retry \`capture ${command} ax:${failure.input}\` to target that accessible name, or use \`backend:<id>\` from \`capture page elements\`.`,
|
|
104
|
+
},
|
|
105
|
+
{ json: parsed.json },
|
|
106
|
+
);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
const code = failure.code === 'no-match' ? 'no_match' : 'ambiguous_target';
|
|
98
112
|
const candidateRows: FactLine[] = failure.candidates.map((c) =>
|
|
99
113
|
line(data(c.role ?? 'unknown'), text` "`, data(c.name ?? ''), text`" — backend:`, data(c.backendNodeId)),
|
|
@@ -42,7 +42,7 @@ Input:
|
|
|
42
42
|
--json mirror the result as JSON
|
|
43
43
|
|
|
44
44
|
Output:
|
|
45
|
-
<elements scope="interactive|all" count=<total>> — one row per element: role "name" backend:<id
|
|
45
|
+
<elements scope="interactive|all" count=<total>> — one row per element: role "name" target:ax:<name> backend:<id>; copy the target value into a driving verb
|
|
46
46
|
|
|
47
47
|
Effects: read-only — the accessibility fetch is CDP-side, no page-observable writes; routes unmarked through an active composed recording.`;
|
|
48
48
|
|
|
@@ -106,7 +106,7 @@ export function buildElementsResult(
|
|
|
106
106
|
|
|
107
107
|
const rows = shown.map((r) =>
|
|
108
108
|
r.backendNodeId !== null
|
|
109
|
-
? fact`${r.role} "${r.name}" backend:${r.backendNodeId}`
|
|
109
|
+
? fact`${r.role} "${r.name}" target:ax:${r.name} backend:${r.backendNodeId}`
|
|
110
110
|
: fact`${r.role} "${r.name}"`,
|
|
111
111
|
);
|
|
112
112
|
|
|
@@ -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`;
|
|
@@ -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).
|
|
@@ -18,8 +19,9 @@ import * as fs from 'fs';
|
|
|
18
19
|
import * as path from 'path';
|
|
19
20
|
import { type ParsedArgs } from '../../types.js';
|
|
20
21
|
import { withConnection } from '../../connection.js';
|
|
21
|
-
import {
|
|
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=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions,
|
|
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
|
|
@@ -116,21 +119,40 @@ function writeScreenshot(outPath: string, png: Buffer): void {
|
|
|
116
119
|
|
|
117
120
|
export type EmulationMode = 'none' | 'viewport' | 'full-page';
|
|
118
121
|
|
|
122
|
+
/** Renders a CSS-pixel measurement without inventing precision: an integer stays an integer, a fractional zoomed viewport keeps six decimals. */
|
|
123
|
+
function round6(value: number): number {
|
|
124
|
+
return Math.round(value * 1e6) / 1e6;
|
|
125
|
+
}
|
|
126
|
+
|
|
119
127
|
export function buildScreenshotResult(f: {
|
|
120
128
|
path: string;
|
|
121
129
|
bytes: number;
|
|
122
130
|
dimensions: { width: number; height: number } | null;
|
|
131
|
+
cssViewport?: CapturedRegion;
|
|
123
132
|
emulation: EmulationMode;
|
|
124
133
|
viewport?: { width: number; height: number };
|
|
134
|
+
colorScheme?: ColorScheme;
|
|
125
135
|
}): RenderableResult {
|
|
136
|
+
// The denominator is the CSS region the image was actually cut from, so the
|
|
137
|
+
// ratio stays true under page zoom, device pixel ratio, and the local
|
|
138
|
+
// downscale — the layout viewport is a different space and is never used here.
|
|
139
|
+
const scale = f.dimensions && f.cssViewport
|
|
140
|
+
? { x: f.dimensions.width / f.cssViewport.width, y: f.dimensions.height / f.cssViewport.height }
|
|
141
|
+
: undefined;
|
|
126
142
|
const sections: FactLine[] = [
|
|
143
|
+
scale && f.cssViewport
|
|
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)}).`
|
|
145
|
+
: text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
|
|
127
146
|
f.emulation === 'none'
|
|
128
|
-
? text`emulation: none — the browser's actual current viewport was captured
|
|
147
|
+
? text`viewport emulation: none — the browser's actual current viewport was captured.`
|
|
129
148
|
: f.emulation === 'viewport'
|
|
130
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.`
|
|
131
150
|
: f.viewport
|
|
132
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.`
|
|
133
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.`,
|
|
134
156
|
];
|
|
135
157
|
return {
|
|
136
158
|
tag: 'screenshot',
|
|
@@ -138,7 +160,14 @@ export function buildScreenshotResult(f: {
|
|
|
138
160
|
path: f.path,
|
|
139
161
|
width: f.dimensions?.width,
|
|
140
162
|
height: f.dimensions?.height,
|
|
163
|
+
'css-width': f.cssViewport === undefined ? undefined : round6(f.cssViewport.width),
|
|
164
|
+
'css-height': f.cssViewport === undefined ? undefined : round6(f.cssViewport.height),
|
|
165
|
+
'css-x': f.cssViewport === undefined ? undefined : round6(f.cssViewport.x),
|
|
166
|
+
'css-y': f.cssViewport === undefined ? undefined : round6(f.cssViewport.y),
|
|
167
|
+
'css-to-image-x': scale?.x.toFixed(6),
|
|
168
|
+
'css-to-image-y': scale?.y.toFixed(6),
|
|
141
169
|
emulation: f.emulation,
|
|
170
|
+
'color-scheme': f.colorScheme,
|
|
142
171
|
},
|
|
143
172
|
summary: f.dimensions
|
|
144
173
|
? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
|
|
@@ -162,7 +191,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
162
191
|
{
|
|
163
192
|
tag: 'error',
|
|
164
193
|
attrs: { command: 'page shot', code: 'invalid_input' },
|
|
165
|
-
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>]\`.`,
|
|
166
195
|
},
|
|
167
196
|
{ json: parsed.json },
|
|
168
197
|
);
|
|
@@ -188,6 +217,22 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
188
217
|
}
|
|
189
218
|
}
|
|
190
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
|
+
|
|
191
236
|
const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
|
|
192
237
|
|
|
193
238
|
// connection.ts derives the recorder-routed action label from
|
|
@@ -195,14 +240,14 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
195
240
|
// restore the verb so stderr diagnostics identify this leaf.
|
|
196
241
|
const result = await deps.withConnection(
|
|
197
242
|
{ ...parsed, command: 'shot' },
|
|
198
|
-
async (client) => {
|
|
199
|
-
const
|
|
243
|
+
async (client) => withAppliedColorScheme(client, colorScheme, async () => {
|
|
244
|
+
const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
|
|
200
245
|
const outPath = await resolveOutPath(parsed);
|
|
201
|
-
writeScreenshot(outPath, png);
|
|
202
|
-
return { path: outPath, bytes: png.length, dimensions: pngDimensions(png) };
|
|
203
|
-
},
|
|
246
|
+
writeScreenshot(outPath, screenshot.png);
|
|
247
|
+
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
|
|
248
|
+
}),
|
|
204
249
|
{ settle: 0 },
|
|
205
250
|
);
|
|
206
251
|
|
|
207
|
-
emitResult(buildScreenshotResult({ ...result, emulation, viewport }), { json: parsed.json });
|
|
252
|
+
emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
|
|
208
253
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { invalidInput } from '../../../errors.js';
|
|
12
12
|
import { type ParsedArgs } from '../../types.js';
|
|
13
|
+
import { cmdTabLaunch } from './launch.js';
|
|
14
|
+
import { cmdTabQuit } from './quit.js';
|
|
13
15
|
import { cmdTabList } from './list.js';
|
|
14
16
|
import { cmdTabOpen } from './open.js';
|
|
15
17
|
import { cmdTabClose } from './close.js';
|
|
@@ -18,19 +20,24 @@ import { cmdTabNetwork } from './network.js';
|
|
|
18
20
|
|
|
19
21
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
20
22
|
export const COMMAND_BLOCK = `<command name="tab">
|
|
21
|
-
|
|
22
|
-
use when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
23
|
-
list · open · close · reset · network — \`capture tab -h\`
|
|
23
|
+
browser and tab plumbing — starting/stopping a browser capture owns, endpoint/tab discovery, tab lifecycle, connection-level network emulation
|
|
24
|
+
use when nothing is running yet (\`tab launch\` starts a browser capture owns and reaps), when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
25
|
+
launch · quit · list · open · close · reset · network — \`capture tab -h\`
|
|
24
26
|
</command>`;
|
|
25
27
|
|
|
26
|
-
export const TAB_USAGE = `capture tab —
|
|
28
|
+
export const TAB_USAGE = `capture tab — browser and tab plumbing: browser lifecycle, discovery, tab lifecycle, network emulation.
|
|
27
29
|
|
|
28
30
|
\`tab list\` with no --port performs full endpoint discovery and is the probe
|
|
29
|
-
for whether a CDP-enabled browser is running at all
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
for whether a CDP-enabled browser is running at all; when nothing is running,
|
|
32
|
+
\`tab launch\` starts one capture owns — never hand-roll a detached browser,
|
|
33
|
+
since nothing reaps that. Capture signals only browsers it launched itself
|
|
34
|
+
(verified by process-birth identity): an endpoint you reach with --port stays
|
|
35
|
+
untouched. \`tab reset\` replaces a stuck tab with a fresh one; under an active
|
|
36
|
+
session it refuses while a recording is active (stop it first), reaps dead
|
|
37
|
+
recorder handles, and updates the session's {target, port} pair together.
|
|
33
38
|
|
|
39
|
+
<subcommand name="launch" args="[--url <url>] [--port <port>] [--headed]" whenToUse="start a browser capture owns (and reaps) when none is running"/>
|
|
40
|
+
<subcommand name="quit" args="[<port>] [--all]" whenToUse="stop a browser capture launched; never touches a browser capture did not start"/>
|
|
34
41
|
<subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
|
|
35
42
|
<subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
|
|
36
43
|
<subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
|
|
@@ -44,6 +51,10 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
|
|
|
44
51
|
const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
|
|
45
52
|
|
|
46
53
|
switch (leaf) {
|
|
54
|
+
case 'launch':
|
|
55
|
+
return cmdTabLaunch(rest, args);
|
|
56
|
+
case 'quit':
|
|
57
|
+
return cmdTabQuit(rest, args);
|
|
47
58
|
case 'list':
|
|
48
59
|
return cmdTabList(rest, args);
|
|
49
60
|
case 'open':
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab launch [--url <url>] [--port <port>] [--headed]` — start a
|
|
3
|
+
* browser capture owns, and report the endpoint every other capture command can
|
|
4
|
+
* now be pointed at.
|
|
5
|
+
*
|
|
6
|
+
* This is the answer to "a CDP-enabled browser must be running": without it the
|
|
7
|
+
* only way to satisfy that precondition was a hand-rolled detached Chrome with
|
|
8
|
+
* an invented port and an invented scratch profile, which nothing ever reaped.
|
|
9
|
+
* Ownership, registry, and reaping all live in `../../browser-process.ts`; this
|
|
10
|
+
* leaf is the surface over them.
|
|
11
|
+
*/
|
|
12
|
+
import { launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
|
|
13
|
+
import { type ParsedArgs } from '../../types.js';
|
|
14
|
+
import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
|
|
15
|
+
|
|
16
|
+
const USAGE = `capture tab launch — start a browser capture owns and report its CDP endpoint.
|
|
17
|
+
|
|
18
|
+
input:
|
|
19
|
+
--url <url> first page to open (default: about:blank)
|
|
20
|
+
--port <port> pin the debugging port (default: a free port chosen by the kernel)
|
|
21
|
+
--headed run with a visible window instead of headless
|
|
22
|
+
|
|
23
|
+
output: <browser-launched port=… pid=…> — the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
|
|
24
|
+
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, stopped answering CDP, or gone idle.`;
|
|
25
|
+
|
|
26
|
+
/** Pure `<browser-launched>` result builder — exported for tests. */
|
|
27
|
+
export function buildBrowserLaunchedResult(
|
|
28
|
+
browser: { port: number; pid: number; executablePath: string; source: string; headless: boolean },
|
|
29
|
+
swept: number,
|
|
30
|
+
): RenderableResult {
|
|
31
|
+
const sections = [
|
|
32
|
+
lineList([
|
|
33
|
+
line(text`executable `, data(browser.executablePath, 300), text` (`, data(browser.source), text`)`),
|
|
34
|
+
line(text`mode `, data(browser.headless ? 'headless' : 'headed')),
|
|
35
|
+
]),
|
|
36
|
+
];
|
|
37
|
+
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, stopped answering CDP, or gone idle.`);
|
|
38
|
+
return {
|
|
39
|
+
tag: 'browser-launched',
|
|
40
|
+
attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
|
|
41
|
+
summary: line(
|
|
42
|
+
text`capture-owned browser on port `,
|
|
43
|
+
data(browser.port),
|
|
44
|
+
fact` (pid ${browser.pid}) is answering CDP.`,
|
|
45
|
+
),
|
|
46
|
+
sections,
|
|
47
|
+
followUp: line(text`capture tab quit `, data(browser.port)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function cmdTabLaunch(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
52
|
+
if (parsed.help) {
|
|
53
|
+
console.log(USAGE);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const swept = await sweepOwnedBrowsers();
|
|
58
|
+
const browser = await launchOwnedBrowser({
|
|
59
|
+
url: parsed.url,
|
|
60
|
+
port: parsed.port,
|
|
61
|
+
headless: !parsed.headed,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
emitResult(
|
|
65
|
+
buildBrowserLaunchedResult(
|
|
66
|
+
// A launched browser always reports a port — `launchOwnedBrowser` only
|
|
67
|
+
// returns once its endpoint answered on one.
|
|
68
|
+
{ ...browser, port: browser.port! },
|
|
69
|
+
swept.length,
|
|
70
|
+
),
|
|
71
|
+
{ json: parsed.json },
|
|
72
|
+
);
|
|
73
|
+
}
|