@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
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The `measure snap` orchestrator — drives the settledness gate, then (when
3
- * captured) runs the collectors in two phases: `baseline` collectors in
4
- * parallel, then `screenshot.png`/`dom.html` at the baseline boundary,
5
- * then `mutating` collectors serialized (so their DOM/focus/scroll/
3
+ * captured) runs the collectors in two phases: serialized `baseline`
4
+ * collectors, then `screenshot.png`/`dom.html` at the baseline boundary,
5
+ * then serialized `mutating` collectors (so their DOM/focus/scroll/
6
6
  * background changes can't contaminate the baseline artifacts). `meta.json`
7
7
  * is ALWAYS written last. This is the one function
8
8
  * `src/cdp/commands/measure/snap.ts` (U15) calls; nothing else drives a
@@ -11,7 +11,8 @@
11
11
 
12
12
  import * as path from 'path';
13
13
 
14
- import { ensurePrivateDir, writeJsonPrivate, writeBinaryPrivate, writePrivateFile } from '../../session/artifacts.js';
14
+ import { ensurePrivateDir, writeJsonPrivate, writeBinaryPrivate, writePrivateFile, unlinkPrivateFile } from '../../session/artifacts.js';
15
+ import type { CDPClient } from '../client.js';
15
16
  import { enableDomainsForSnap } from '../domains.js';
16
17
  import {
17
18
  DEFAULT_SETTLE_TIMEOUT_MS,
@@ -37,6 +38,7 @@ import type {
37
38
  SnapshotWriter,
38
39
  UnstableRegion,
39
40
  CollectorDescriptor,
41
+ CollectorOutcome,
40
42
  } from './types.js';
41
43
 
42
44
  import { collectGeometry } from './collectors/geometry.js';
@@ -55,9 +57,9 @@ import { collectStates } from './collectors/states.js';
55
57
  import { collectMedia } from './collectors/media.js';
56
58
 
57
59
  /**
58
- * The collector set, split by phase. `baseline` collectors run in parallel
59
- * and are all finished before `screenshot.png`/`dom.html` are captured;
60
- * `mutating` collectors run afterward, serialized, so their
60
+ * The collector set, split by phase. Every collector shares one browser CDP
61
+ * connection, so both phases run in descriptor order. Baseline collectors
62
+ * finish before `screenshot.png`/`dom.html`; `mutating` collectors run afterward, so their
61
63
  * DOM/focus/scroll/background mutations cannot contaminate the baseline
62
64
  * artifacts. Geometry/hittest/text/forms are safely `baseline`: none of
63
65
  * them ever mutates the DOM — each resolves its elements' `backendNodeId`
@@ -68,13 +70,13 @@ import { collectMedia } from './collectors/media.js';
68
70
  * `screenshot.png`/`dom.html` capture could observe ever changes.
69
71
  */
70
72
  const COLLECTORS: readonly CollectorDescriptor[] = [
73
+ { name: 'geometry', phase: 'baseline', fn: collectGeometry },
71
74
  { name: 'ax', phase: 'baseline', fn: collectAx },
72
75
  { name: 'styles', phase: 'baseline', fn: collectStyles },
73
76
  { name: 'queries', phase: 'baseline', fn: collectQueries },
74
77
  { name: 'animation', phase: 'baseline', fn: collectAnimation },
75
78
  { name: 'layers', phase: 'baseline', fn: collectLayers },
76
79
  { name: 'media', phase: 'baseline', fn: collectMedia },
77
- { name: 'geometry', phase: 'baseline', fn: collectGeometry },
78
80
  { name: 'hittest', phase: 'baseline', fn: collectHittest },
79
81
  { name: 'text', phase: 'baseline', fn: collectText },
80
82
  { name: 'forms', phase: 'baseline', fn: collectForms },
@@ -84,6 +86,8 @@ const COLLECTORS: readonly CollectorDescriptor[] = [
84
86
  { name: 'pixels', phase: 'mutating', fn: collectPixels },
85
87
  ];
86
88
 
89
+ export const COLLECTOR_NAMES = COLLECTORS.map((collector) => collector.name);
90
+
87
91
  /**
88
92
  * Resolves `filename` against the snap `dir` and rejects (throws) anything
89
93
  * that would escape it — an absolute path, or a `path.relative(dir, target)`
@@ -101,7 +105,12 @@ function resolveScopedArtifactPath(dir: string, filename: string): string {
101
105
  return target;
102
106
  }
103
107
 
104
- function makeWriter(dir: string, artifacts: string[]): SnapshotWriter {
108
+ /** The snap-directory writer, plus the un-write a failed collector commit needs to leave nothing behind. */
109
+ interface RollbackWriter extends SnapshotWriter {
110
+ remove(filename: string): void;
111
+ }
112
+
113
+ function makeWriter(dir: string, artifacts: string[]): RollbackWriter {
105
114
  return {
106
115
  json(filename: string, value: unknown): void {
107
116
  writeJsonPrivate(resolveScopedArtifactPath(dir, filename), value);
@@ -111,6 +120,59 @@ function makeWriter(dir: string, artifacts: string[]): SnapshotWriter {
111
120
  writeBinaryPrivate(resolveScopedArtifactPath(dir, filename), data);
112
121
  artifacts.push(filename);
113
122
  },
123
+ remove(filename: string): void {
124
+ const index = artifacts.lastIndexOf(filename);
125
+ if (index !== -1) artifacts.splice(index, 1);
126
+ try {
127
+ unlinkPrivateFile(resolveScopedArtifactPath(dir, filename));
128
+ } catch {
129
+ // The file is already gone, or the snap dir itself is unwritable; the
130
+ // artifact list no longer advertises it either way.
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ /**
137
+ * A collector publishes its artifacts only after it returns cleanly, and only
138
+ * as a set: a write that throws part-way through `commit` un-writes the ones
139
+ * that already landed and rethrows, so the collector is recorded as `error`
140
+ * with nothing of its own left in the snapshot. A partial artifact never
141
+ * reaches a completed snapshot by either route.
142
+ */
143
+ function makeCollectorWriter(writer: RollbackWriter): { writer: SnapshotWriter; commit: () => void; discard: () => void } {
144
+ const staged: Array<{ filename: string; value: unknown } | { filename: string; data: Buffer }> = [];
145
+ let open = true;
146
+ return {
147
+ writer: {
148
+ json(filename: string, value: unknown): void {
149
+ if (open) staged.push({ filename, value });
150
+ },
151
+ binary(filename: string, data: Buffer): void {
152
+ if (open) staged.push({ filename, data });
153
+ },
154
+ },
155
+ commit(): void {
156
+ if (!open) return;
157
+ open = false;
158
+ const written: string[] = [];
159
+ try {
160
+ for (const artifact of staged) {
161
+ if ('data' in artifact) writer.binary(artifact.filename, artifact.data);
162
+ else writer.json(artifact.filename, artifact.value);
163
+ written.push(artifact.filename);
164
+ }
165
+ } catch (error) {
166
+ for (const filename of written.reverse()) writer.remove(filename);
167
+ throw error;
168
+ } finally {
169
+ staged.length = 0;
170
+ }
171
+ },
172
+ discard(): void {
173
+ open = false;
174
+ staged.length = 0;
175
+ },
114
176
  };
115
177
  }
116
178
 
@@ -118,6 +180,57 @@ function makeWriter(dir: string, artifacts: string[]): SnapshotWriter {
118
180
  // Orchestrator
119
181
  // ============================================================================
120
182
 
183
+ /** One CDP operation is bounded so a page-side script or browser command that never returns cannot strand the CLI behind CDPClient's 60-second default. */
184
+ export const SNAPSHOT_REQUEST_TIMEOUT_MS = 5_000;
185
+
186
+ /** A snapshot operation timed out after writing zero or more partial artifacts. */
187
+ export class SnapshotCaptureTimeout extends Error {
188
+ readonly code = 'snapshot_capture_timeout';
189
+
190
+ constructor(
191
+ readonly phase: string,
192
+ readonly method: string,
193
+ readonly timeoutMs: number,
194
+ readonly partialPath: string,
195
+ ) {
196
+ super(`snapshot capture timed out in ${phase} while waiting for ${method} after ${timeoutMs}ms; partial artifacts: ${partialPath}`);
197
+ this.name = 'SnapshotCaptureTimeout';
198
+ }
199
+ }
200
+
201
+ /** Bounds one snapshot CDP request and labels the phase that owns it. */
202
+ function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: () => void): CDPClient {
203
+ const bounded = Object.create(client) as CDPClient;
204
+ bounded.send = (method: string, params: Record<string, unknown> = {}, timeout = 60_000, sessionId?: string): Promise<unknown> => {
205
+ const timeoutMs = Math.min(timeout, SNAPSHOT_REQUEST_TIMEOUT_MS);
206
+ let timer: NodeJS.Timeout | undefined;
207
+ let timedOut = false;
208
+ const markTimedOut = (): void => {
209
+ if (timedOut) return;
210
+ timedOut = true;
211
+ onTimeout?.();
212
+ };
213
+ const deadline = new Promise<never>((_, reject) => {
214
+ timer = setTimeout(() => {
215
+ markTimedOut();
216
+ reject(new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath));
217
+ }, timeoutMs);
218
+ });
219
+ const request = Promise.resolve().then(() => client.send(method, params, timeoutMs, sessionId)).catch((error: unknown) => {
220
+ if (error instanceof SnapshotCaptureTimeout) throw error;
221
+ if (error instanceof Error && error.message.startsWith('CDP request timeout')) {
222
+ markTimedOut();
223
+ throw new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
224
+ }
225
+ throw error;
226
+ });
227
+ return Promise.race([request, deadline]).finally(() => {
228
+ if (timer) clearTimeout(timer);
229
+ });
230
+ };
231
+ return bounded;
232
+ }
233
+
121
234
  /**
122
235
  * Captures one snapshot substrate directory: enables the CDP domains,
123
236
  * runs the settledness gate, and — when the page settled or the caller
@@ -137,11 +250,14 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
137
250
  const pixels = options.pixels ?? false;
138
251
  const state = options.state ?? [];
139
252
  const viewport = options.viewport ?? null;
253
+ const colorScheme = options.colorScheme;
140
254
  // Apply the shared artifact-string cap once at the source. URL contents
141
255
  // otherwise survive unchanged in meta.json, the result, and collector context.
142
256
  const url = options.url ? sanitizeString(options.url) : null;
257
+ const artifacts: string[] = [];
258
+ const phaseClient = (phase: string): CDPClient => boundedClient(client, phase, dir);
143
259
 
144
- await enableDomainsForSnap(client);
260
+ await enableDomainsForSnap(phaseClient('domains'));
145
261
 
146
262
  // I-6: capture each animation's pre-freeze origin BEFORE this, the first
147
263
  // mutation, and restore it exception-safely — `animationFreezeHandle`/
@@ -173,7 +289,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
173
289
  let freezeIncomplete: boolean | undefined;
174
290
  let unfrozenCount: number | undefined;
175
291
  if (freezeAnimations) {
176
- animationFreezeHandle = await freezeAnimationsBeforeCapture(client);
292
+ animationFreezeHandle = await freezeAnimationsBeforeCapture(phaseClient('freeze-animations'));
177
293
  freezeOverrideApplied = animationFreezeHandle?.rateOverrideApplied;
178
294
  freezeIncomplete = animationFreezeHandle?.freezeIncomplete;
179
295
  unfrozenCount = animationFreezeHandle?.unfrozenCount;
@@ -185,12 +301,12 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
185
301
  if (!animationFreezeHandle) return;
186
302
  const handle = animationFreezeHandle;
187
303
  animationFreezeHandle = undefined;
188
- const result = await restoreAnimationsAfterCapture(client, handle);
304
+ const result = await restoreAnimationsAfterCapture(phaseClient('restore-animations'), handle);
189
305
  animationsRestored = result.restored;
190
306
  };
191
307
 
192
308
  try {
193
- const churnHandle = await injectChurnObservers(client);
309
+ const churnHandle = await injectChurnObservers(phaseClient('settle-observers'));
194
310
 
195
311
  // The churn observers (MutationObserver/ResizeObserver) MUST be torn down
196
312
  // even if sampling throws (a CDP failure mid-poll) — otherwise they leak
@@ -204,7 +320,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
204
320
  let settleResult: Awaited<ReturnType<typeof pollForSettle<string>>>;
205
321
  try {
206
322
  settleResult = await pollForSettle({
207
- captureSample: buildDomSettleSampler(client, churnHandle),
323
+ captureSample: buildDomSettleSampler(phaseClient('settle'), churnHandle),
208
324
  isEqual: domSignaturesEqual,
209
325
  settleTimeoutMs,
210
326
  quietThresholdMs,
@@ -212,7 +328,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
212
328
  });
213
329
  } catch (pollError) {
214
330
  try {
215
- await collectChurnEvidence(client, churnHandle);
331
+ await collectChurnEvidence(phaseClient('settle-cleanup'), churnHandle);
216
332
  } catch {
217
333
  // Secondary teardown failure — the original poll error is what matters.
218
334
  }
@@ -220,22 +336,22 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
220
336
  }
221
337
 
222
338
  // Always call — cleanup, and potential evidence, regardless of settledness.
223
- const churnEvidenceRaw = await collectChurnEvidence(client, churnHandle);
339
+ const churnEvidenceRaw = await collectChurnEvidence(phaseClient('settle-cleanup'), churnHandle);
224
340
 
225
341
  const settled = settleResult.settled;
226
342
  const captured = settled || captureUnsettled;
227
343
  const unstable = !settled;
228
344
 
229
- const artifacts: string[] = [];
230
345
  let unstableRegions: readonly UnstableRegion[] = [];
231
346
  // I-5: set only inside the `captured` branch below (the only place
232
347
  // `dom.html` is ever attempted) — stays `undefined` on the evidence-only
233
348
  // branch, where `meta.json`'s spread omits the field entirely rather than
234
349
  // implying a read that never happened.
235
350
  let domHtmlFact: SnapshotMeta['domHtml'];
351
+ const collectorOutcomes: CollectorOutcome[] = [];
236
352
 
237
353
  if (unstable) {
238
- const animationEvidence = await collectAnimationEvidence(client);
354
+ const animationEvidence = await collectAnimationEvidence(phaseClient('settle-evidence'));
239
355
  const grouped = groupChurnEvidence(churnEvidenceRaw, animationEvidence, settleResult.elapsedMs, settleTimeoutMs);
240
356
  unstableRegions = grouped.unstableRegions;
241
357
  writeJsonPrivate(path.join(dir, 'churn.json'), grouped.report);
@@ -263,19 +379,50 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
263
379
  };
264
380
 
265
381
  const collectors = options.collectors ?? COLLECTORS;
382
+ const skipCollectors = new Set(options.skipCollectors ?? []);
266
383
  const baseline = collectors.filter((c) => c.phase === 'baseline');
267
384
  const mutating = collectors.filter((c) => c.phase === 'mutating');
385
+ const runCollector = async (collector: CollectorDescriptor): Promise<CollectorOutcome> => {
386
+ if (skipCollectors.has(collector.name)) return { name: collector.name, status: 'skipped' };
268
387
 
269
- // Phase 1 — baseline collectors observe the page exactly as it settled;
270
- // they run in parallel and must ALL finish before the baseline
271
- // artifacts are captured.
272
- await Promise.all(baseline.map((c) => c.fn(ctx)));
388
+ const startedAt = Date.now();
389
+ let requestTimeouts = 0;
390
+ const collectorWriter = makeCollectorWriter(ctx.write);
391
+ try {
392
+ // Await the collector itself, not a wall-clock race around it. Each CDP
393
+ // request is still bounded at five seconds; awaiting the collector keeps
394
+ // any cleanup and pending browser work quiescent before the next shared-
395
+ // connection collector starts.
396
+ await collector.fn({
397
+ ...ctx,
398
+ client: boundedClient(client, `collector:${collector.name}`, dir, () => { requestTimeouts += 1; }),
399
+ write: collectorWriter.writer,
400
+ });
401
+ const ms = Date.now() - startedAt;
402
+ collectorWriter.commit();
403
+ return requestTimeouts > 0
404
+ ? { name: collector.name, status: 'degraded', ms, requestTimeouts }
405
+ : { name: collector.name, status: 'ok', ms };
406
+ } catch {
407
+ const ms = Date.now() - startedAt;
408
+ collectorWriter.discard();
409
+ return { name: collector.name, status: 'error', ms, ...(requestTimeouts > 0 ? { requestTimeouts } : {}) };
410
+ }
411
+ };
412
+
413
+ // Phase 1 — baseline collectors observe the page exactly as it settled.
414
+ // They are serialized to COMPLETION because CDP commands share one browser
415
+ // connection: the next collector starts only once the previous one has
416
+ // returned or thrown, so no abandoned collector can still be issuing CDP
417
+ // commands (or restoring page state) underneath its successor. Every
418
+ // completed result is published before the baseline boundary.
419
+ for (const collector of baseline) collectorOutcomes.push(await runCollector(collector));
273
420
 
274
421
  // Baseline boundary — capture screenshot.png + dom.html BEFORE any
275
422
  // mutating collector runs, so a mutating collector's DOM/focus/scroll/
276
423
  // background change (or a failed restoration) can never contaminate
277
424
  // these artifacts.
278
- const screenshotResponse = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
425
+ const screenshotResponse = (await phaseClient('baseline-screenshot').send('Page.captureScreenshot', { format: 'png' })) as { data: string };
279
426
  writeBinaryPrivate(path.join(dir, 'screenshot.png'), Buffer.from(screenshotResponse.data, 'base64'));
280
427
  artifacts.push('screenshot.png');
281
428
 
@@ -288,7 +435,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
288
435
  let domHtmlValue: string | undefined;
289
436
  let domHtmlUnavailableReason: string | undefined;
290
437
  try {
291
- const domResponse = (await client.send('Runtime.evaluate', {
438
+ const domResponse = (await phaseClient('baseline-dom').send('Runtime.evaluate', {
292
439
  expression: 'document.documentElement.outerHTML',
293
440
  returnByValue: true,
294
441
  })) as { result?: { value?: string } };
@@ -316,11 +463,9 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
316
463
  await restoreAnimationsOnce();
317
464
 
318
465
  // Phase 2 — mutating collectors run AFTER the baseline artifacts are
319
- // captured, and SERIALIZED (one at a time) so their mutations and
320
- // restorations cannot contaminate each other.
321
- for (const c of mutating) {
322
- await c.fn(ctx);
323
- }
466
+ // captured, one at a time, so their mutations and restorations cannot
467
+ // contaminate each other.
468
+ for (const collector of mutating) collectorOutcomes.push(await runCollector(collector));
324
469
  } else {
325
470
  // Evidence-only branch (unstable, not captured) — nothing depends on
326
471
  // animations staying frozen any longer, so restore now.
@@ -339,6 +484,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
339
484
  captureUnsettled,
340
485
  pixels,
341
486
  states: state.map((spec) => sanitizeString(spec)),
487
+ ...(colorScheme ? { colorScheme } : {}),
342
488
  unstableRegionCount: unstableRegions.length,
343
489
  ...(freezeAnimations ? { animationsRestored: animationsRestored ?? false } : {}),
344
490
  // #8: the browser-wide `Animation.setPlaybackRate({playbackRate:0})`
@@ -362,6 +508,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
362
508
  // `freezeIncomplete`, matching the handle's own documented contract.
363
509
  ...(freezeIncomplete === true ? { freezeIncomplete: true as const, unfrozenCount: unfrozenCount ?? 0 } : {}),
364
510
  ...(domHtmlFact ? { domHtml: domHtmlFact } : {}),
511
+ ...(captured ? { collectors: collectorOutcomes } : {}),
365
512
  };
366
513
  writeJsonPrivate(path.join(dir, 'meta.json'), meta);
367
514
  artifacts.push('meta.json');
@@ -377,8 +524,29 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
377
524
  settleTimeoutMs,
378
525
  unstableRegions,
379
526
  artifacts,
527
+ collectors: collectorOutcomes,
380
528
  meta,
381
529
  };
530
+ } catch (error) {
531
+ if (error instanceof SnapshotCaptureTimeout) {
532
+ try {
533
+ writeJsonPrivate(path.join(dir, 'capture-timeout.json'), {
534
+ schemaVersion: 1,
535
+ id: snapId,
536
+ status: 'timeout',
537
+ phase: error.phase,
538
+ method: error.method,
539
+ timeoutMs: error.timeoutMs,
540
+ partialPath: dir,
541
+ artifacts,
542
+ capturedAt: new Date().toISOString(),
543
+ });
544
+ artifacts.push('capture-timeout.json');
545
+ } catch {
546
+ // Preserve the timeout and its directory even if recovery metadata cannot be written.
547
+ }
548
+ }
549
+ throw error;
382
550
  } finally {
383
551
  // I-6 exception safety net: if anything above threw before either
384
552
  // natural restore point ran, still restore the page against the
@@ -53,6 +53,8 @@ export interface CaptureSnapshotOptions {
53
53
  /** `--state <state[:selector]>` (repeatable); default `[]`. */
54
54
  readonly state?: readonly string[];
55
55
  readonly viewport?: string | null;
56
+ /** Transient `prefers-color-scheme` media override used while capturing. */
57
+ readonly colorScheme?: 'dark' | 'light';
56
58
  /**
57
59
  * Poll interval for the settledness loop; default {@link DEFAULT_POLL_INTERVAL_MS}
58
60
  * (100ms). Not part of the CLI surface — a test-only override so the
@@ -73,6 +75,32 @@ export interface CaptureSnapshotOptions {
73
75
  * the real collector set `snapshot.ts` defines.
74
76
  */
75
77
  readonly collectors?: readonly CollectorDescriptor[];
78
+ /** `--skip-collector <name>` (repeatable); collectors to leave out of this capture entirely. Each one is recorded in {@link SnapshotMeta.collectors} as `skipped`, so an absent artifact is never mistaken for a failed read. */
79
+ readonly skipCollectors?: readonly string[];
80
+ }
81
+
82
+ /**
83
+ * What one collector did in this capture — the honest ledger written to
84
+ * `meta.json`'s `collectors` field, so an absent artifact always names its
85
+ * reason.
86
+ *
87
+ * - `ok`: ran to completion; its artifact is present and trustworthy.
88
+ * - `degraded`: ran to completion, but at least one of its CDP requests was
89
+ * killed by the per-request bound. The artifact exists and its own
90
+ * per-record failure markers are honest, but some of what it reports is a
91
+ * timed-out read rather than a measurement — `requestTimeouts` counts them.
92
+ * - `error`: threw before completing (including a CDP request killed by the
93
+ * per-request bound that the collector did not itself absorb). Its artifact is
94
+ * absent because writes publish only after collector completion.
95
+ * - `skipped`: excluded by `--skip-collector`; never ran.
96
+ */
97
+ export interface CollectorOutcome {
98
+ readonly name: string;
99
+ readonly status: 'ok' | 'degraded' | 'error' | 'skipped';
100
+ /** Wall-clock ms the collector ran. Absent for `skipped` — it never ran. */
101
+ readonly ms?: number;
102
+ /** Present only when at least one of this collector's CDP requests was killed by the per-request bound. */
103
+ readonly requestTimeouts?: number;
76
104
  }
77
105
 
78
106
  /** `meta.json`'s on-disk shape — a {@link SnapMeta} (the U03 contract) plus snapshot-specific detail. */
@@ -83,6 +111,8 @@ export interface SnapshotMeta extends SnapMeta {
83
111
  readonly captureUnsettled: boolean;
84
112
  readonly pixels: boolean;
85
113
  readonly states: readonly string[];
114
+ /** Present only when this snapshot applied a transient color-scheme override. */
115
+ readonly colorScheme?: 'dark' | 'light';
86
116
  readonly unstableRegionCount: number;
87
117
  /** Present only when `freezeAnimations` is true — whether every animation `freezeAnimationsBeforeCapture` paused was confirmed resumed to its pre-freeze `playState` (I-6). `false` means restoration could not be guaranteed (origin capture failed, or the restore call itself threw) — never omitted to imply a clean restore. */
88
118
  readonly animationsRestored?: boolean;
@@ -94,6 +124,8 @@ export interface SnapshotMeta extends SnapMeta {
94
124
  readonly unfrozenCount?: number;
95
125
  /** Present only when the snapshot reached the baseline boundary (`captured`) — whether `document.documentElement.outerHTML` was actually read (I-5). `false` means the CDP read threw or returned no value: `dom.html` is NOT written in that case (an absent file, never a benign empty one), and `unavailableReason` names why. */
96
126
  readonly domHtml?: { readonly available: boolean; readonly unavailableReason?: string };
127
+ /** Per-collector outcome ledger — present only on a capture that reached the collector phase (`captured`), never on an evidence-only unsettled capture where no collector ran. See {@link CollectorOutcome}. */
128
+ readonly collectors?: readonly CollectorOutcome[];
97
129
  }
98
130
 
99
131
  /** Return value of {@link captureSnapshotSubstrate}. */
@@ -110,6 +142,8 @@ export interface CaptureSnapshotResult {
110
142
  readonly unstableRegions: readonly UnstableRegion[];
111
143
  /** Filenames written under `dir`, relative to it. */
112
144
  readonly artifacts: readonly string[];
145
+ /** Per-collector outcome ledger; empty on an evidence-only capture where no collector ran. */
146
+ readonly collectors: readonly CollectorOutcome[];
113
147
  readonly meta: SnapshotMeta;
114
148
  }
115
149
 
@@ -159,8 +193,8 @@ export type Collector = (ctx: SnapshotContext) => Promise<void>;
159
193
  * Which snapshot phase a collector belongs to — the ordering guarantee the
160
194
  * orchestrator enforces:
161
195
  * - `baseline`: pure/measuring collectors that must observe the page as it
162
- * settled. They run in PARALLEL, and ALL finish before `screenshot.png`
163
- * + `dom.html` are captured at the baseline boundary.
196
+ * settled. They run in descriptor order, and all finish before
197
+ * `screenshot.png` + `dom.html` are captured at the baseline boundary.
164
198
  * - `mutating`: collectors that mutate DOM/focus/scroll/background
165
199
  * (focus, scroll, states, pixels). They run AFTER the baseline artifacts
166
200
  * are captured, and SERIALIZED (one at a time) so their mutations and
@@ -426,7 +426,14 @@ function longTask(source: 'observer' | 'trace', timingDomain: LongTaskTimingDoma
426
426
  export function readMotionJank(ref: RecRef): { analysis: MotionJankAnalysis; meta: Record<string, unknown> } {
427
427
  const meta = readMeta<Record<string, unknown>>(ref);
428
428
  const state = typeof meta.state === 'string' ? meta.state : undefined;
429
- if (state !== 'finalized' && state !== 'orphaned-finalized') {
429
+ // A one-shot recording that captured zero screencast frames is still a
430
+ // completed recording, not one awaiting `--stop` (which only applies to an
431
+ // active composed lifecycle recording) — its retained event/trace records
432
+ // remain readable, so long-task and layout-shift facts stay available;
433
+ // only frame-derived counts fall back to "incomplete" via cadenceMs===null.
434
+ // Mirrors the same exception already made in response.ts's loadResponseTimeline.
435
+ const readablePartial = state === 'partial' && meta.reason === 'no_frames';
436
+ if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
430
437
  throw new Error(`recording ${JSON.stringify(ref.id)} is state ${JSON.stringify(state ?? 'unknown')}, not finalized; finalize it with: capture motion rec --stop`);
431
438
  }
432
439
  return { analysis: analyzeMotionJank({ rects: readRects(ref), events: readEvents(ref), markers: readMarkers<Markers>(ref), state }), meta };
@@ -4,6 +4,25 @@ import { nextStepPath } from '../session-context.js';
4
4
  import { writeBinaryPrivate } from '../session/artifacts.js';
5
5
  import { withScopeSerialization } from './scope-lock.js';
6
6
 
7
+ /**
8
+ * The CSS-pixel rectangle a captured PNG covers: its page-coordinate origin and
9
+ * its size. `image px / width` (and `/ height`) is the capture's true CSS-to-
10
+ * image scale, and `x`/`y` turn a page coordinate into an image coordinate.
11
+ */
12
+ export interface CapturedRegion {
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ }
18
+
19
+ /** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
20
+ function validRegion(region: CapturedRegion): CapturedRegion | undefined {
21
+ const finite = [region.x, region.y, region.width, region.height].every((n) => typeof n === 'number' && Number.isFinite(n));
22
+ if (!finite || region.width <= 0 || region.height <= 0) return undefined;
23
+ return region;
24
+ }
25
+
7
26
  /**
8
27
  * Downscales a PNG so its longest side fits within maxDim, using a box
9
28
  * filter. Used when a clipped/scaled CDP capture is unavailable and the raw
@@ -65,9 +84,27 @@ export async function captureScreenshot(
65
84
  viewport?: { width: number; height: number },
66
85
  options?: { fullPage?: boolean },
67
86
  ): Promise<Buffer> {
68
- return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
87
+ const result = await withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
69
88
  viewportScopedCapture(client, viewport, options),
70
89
  );
90
+ return result.png;
91
+ }
92
+
93
+ /**
94
+ * Captures a PNG and the CSS-pixel region the image actually covers — the clip
95
+ * sent to `Page.captureScreenshot` (or, for an unclipped capture, the visual
96
+ * viewport). Dividing the PNG's pixel dimensions by that region's size is the
97
+ * true CSS-to-image scale; the layout viewport (`window.innerWidth`) is a
98
+ * different coordinate space under page zoom and must not stand in for it.
99
+ */
100
+ export async function captureScreenshotWithCssViewport(
101
+ client: CDPClient,
102
+ viewport?: { width: number; height: number },
103
+ options?: { fullPage?: boolean },
104
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
105
+ return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
106
+ viewportScopedCapture(client, viewport, options, true),
107
+ );
71
108
  }
72
109
 
73
110
  /**
@@ -81,7 +118,8 @@ async function viewportScopedCapture(
81
118
  client: CDPClient,
82
119
  viewport?: { width: number; height: number },
83
120
  options?: { fullPage?: boolean },
84
- ): Promise<Buffer> {
121
+ includeCssViewport = false,
122
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
85
123
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
86
124
  let ownsDeviceMetricsOverride = false;
87
125
  let primaryFailed = false;
@@ -102,7 +140,7 @@ async function viewportScopedCapture(
102
140
  }
103
141
 
104
142
  if (options?.fullPage) {
105
- const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
143
+ const layoutMetrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
106
144
  contentSize?: { width: number; height: number };
107
145
  cssVisualViewport?: { clientWidth: number };
108
146
  };
@@ -123,8 +161,13 @@ async function viewportScopedCapture(
123
161
  captureBeyondViewport: false,
124
162
  };
125
163
 
164
+ // The CSS-pixel region the returned image covers, set from the clip that is
165
+ // actually sent. An unclipped capture clears it again and it is read from the
166
+ // live visual viewport below instead.
167
+ let capturedRegion: CapturedRegion | undefined;
168
+
126
169
  try {
127
- const metrics = (await client.send('Page.getLayoutMetrics')) as {
170
+ const metrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
128
171
  cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
129
172
  };
130
173
  // Snap the clip to integer CSS pixels: browser zoom (e.g. 110%) makes the
@@ -139,7 +182,7 @@ async function viewportScopedCapture(
139
182
  const dprResult = (await client.send('Runtime.evaluate', {
140
183
  expression: 'window.devicePixelRatio',
141
184
  returnByValue: true,
142
- })) as { result: { value: number } };
185
+ }, 5000)) as { result: { value: number } };
143
186
  const dpr = dprResult.result.value ?? 1;
144
187
 
145
188
  const actualMaxSide = Math.max(vw, vh) * dpr;
@@ -148,6 +191,9 @@ async function viewportScopedCapture(
148
191
  ...screenshotOpts,
149
192
  clip: { x: sx, y: sy, width: vw, height: vh, scale },
150
193
  };
194
+ // The clip is what the image is cut from, rounding included — not the
195
+ // fractional viewport it was derived from.
196
+ capturedRegion = validRegion({ x: sx, y: sy, width: vw, height: vh });
151
197
  } catch {
152
198
  // Capture without downscaling when the optional metrics probe is unavailable.
153
199
  }
@@ -155,6 +201,7 @@ async function viewportScopedCapture(
155
201
  const result = (await client.send(
156
202
  'Page.captureScreenshot',
157
203
  screenshotOpts,
204
+ 15000,
158
205
  )) as { data?: string };
159
206
  let png = Buffer.from(result.data ?? '', 'base64');
160
207
 
@@ -167,7 +214,7 @@ async function viewportScopedCapture(
167
214
  const retry = (await client.send('Page.captureScreenshot', {
168
215
  ...screenshotOpts,
169
216
  clip: { ...clip, scale: 1 },
170
- })) as { data?: string };
217
+ }, 15000)) as { data?: string };
171
218
  png = Buffer.from(retry.data ?? '', 'base64');
172
219
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
173
220
  }
@@ -178,9 +225,37 @@ async function viewportScopedCapture(
178
225
  const retry = (await client.send('Page.captureScreenshot', {
179
226
  format: 'png',
180
227
  captureBeyondViewport: false,
181
- })) as { data?: string };
228
+ }, 15000)) as { data?: string };
182
229
  png = Buffer.from(retry.data ?? '', 'base64');
183
230
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
231
+ // An unclipped capture covers the visual viewport itself, not the integer
232
+ // clip that was refused — and the two failed attempts above can have taken
233
+ // 30 seconds, so the viewport sampled before them is no longer evidence.
234
+ if (png.length > 0) capturedRegion = undefined;
235
+ }
236
+
237
+ // The image was captured unclipped (no clip was ever sent, or the clipped
238
+ // attempts were refused), so it covers the visual viewport. Read it as it
239
+ // stands now rather than reporting the layout viewport, which is a different
240
+ // space under page zoom, or a sample taken before a failed attempt.
241
+ if (includeCssViewport && !capturedRegion) {
242
+ try {
243
+ const response = (await client.send('Runtime.evaluate', {
244
+ expression: '(() => { const v = window.visualViewport; return v ? { x: v.pageLeft, y: v.pageTop, width: v.width, height: v.height } : null; })()',
245
+ returnByValue: true,
246
+ }, 5000)) as { result?: { value?: Record<string, unknown> | null } };
247
+ const value = response.result?.value;
248
+ if (value) {
249
+ capturedRegion = validRegion({
250
+ x: Number(value.x),
251
+ y: Number(value.y),
252
+ width: Number(value.width),
253
+ height: Number(value.height),
254
+ });
255
+ }
256
+ } catch {
257
+ // The image remains usable; only its CSS-coordinate mapping is unavailable.
258
+ }
184
259
  }
185
260
 
186
261
  if (png.length === 0) {
@@ -189,7 +264,7 @@ async function viewportScopedCapture(
189
264
  );
190
265
  }
191
266
 
192
- return png;
267
+ return { png, ...(includeCssViewport && capturedRegion ? { cssViewport: capturedRegion } : {}) };
193
268
  } catch (error) {
194
269
  primaryFailed = true;
195
270
  primaryError = error;