@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.
- package/bin/capture +4109 -3229
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +14 -2
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/commands/measure/snap.ts +37 -2
- 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/shot.ts +26 -5
- 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 +195 -29
- package/src/cdp/measure/types.ts +32 -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 +4 -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
|
@@ -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
|
|
45
|
-
* collector
|
|
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
|
|
632
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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).
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
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
|
|
217
|
-
// module doc for why
|
|
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
|
|
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
|
|
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
|
|
425
|
-
//
|
|
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`
|
|
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
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
|
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,
|
|
320
|
-
//
|
|
321
|
-
for (const
|
|
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
|
package/src/cdp/measure/types.ts
CHANGED
|
@@ -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
|
|
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
|
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 };
|