@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.
Files changed (38) hide show
  1. package/bin/capture +4204 -3244
  2. package/package.json +1 -1
  3. package/src/capture.ts +4 -1
  4. package/src/cdp/args.ts +22 -3
  5. package/src/cdp/browser-process.ts +510 -0
  6. package/src/cdp/client.ts +10 -1
  7. package/src/cdp/color-scheme.ts +47 -0
  8. package/src/cdp/commands/measure/index.ts +1 -1
  9. package/src/cdp/commands/measure/snap.ts +50 -8
  10. package/src/cdp/commands/motion/mask.ts +8 -2
  11. package/src/cdp/commands/page/click.ts +14 -0
  12. package/src/cdp/commands/page/elements.ts +2 -2
  13. package/src/cdp/commands/page/index.ts +1 -1
  14. package/src/cdp/commands/page/shot.ts +58 -13
  15. package/src/cdp/commands/tab/index.ts +19 -8
  16. package/src/cdp/commands/tab/launch.ts +73 -0
  17. package/src/cdp/commands/tab/list.ts +14 -2
  18. package/src/cdp/commands/tab/quit.ts +78 -0
  19. package/src/cdp/har-recorder.ts +14 -20
  20. package/src/cdp/measure/collectors/ax.ts +3 -4
  21. package/src/cdp/measure/collectors/forms.ts +4 -6
  22. package/src/cdp/measure/collectors/media.ts +1 -2
  23. package/src/cdp/measure/collectors/states.ts +1 -6
  24. package/src/cdp/measure/collectors/style-provenance.ts +4 -16
  25. package/src/cdp/measure/collectors/styles.ts +21 -10
  26. package/src/cdp/measure/collectors/text.ts +3 -5
  27. package/src/cdp/measure/snapshot.ts +197 -29
  28. package/src/cdp/measure/types.ts +36 -2
  29. package/src/cdp/motion/jank.ts +8 -1
  30. package/src/cdp/screenshot.ts +83 -8
  31. package/src/cdp/targets.ts +5 -0
  32. package/src/cdp/types.ts +6 -0
  33. package/src/har-manager.ts +16 -15
  34. package/src/interact.ts +20 -4
  35. package/src/output/render.ts +2 -0
  36. package/src/registry.ts +1 -1
  37. package/src/session/commands.ts +223 -43
  38. 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 { captureSnapshotSubstrate } from '../../measure/snapshot.js';
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
- if (state !== 'finalized' && state !== 'orphaned-finalized') {
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`), applied
8
- * as a transient `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
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 { captureScreenshot } from '../../screenshot.js';
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, byte size, and the emulation fact (whether a transient override was applied, or its absence)
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. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
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; no Emulation call was made.`
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 png = await captureScreenshot(client, viewport, { fullPage: parsed.fullPage });
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
- tab and connection plumbing — endpoint/tab discovery, lifecycle, connection-level network emulation
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 — tab and connection plumbing: discovery, lifecycle, network emulation.
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. \`tab reset\` replaces a
30
- stuck tab with a fresh one; under an active session it refuses while a
31
- recording is active (stop it first), reaps dead recorder handles, and updates
32
- the session's {target, port} pair together.
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
+ }