@crouton-kit/capture 1.3.18 → 1.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +4204 -3244
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +22 -3
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/color-scheme.ts +47 -0
- package/src/cdp/commands/measure/index.ts +1 -1
- package/src/cdp/commands/measure/snap.ts +50 -8
- package/src/cdp/commands/motion/mask.ts +8 -2
- package/src/cdp/commands/page/click.ts +14 -0
- package/src/cdp/commands/page/elements.ts +2 -2
- package/src/cdp/commands/page/index.ts +1 -1
- package/src/cdp/commands/page/shot.ts +58 -13
- package/src/cdp/commands/tab/index.ts +19 -8
- package/src/cdp/commands/tab/launch.ts +73 -0
- package/src/cdp/commands/tab/list.ts +14 -2
- package/src/cdp/commands/tab/quit.ts +78 -0
- package/src/cdp/har-recorder.ts +14 -20
- package/src/cdp/measure/collectors/ax.ts +3 -4
- package/src/cdp/measure/collectors/forms.ts +4 -6
- package/src/cdp/measure/collectors/media.ts +1 -2
- package/src/cdp/measure/collectors/states.ts +1 -6
- package/src/cdp/measure/collectors/style-provenance.ts +4 -16
- package/src/cdp/measure/collectors/styles.ts +21 -10
- package/src/cdp/measure/collectors/text.ts +3 -5
- package/src/cdp/measure/snapshot.ts +197 -29
- package/src/cdp/measure/types.ts +36 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +6 -0
- package/src/har-manager.ts +16 -15
- package/src/interact.ts +20 -4
- package/src/output/render.ts +2 -0
- package/src/registry.ts +1 -1
- package/src/session/commands.ts +223 -43
- package/src/session/har-redact.ts +125 -0
|
@@ -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`
|
|
4
|
-
*
|
|
5
|
-
* then `mutating` collectors
|
|
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.
|
|
59
|
-
*
|
|
60
|
-
* `mutating` collectors run afterward,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
|
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,
|
|
320
|
-
//
|
|
321
|
-
for (const
|
|
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
|
package/src/cdp/measure/types.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/cdp/motion/jank.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/cdp/screenshot.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|