@crouton-kit/capture 1.3.18 → 1.3.19

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.
@@ -41,9 +41,8 @@
41
41
  * turns into a `backendNodeId`. A page can predefine a setter for any
42
42
  * global name it can guess; it can observe nothing here, because nothing
43
43
  * is ever set on it. No DOM mutation and no page-observable global either,
44
- * so nothing in the emitted `screenshot.png`/`dom.html`/any other baseline
45
- * collector running concurrently in the same `Promise.all` can ever
46
- * observe this collector having run.
44
+ * so nothing in the emitted `screenshot.png`/`dom.html` can observe this
45
+ * collector having run.
47
46
  */
48
47
 
49
48
  import type { CDPClient } from '../../client.js';
@@ -628,9 +627,8 @@ async function resolveIndexedObjectIds(
628
627
  export const collectForms: Collector = async (ctx) => {
629
628
  // The walk's return value is a plain in-memory `{ facts, elements }`
630
629
  // object — never a `window` global, never a live-DOM attribute — so
631
- // nothing another baseline collector or the `screenshot.png`/`dom.html`
632
- // capture running concurrently in the same `Promise.all` (see
633
- // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
630
+ // nothing another baseline artifact capture could ever observe.
631
+ // `Runtime.evaluate({returnByValue:
634
632
  // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
635
633
  // with zero page visibility into it); every held `objectId` (the
636
634
  // container, `facts`, `elements`) is released via `Runtime.releaseObject`
@@ -4,8 +4,7 @@
4
4
  * state, and (for `object-fit`-bearing elements) computed crop/letterbox
5
5
  * facts. One `Runtime.evaluate` gathers raw per-element facts in-page;
6
6
  * `backendNodeId` correlation reuses `resolveNodeIds` from `./styles.js`
7
- * (this file issues its own `DOM.getDocument` call — collectors run
8
- * concurrently, so no CDP call is shared across files).
7
+ * while issuing its own `DOM.getDocument` call.
9
8
  *
10
9
  * This is a `phase: 'baseline'` collector, so it must not create or pin
11
10
  * any page-observable state: `canvas.getContext(...)` is deliberately NOT
@@ -5,12 +5,7 @@
5
5
  * Written ONLY when at least one `--state` was requested (`ctx.state.length
6
6
  * > 0`); otherwise this is a no-op (no file written). Owned by U12.
7
7
  *
8
- * Independent of the other 13 collectors (they all fan out via
9
- * `Promise.all` in `snapshot.ts` — there is no cross-collector read
10
- * ordering, and `geometry.json`/`styles.json`/`hittest.json` may not exist
11
- * yet when this runs), so this collector drives its own minimal
12
- * geometry/style/hittest re-capture per forced element rather than reading
13
- * those files. Scope: top-frame light DOM only — elements inside an
8
+ * This collector drives its own minimal geometry/style/hittest re-capture per forced element rather than reading those files. Scope: top-frame light DOM only — elements inside an
14
9
  * iframe or a shadow root are out of reach of `DOM.querySelectorAll`'s
15
10
  * plain (non-piercing) subtree walk and are not visited here; frame/shadow
16
11
  * stitching is `geometry.json`'s concern, not this collector's.
@@ -29,22 +29,10 @@
29
29
  * re-invoking `CSS.enable` while the domain is ALREADY enabled is a
30
30
  * no-op that redelivers nothing — only a `CSS.disable` + `CSS.enable`
31
31
  * pair forces Chrome to resend every currently-known stylesheet's
32
- * header). Both `styles.ts` and `layers.ts` run in the same `baseline`
33
- * `Promise.all` (see `snapshot.ts`), and `enableDomainsForSnap` already
34
- * enabled CSS once before either collector starts — so a caller-owned
35
- * listener registered only after that point would otherwise never see
36
- * the headers for stylesheets already parsed at that time. Each caller
37
- * runs `captureStyleSheetHeaders` independently, as the very first thing
38
- * it does (before any other CSS-domain call), and awaits it before
39
- * issuing any of its own `CSS.*` calls. Because listener registration is
40
- * synchronous (before any `await`), and JS never yields to socket I/O
41
- * mid-way through `Promise.all`'s synchronous dispatch of every baseline
42
- * collector, both this file's and the other collector's listeners are
43
- * always attached before either's disable/enable pair reaches Chrome —
44
- * so neither can miss the other's redelivery burst, and once both
45
- * callers' own `captureStyleSheetHeaders` calls resolve, the CSS domain
46
- * is stably enabled for the rest of the snapshot (neither file disables
47
- * it again).
32
+ * header). `enableDomainsForSnap` already enabled CSS before either
33
+ * collector starts, so each collector captures its own headers as its
34
+ * first CSS-domain action and awaits that capture before its provenance
35
+ * reads. This keeps either artifact self-contained.
48
36
  */
49
37
 
50
38
  import { resolveAuthoredSourceLocation, type ResolvedSourceLocation } from '../../source-map.js';
@@ -14,9 +14,8 @@
14
14
  * file's `CSS.getMatchedStylesForNode` calls (which need a CDP `nodeId`,
15
15
  * not just a `backendNodeId`) and reused by `media.ts` for its own
16
16
  * per-element `backendNodeId` correlation. Both `styles.ts` and `media.ts`
17
- * independently issue their own `Runtime.evaluate`/`DOM.getDocument`
18
- * calls — they run concurrently in `snapshot.ts`'s `Promise.all`, so there
19
- * is no cross-collector call sharing beyond this shared, stateless logic.
17
+ * independently issue their own `Runtime.evaluate`/`DOM.getDocument` calls,
18
+ * so there is no cross-collector call sharing beyond this shared, stateless logic.
20
19
  */
21
20
 
22
21
  import type { ResolvedSourceLocation } from '../../source-map.js';
@@ -35,6 +34,7 @@ export { computeSpecificity, resolveNodeIds };
35
34
  export type { Specificity, CSSRange, WinningDeclaration } from './style-provenance.js';
36
35
 
37
36
  const STYLES_MAX_ELEMENTS = 150;
37
+ const PROVENANCE_CONCURRENCY = 8;
38
38
 
39
39
  /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
40
  const COMPUTED_PROPERTIES = [
@@ -205,6 +205,20 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
205
205
  return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
206
  }
207
207
 
208
+ /** Limits expensive per-element CDP provenance reads without changing document order. */
209
+ async function mapWithConcurrency<T, R>(values: readonly T[], limit: number, fn: (value: T, index: number) => Promise<R>): Promise<R[]> {
210
+ const result = new Array<R>(values.length);
211
+ let next = 0;
212
+ const worker = async (): Promise<void> => {
213
+ while (next < values.length) {
214
+ const index = next++;
215
+ result[index] = await fn(values[index]!, index);
216
+ }
217
+ };
218
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
219
+ return result;
220
+ }
221
+
208
222
  // ============================================================================
209
223
  // Collector
210
224
  // ============================================================================
@@ -213,9 +227,8 @@ export const collectStyles: Collector = async (ctx) => {
213
227
  const { client } = ctx;
214
228
 
215
229
  // Registers the CSS.styleSheetAdded listener and forces header redelivery
216
- // BEFORE anything else touches the CSS domain — see style-provenance.ts's
217
- // module doc for why this must run first and why it's safe alongside the
218
- // concurrently-running `layers` collector's own copy of the same step.
230
+ // BEFORE this collector uses the CSS domain — see style-provenance.ts's
231
+ // module doc for why header capture must begin before provenance reads.
219
232
  const {
220
233
  urls: styleSheetUrls,
221
234
  stop: stopTrackingStyleSheets,
@@ -292,8 +305,7 @@ async function collectStylesInner(
292
305
 
293
306
  const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
294
307
 
295
- const elements: StylesElementRecord[] = await Promise.all(
296
- facts.map(async (fact, index) => {
308
+ const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact, index): Promise<StylesElementRecord> => {
297
309
  const ref = resolved[index];
298
310
  const computed = normalizeComputed(fact.computed);
299
311
 
@@ -334,8 +346,7 @@ async function collectStylesInner(
334
346
  winningDeclarations,
335
347
  provenanceUnavailable,
336
348
  };
337
- }),
338
- );
349
+ });
339
350
 
340
351
  ctx.write.json('styles.json', {
341
352
  elements,
@@ -27,8 +27,7 @@
27
27
  * identifier `CSS.getPlatformFontsForNode` accepts — and `backendNodeId`.
28
28
  * No `setAttribute`/`removeAttribute` pair and no page-observable global
29
29
  * either, so nothing in the emitted `screenshot.png`/`dom.html`/any other
30
- * baseline collector running concurrently in the same `Promise.all` (see
31
- * `snapshot.ts`) can ever observe this collector having run.
30
+ * baseline artifact capture can ever observe this collector having run.
32
31
  */
33
32
 
34
33
  import type { CDPClient } from '../../client.js';
@@ -421,9 +420,8 @@ async function resolveIndexedObjectIds(
421
420
  export const collectText: Collector = async (ctx) => {
422
421
  // The walk's return value is a plain in-memory `{ facts, elements }`
423
422
  // object — never a `window` global, never a live-DOM attribute — so
424
- // nothing another baseline collector or the `screenshot.png`/`dom.html`
425
- // capture running concurrently in the same `Promise.all` (see
426
- // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
423
+ // nothing another baseline artifact capture could ever observe.
424
+ // `Runtime.evaluate({returnByValue:
427
425
  // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
428
426
  // with zero page visibility into it); every held `objectId` (the
429
427
  // container, `facts`, `elements`) is released via `Runtime.releaseObject`
@@ -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
@@ -140,8 +253,10 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
140
253
  // Apply the shared artifact-string cap once at the source. URL contents
141
254
  // otherwise survive unchanged in meta.json, the result, and collector context.
142
255
  const url = options.url ? sanitizeString(options.url) : null;
256
+ const artifacts: string[] = [];
257
+ const phaseClient = (phase: string): CDPClient => boundedClient(client, phase, dir);
143
258
 
144
- await enableDomainsForSnap(client);
259
+ await enableDomainsForSnap(phaseClient('domains'));
145
260
 
146
261
  // I-6: capture each animation's pre-freeze origin BEFORE this, the first
147
262
  // mutation, and restore it exception-safely — `animationFreezeHandle`/
@@ -173,7 +288,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
173
288
  let freezeIncomplete: boolean | undefined;
174
289
  let unfrozenCount: number | undefined;
175
290
  if (freezeAnimations) {
176
- animationFreezeHandle = await freezeAnimationsBeforeCapture(client);
291
+ animationFreezeHandle = await freezeAnimationsBeforeCapture(phaseClient('freeze-animations'));
177
292
  freezeOverrideApplied = animationFreezeHandle?.rateOverrideApplied;
178
293
  freezeIncomplete = animationFreezeHandle?.freezeIncomplete;
179
294
  unfrozenCount = animationFreezeHandle?.unfrozenCount;
@@ -185,12 +300,12 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
185
300
  if (!animationFreezeHandle) return;
186
301
  const handle = animationFreezeHandle;
187
302
  animationFreezeHandle = undefined;
188
- const result = await restoreAnimationsAfterCapture(client, handle);
303
+ const result = await restoreAnimationsAfterCapture(phaseClient('restore-animations'), handle);
189
304
  animationsRestored = result.restored;
190
305
  };
191
306
 
192
307
  try {
193
- const churnHandle = await injectChurnObservers(client);
308
+ const churnHandle = await injectChurnObservers(phaseClient('settle-observers'));
194
309
 
195
310
  // The churn observers (MutationObserver/ResizeObserver) MUST be torn down
196
311
  // even if sampling throws (a CDP failure mid-poll) — otherwise they leak
@@ -204,7 +319,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
204
319
  let settleResult: Awaited<ReturnType<typeof pollForSettle<string>>>;
205
320
  try {
206
321
  settleResult = await pollForSettle({
207
- captureSample: buildDomSettleSampler(client, churnHandle),
322
+ captureSample: buildDomSettleSampler(phaseClient('settle'), churnHandle),
208
323
  isEqual: domSignaturesEqual,
209
324
  settleTimeoutMs,
210
325
  quietThresholdMs,
@@ -212,7 +327,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
212
327
  });
213
328
  } catch (pollError) {
214
329
  try {
215
- await collectChurnEvidence(client, churnHandle);
330
+ await collectChurnEvidence(phaseClient('settle-cleanup'), churnHandle);
216
331
  } catch {
217
332
  // Secondary teardown failure — the original poll error is what matters.
218
333
  }
@@ -220,22 +335,22 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
220
335
  }
221
336
 
222
337
  // Always call — cleanup, and potential evidence, regardless of settledness.
223
- const churnEvidenceRaw = await collectChurnEvidence(client, churnHandle);
338
+ const churnEvidenceRaw = await collectChurnEvidence(phaseClient('settle-cleanup'), churnHandle);
224
339
 
225
340
  const settled = settleResult.settled;
226
341
  const captured = settled || captureUnsettled;
227
342
  const unstable = !settled;
228
343
 
229
- const artifacts: string[] = [];
230
344
  let unstableRegions: readonly UnstableRegion[] = [];
231
345
  // I-5: set only inside the `captured` branch below (the only place
232
346
  // `dom.html` is ever attempted) — stays `undefined` on the evidence-only
233
347
  // branch, where `meta.json`'s spread omits the field entirely rather than
234
348
  // implying a read that never happened.
235
349
  let domHtmlFact: SnapshotMeta['domHtml'];
350
+ const collectorOutcomes: CollectorOutcome[] = [];
236
351
 
237
352
  if (unstable) {
238
- const animationEvidence = await collectAnimationEvidence(client);
353
+ const animationEvidence = await collectAnimationEvidence(phaseClient('settle-evidence'));
239
354
  const grouped = groupChurnEvidence(churnEvidenceRaw, animationEvidence, settleResult.elapsedMs, settleTimeoutMs);
240
355
  unstableRegions = grouped.unstableRegions;
241
356
  writeJsonPrivate(path.join(dir, 'churn.json'), grouped.report);
@@ -263,19 +378,50 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
263
378
  };
264
379
 
265
380
  const collectors = options.collectors ?? COLLECTORS;
381
+ const skipCollectors = new Set(options.skipCollectors ?? []);
266
382
  const baseline = collectors.filter((c) => c.phase === 'baseline');
267
383
  const mutating = collectors.filter((c) => c.phase === 'mutating');
384
+ const runCollector = async (collector: CollectorDescriptor): Promise<CollectorOutcome> => {
385
+ if (skipCollectors.has(collector.name)) return { name: collector.name, status: 'skipped' };
268
386
 
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)));
387
+ const startedAt = Date.now();
388
+ let requestTimeouts = 0;
389
+ const collectorWriter = makeCollectorWriter(ctx.write);
390
+ try {
391
+ // Await the collector itself, not a wall-clock race around it. Each CDP
392
+ // request is still bounded at five seconds; awaiting the collector keeps
393
+ // any cleanup and pending browser work quiescent before the next shared-
394
+ // connection collector starts.
395
+ await collector.fn({
396
+ ...ctx,
397
+ client: boundedClient(client, `collector:${collector.name}`, dir, () => { requestTimeouts += 1; }),
398
+ write: collectorWriter.writer,
399
+ });
400
+ const ms = Date.now() - startedAt;
401
+ collectorWriter.commit();
402
+ return requestTimeouts > 0
403
+ ? { name: collector.name, status: 'degraded', ms, requestTimeouts }
404
+ : { name: collector.name, status: 'ok', ms };
405
+ } catch {
406
+ const ms = Date.now() - startedAt;
407
+ collectorWriter.discard();
408
+ return { name: collector.name, status: 'error', ms, ...(requestTimeouts > 0 ? { requestTimeouts } : {}) };
409
+ }
410
+ };
411
+
412
+ // Phase 1 — baseline collectors observe the page exactly as it settled.
413
+ // They are serialized to COMPLETION because CDP commands share one browser
414
+ // connection: the next collector starts only once the previous one has
415
+ // returned or thrown, so no abandoned collector can still be issuing CDP
416
+ // commands (or restoring page state) underneath its successor. Every
417
+ // completed result is published before the baseline boundary.
418
+ for (const collector of baseline) collectorOutcomes.push(await runCollector(collector));
273
419
 
274
420
  // Baseline boundary — capture screenshot.png + dom.html BEFORE any
275
421
  // mutating collector runs, so a mutating collector's DOM/focus/scroll/
276
422
  // background change (or a failed restoration) can never contaminate
277
423
  // these artifacts.
278
- const screenshotResponse = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
424
+ const screenshotResponse = (await phaseClient('baseline-screenshot').send('Page.captureScreenshot', { format: 'png' })) as { data: string };
279
425
  writeBinaryPrivate(path.join(dir, 'screenshot.png'), Buffer.from(screenshotResponse.data, 'base64'));
280
426
  artifacts.push('screenshot.png');
281
427
 
@@ -288,7 +434,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
288
434
  let domHtmlValue: string | undefined;
289
435
  let domHtmlUnavailableReason: string | undefined;
290
436
  try {
291
- const domResponse = (await client.send('Runtime.evaluate', {
437
+ const domResponse = (await phaseClient('baseline-dom').send('Runtime.evaluate', {
292
438
  expression: 'document.documentElement.outerHTML',
293
439
  returnByValue: true,
294
440
  })) as { result?: { value?: string } };
@@ -316,11 +462,9 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
316
462
  await restoreAnimationsOnce();
317
463
 
318
464
  // 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
- }
465
+ // captured, one at a time, so their mutations and restorations cannot
466
+ // contaminate each other.
467
+ for (const collector of mutating) collectorOutcomes.push(await runCollector(collector));
324
468
  } else {
325
469
  // Evidence-only branch (unstable, not captured) — nothing depends on
326
470
  // animations staying frozen any longer, so restore now.
@@ -362,6 +506,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
362
506
  // `freezeIncomplete`, matching the handle's own documented contract.
363
507
  ...(freezeIncomplete === true ? { freezeIncomplete: true as const, unfrozenCount: unfrozenCount ?? 0 } : {}),
364
508
  ...(domHtmlFact ? { domHtml: domHtmlFact } : {}),
509
+ ...(captured ? { collectors: collectorOutcomes } : {}),
365
510
  };
366
511
  writeJsonPrivate(path.join(dir, 'meta.json'), meta);
367
512
  artifacts.push('meta.json');
@@ -377,8 +522,29 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
377
522
  settleTimeoutMs,
378
523
  unstableRegions,
379
524
  artifacts,
525
+ collectors: collectorOutcomes,
380
526
  meta,
381
527
  };
528
+ } catch (error) {
529
+ if (error instanceof SnapshotCaptureTimeout) {
530
+ try {
531
+ writeJsonPrivate(path.join(dir, 'capture-timeout.json'), {
532
+ schemaVersion: 1,
533
+ id: snapId,
534
+ status: 'timeout',
535
+ phase: error.phase,
536
+ method: error.method,
537
+ timeoutMs: error.timeoutMs,
538
+ partialPath: dir,
539
+ artifacts,
540
+ capturedAt: new Date().toISOString(),
541
+ });
542
+ artifacts.push('capture-timeout.json');
543
+ } catch {
544
+ // Preserve the timeout and its directory even if recovery metadata cannot be written.
545
+ }
546
+ }
547
+ throw error;
382
548
  } finally {
383
549
  // I-6 exception safety net: if anything above threw before either
384
550
  // natural restore point ran, still restore the page against the
@@ -73,6 +73,32 @@ export interface CaptureSnapshotOptions {
73
73
  * the real collector set `snapshot.ts` defines.
74
74
  */
75
75
  readonly collectors?: readonly CollectorDescriptor[];
76
+ /** `--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. */
77
+ readonly skipCollectors?: readonly string[];
78
+ }
79
+
80
+ /**
81
+ * What one collector did in this capture — the honest ledger written to
82
+ * `meta.json`'s `collectors` field, so an absent artifact always names its
83
+ * reason.
84
+ *
85
+ * - `ok`: ran to completion; its artifact is present and trustworthy.
86
+ * - `degraded`: ran to completion, but at least one of its CDP requests was
87
+ * killed by the per-request bound. The artifact exists and its own
88
+ * per-record failure markers are honest, but some of what it reports is a
89
+ * timed-out read rather than a measurement — `requestTimeouts` counts them.
90
+ * - `error`: threw before completing (including a CDP request killed by the
91
+ * per-request bound that the collector did not itself absorb). Its artifact is
92
+ * absent because writes publish only after collector completion.
93
+ * - `skipped`: excluded by `--skip-collector`; never ran.
94
+ */
95
+ export interface CollectorOutcome {
96
+ readonly name: string;
97
+ readonly status: 'ok' | 'degraded' | 'error' | 'skipped';
98
+ /** Wall-clock ms the collector ran. Absent for `skipped` — it never ran. */
99
+ readonly ms?: number;
100
+ /** Present only when at least one of this collector's CDP requests was killed by the per-request bound. */
101
+ readonly requestTimeouts?: number;
76
102
  }
77
103
 
78
104
  /** `meta.json`'s on-disk shape — a {@link SnapMeta} (the U03 contract) plus snapshot-specific detail. */
@@ -94,6 +120,8 @@ export interface SnapshotMeta extends SnapMeta {
94
120
  readonly unfrozenCount?: number;
95
121
  /** 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
122
  readonly domHtml?: { readonly available: boolean; readonly unavailableReason?: string };
123
+ /** 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}. */
124
+ readonly collectors?: readonly CollectorOutcome[];
97
125
  }
98
126
 
99
127
  /** Return value of {@link captureSnapshotSubstrate}. */
@@ -110,6 +138,8 @@ export interface CaptureSnapshotResult {
110
138
  readonly unstableRegions: readonly UnstableRegion[];
111
139
  /** Filenames written under `dir`, relative to it. */
112
140
  readonly artifacts: readonly string[];
141
+ /** Per-collector outcome ledger; empty on an evidence-only capture where no collector ran. */
142
+ readonly collectors: readonly CollectorOutcome[];
113
143
  readonly meta: SnapshotMeta;
114
144
  }
115
145
 
@@ -159,8 +189,8 @@ export type Collector = (ctx: SnapshotContext) => Promise<void>;
159
189
  * Which snapshot phase a collector belongs to — the ordering guarantee the
160
190
  * orchestrator enforces:
161
191
  * - `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.
192
+ * settled. They run in descriptor order, and all finish before
193
+ * `screenshot.png` + `dom.html` are captured at the baseline boundary.
164
194
  * - `mutating`: collectors that mutate DOM/focus/scroll/background
165
195
  * (focus, scroll, states, pixels). They run AFTER the baseline artifacts
166
196
  * 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 };