@crouton-kit/capture 1.3.8 → 1.3.9
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 +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
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/
|
|
6
|
+
* background changes can't contaminate the baseline artifacts). `meta.json`
|
|
7
|
+
* is ALWAYS written last. This is the one function
|
|
8
|
+
* `src/cdp/commands/measure/snap.ts` (U15) calls; nothing else drives a
|
|
9
|
+
* snapshot capture.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
|
|
14
|
+
import { ensurePrivateDir, writeJsonPrivate, writeBinaryPrivate, writePrivateFile } from '../../session/artifacts.js';
|
|
15
|
+
import { enableDomainsForSnap } from '../domains.js';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_SETTLE_TIMEOUT_MS,
|
|
18
|
+
DEFAULT_QUIET_THRESHOLD_MS,
|
|
19
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
20
|
+
pollForSettle,
|
|
21
|
+
buildDomSettleSampler,
|
|
22
|
+
domSignaturesEqual,
|
|
23
|
+
injectChurnObservers,
|
|
24
|
+
collectChurnEvidence,
|
|
25
|
+
freezeAnimationsBeforeCapture,
|
|
26
|
+
restoreAnimationsAfterCapture,
|
|
27
|
+
collectAnimationEvidence,
|
|
28
|
+
groupChurnEvidence,
|
|
29
|
+
} from './settle.js';
|
|
30
|
+
import type { AnimationFreezeHandle } from './settle.js';
|
|
31
|
+
import { sanitizeString } from './redaction.js';
|
|
32
|
+
import type {
|
|
33
|
+
CaptureSnapshotOptions,
|
|
34
|
+
CaptureSnapshotResult,
|
|
35
|
+
SnapshotContext,
|
|
36
|
+
SnapshotMeta,
|
|
37
|
+
SnapshotWriter,
|
|
38
|
+
UnstableRegion,
|
|
39
|
+
CollectorDescriptor,
|
|
40
|
+
} from './types.js';
|
|
41
|
+
|
|
42
|
+
import { collectGeometry } from './collectors/geometry.js';
|
|
43
|
+
import { collectHittest } from './collectors/hittest.js';
|
|
44
|
+
import { collectStyles } from './collectors/styles.js';
|
|
45
|
+
import { collectQueries } from './collectors/queries.js';
|
|
46
|
+
import { collectAx } from './collectors/ax.js';
|
|
47
|
+
import { collectText } from './collectors/text.js';
|
|
48
|
+
import { collectForms } from './collectors/forms.js';
|
|
49
|
+
import { collectAnimation } from './collectors/animation.js';
|
|
50
|
+
import { collectFocus } from './collectors/focus.js';
|
|
51
|
+
import { collectScroll } from './collectors/scroll.js';
|
|
52
|
+
import { collectLayers } from './collectors/layers.js';
|
|
53
|
+
import { collectPixels } from './collectors/pixels.js';
|
|
54
|
+
import { collectStates } from './collectors/states.js';
|
|
55
|
+
import { collectMedia } from './collectors/media.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
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
|
|
61
|
+
* DOM/focus/scroll/background mutations cannot contaminate the baseline
|
|
62
|
+
* artifacts. Geometry/hittest/text/forms are safely `baseline`: none of
|
|
63
|
+
* them ever mutates the DOM — each resolves its elements' `backendNodeId`
|
|
64
|
+
* through the read-only CDP object-id bridge (`geometry.ts`'s
|
|
65
|
+
* `resolveIndexedObjectIds`/`describeBackendNodeId`, off a held
|
|
66
|
+
* `{ facts, elements }` container returned by value and never assigned to
|
|
67
|
+
* `window`), so nothing another baseline collector or the
|
|
68
|
+
* `screenshot.png`/`dom.html` capture could observe ever changes.
|
|
69
|
+
*/
|
|
70
|
+
const COLLECTORS: readonly CollectorDescriptor[] = [
|
|
71
|
+
{ name: 'ax', phase: 'baseline', fn: collectAx },
|
|
72
|
+
{ name: 'styles', phase: 'baseline', fn: collectStyles },
|
|
73
|
+
{ name: 'queries', phase: 'baseline', fn: collectQueries },
|
|
74
|
+
{ name: 'animation', phase: 'baseline', fn: collectAnimation },
|
|
75
|
+
{ name: 'layers', phase: 'baseline', fn: collectLayers },
|
|
76
|
+
{ name: 'media', phase: 'baseline', fn: collectMedia },
|
|
77
|
+
{ name: 'geometry', phase: 'baseline', fn: collectGeometry },
|
|
78
|
+
{ name: 'hittest', phase: 'baseline', fn: collectHittest },
|
|
79
|
+
{ name: 'text', phase: 'baseline', fn: collectText },
|
|
80
|
+
{ name: 'forms', phase: 'baseline', fn: collectForms },
|
|
81
|
+
{ name: 'focus', phase: 'mutating', fn: collectFocus },
|
|
82
|
+
{ name: 'scroll', phase: 'mutating', fn: collectScroll },
|
|
83
|
+
{ name: 'states', phase: 'mutating', fn: collectStates },
|
|
84
|
+
{ name: 'pixels', phase: 'mutating', fn: collectPixels },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves `filename` against the snap `dir` and rejects (throws) anything
|
|
89
|
+
* that would escape it — an absolute path, or a `path.relative(dir, target)`
|
|
90
|
+
* that starts climbing out (`..`). The U03 secure-fs helpers only enforce the
|
|
91
|
+
* global `CAPTURE_ROOT` boundary, so this is what actually scopes a
|
|
92
|
+
* collector's writes to the snap directory `SnapshotWriter` documents.
|
|
93
|
+
*/
|
|
94
|
+
function resolveScopedArtifactPath(dir: string, filename: string): string {
|
|
95
|
+
const target = path.resolve(dir, filename);
|
|
96
|
+
const rel = path.relative(dir, target);
|
|
97
|
+
const escapesDir = rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
98
|
+
if (escapesDir) {
|
|
99
|
+
throw new Error(`Refusing to write snapshot artifact "${filename}" outside its snap dir ${dir}`);
|
|
100
|
+
}
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeWriter(dir: string, artifacts: string[]): SnapshotWriter {
|
|
105
|
+
return {
|
|
106
|
+
json(filename: string, value: unknown): void {
|
|
107
|
+
writeJsonPrivate(resolveScopedArtifactPath(dir, filename), value);
|
|
108
|
+
artifacts.push(filename);
|
|
109
|
+
},
|
|
110
|
+
binary(filename: string, data: Buffer): void {
|
|
111
|
+
writeBinaryPrivate(resolveScopedArtifactPath(dir, filename), data);
|
|
112
|
+
artifacts.push(filename);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Orchestrator
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Captures one snapshot substrate directory: enables the CDP domains,
|
|
123
|
+
* runs the settledness gate, and — when the page settled or the caller
|
|
124
|
+
* forced `captureUnsettled` — writes the full collector substrate plus
|
|
125
|
+
* `screenshot.png`/`dom.html`. `meta.json` is written on every branch,
|
|
126
|
+
* unconditionally, last.
|
|
127
|
+
*/
|
|
128
|
+
export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions): Promise<CaptureSnapshotResult> {
|
|
129
|
+
const client = options.target.client;
|
|
130
|
+
const dir = ensurePrivateDir(options.path);
|
|
131
|
+
const snapId = options.snapId ?? path.basename(dir);
|
|
132
|
+
const settleTimeoutMs = options.settleTimeout ?? DEFAULT_SETTLE_TIMEOUT_MS;
|
|
133
|
+
const quietThresholdMs = options.quietThresholdMs ?? DEFAULT_QUIET_THRESHOLD_MS;
|
|
134
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
135
|
+
const freezeAnimations = options.freezeAnimations ?? false;
|
|
136
|
+
const captureUnsettled = options.captureUnsettled ?? false;
|
|
137
|
+
const pixels = options.pixels ?? false;
|
|
138
|
+
const state = options.state ?? [];
|
|
139
|
+
const viewport = options.viewport ?? null;
|
|
140
|
+
// Apply the shared artifact-string cap once at the source. URL contents
|
|
141
|
+
// otherwise survive unchanged in meta.json, the result, and collector context.
|
|
142
|
+
const url = options.url ? sanitizeString(options.url) : null;
|
|
143
|
+
|
|
144
|
+
await enableDomainsForSnap(client);
|
|
145
|
+
|
|
146
|
+
// I-6: capture each animation's pre-freeze origin BEFORE this, the first
|
|
147
|
+
// mutation, and restore it exception-safely — `animationFreezeHandle`/
|
|
148
|
+
// `animationsRestored` are threaded through the whole function (via the
|
|
149
|
+
// outer `finally` below) so a throw anywhere after this point still gets
|
|
150
|
+
// a real restore attempt, never a page left silently forced.
|
|
151
|
+
let animationFreezeHandle: AnimationFreezeHandle | undefined;
|
|
152
|
+
// Pessimistic default: only flips to `true` on a confirmed restore, so an
|
|
153
|
+
// origin-capture failure (handle stays `undefined`, restoreAnimationsOnce
|
|
154
|
+
// never runs) still reports the honest `false` rather than omitting the
|
|
155
|
+
// fact entirely.
|
|
156
|
+
let animationsRestored: boolean | undefined = freezeAnimations ? false : undefined;
|
|
157
|
+
// #8: capture the freeze-OVERRIDE-apply fact right here, at origin-capture
|
|
158
|
+
// time — `restoreAnimationsOnce` below unconditionally nulls out
|
|
159
|
+
// `animationFreezeHandle` once consumed, so `handle.rateOverrideApplied`
|
|
160
|
+
// would no longer be reachable by the time `meta.json` is built if it
|
|
161
|
+
// weren't captured into its own variable now. Stays `undefined` when
|
|
162
|
+
// freeze wasn't requested at all, OR when the origin capture itself
|
|
163
|
+
// failed (no handle was ever returned — nothing was mutated, and that
|
|
164
|
+
// failure is already surfaced honestly via the pessimistic
|
|
165
|
+
// `animationsRestored:false` default below).
|
|
166
|
+
let freezeOverrideApplied: boolean | undefined;
|
|
167
|
+
// #72: same capture-now rationale as `freezeOverrideApplied` above —
|
|
168
|
+
// `restoreAnimationsOnce` nulls out `animationFreezeHandle` once consumed,
|
|
169
|
+
// so the per-animation pause-failure facts would no longer be reachable by
|
|
170
|
+
// the time `meta.json` is built if they weren't captured into their own
|
|
171
|
+
// variables now. Both stay `undefined` when freeze wasn't requested, OR
|
|
172
|
+
// when the origin capture itself failed (no handle was ever returned).
|
|
173
|
+
let freezeIncomplete: boolean | undefined;
|
|
174
|
+
let unfrozenCount: number | undefined;
|
|
175
|
+
if (freezeAnimations) {
|
|
176
|
+
animationFreezeHandle = await freezeAnimationsBeforeCapture(client);
|
|
177
|
+
freezeOverrideApplied = animationFreezeHandle?.rateOverrideApplied;
|
|
178
|
+
freezeIncomplete = animationFreezeHandle?.freezeIncomplete;
|
|
179
|
+
unfrozenCount = animationFreezeHandle?.unfrozenCount;
|
|
180
|
+
}
|
|
181
|
+
// Idempotent — the second (or only) call is a no-op once the handle has
|
|
182
|
+
// been consumed, so calling it both at the natural restore point AND from
|
|
183
|
+
// the `finally` safety net below never double-restores.
|
|
184
|
+
const restoreAnimationsOnce = async (): Promise<void> => {
|
|
185
|
+
if (!animationFreezeHandle) return;
|
|
186
|
+
const handle = animationFreezeHandle;
|
|
187
|
+
animationFreezeHandle = undefined;
|
|
188
|
+
const result = await restoreAnimationsAfterCapture(client, handle);
|
|
189
|
+
animationsRestored = result.restored;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const churnHandle = await injectChurnObservers(client);
|
|
194
|
+
|
|
195
|
+
// The churn observers (MutationObserver/ResizeObserver) MUST be torn down
|
|
196
|
+
// even if sampling throws (a CDP failure mid-poll) — otherwise they leak
|
|
197
|
+
// and contaminate later captures in the same tab. Teardown is attempted
|
|
198
|
+
// unconditionally; if the poll failed, its error is what gets rethrown
|
|
199
|
+
// (a secondary teardown failure never masks it). Exactly one of the two
|
|
200
|
+
// `collectChurnEvidence(client, churnHandle)` calls below runs per
|
|
201
|
+
// invocation — the catch path rethrows, so the normal-path call below it
|
|
202
|
+
// never also runs — and `collectChurnEvidence` owns the handle's single
|
|
203
|
+
// `Runtime.releaseObject`, so the handle is released exactly once either way.
|
|
204
|
+
let settleResult: Awaited<ReturnType<typeof pollForSettle<string>>>;
|
|
205
|
+
try {
|
|
206
|
+
settleResult = await pollForSettle({
|
|
207
|
+
captureSample: buildDomSettleSampler(client, churnHandle),
|
|
208
|
+
isEqual: domSignaturesEqual,
|
|
209
|
+
settleTimeoutMs,
|
|
210
|
+
quietThresholdMs,
|
|
211
|
+
pollIntervalMs,
|
|
212
|
+
});
|
|
213
|
+
} catch (pollError) {
|
|
214
|
+
try {
|
|
215
|
+
await collectChurnEvidence(client, churnHandle);
|
|
216
|
+
} catch {
|
|
217
|
+
// Secondary teardown failure — the original poll error is what matters.
|
|
218
|
+
}
|
|
219
|
+
throw pollError;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Always call — cleanup, and potential evidence, regardless of settledness.
|
|
223
|
+
const churnEvidenceRaw = await collectChurnEvidence(client, churnHandle);
|
|
224
|
+
|
|
225
|
+
const settled = settleResult.settled;
|
|
226
|
+
const captured = settled || captureUnsettled;
|
|
227
|
+
const unstable = !settled;
|
|
228
|
+
|
|
229
|
+
const artifacts: string[] = [];
|
|
230
|
+
let unstableRegions: readonly UnstableRegion[] = [];
|
|
231
|
+
// I-5: set only inside the `captured` branch below (the only place
|
|
232
|
+
// `dom.html` is ever attempted) — stays `undefined` on the evidence-only
|
|
233
|
+
// branch, where `meta.json`'s spread omits the field entirely rather than
|
|
234
|
+
// implying a read that never happened.
|
|
235
|
+
let domHtmlFact: SnapshotMeta['domHtml'];
|
|
236
|
+
|
|
237
|
+
if (unstable) {
|
|
238
|
+
const animationEvidence = await collectAnimationEvidence(client);
|
|
239
|
+
const grouped = groupChurnEvidence(churnEvidenceRaw, animationEvidence, settleResult.elapsedMs, settleTimeoutMs);
|
|
240
|
+
unstableRegions = grouped.unstableRegions;
|
|
241
|
+
writeJsonPrivate(path.join(dir, 'churn.json'), grouped.report);
|
|
242
|
+
artifacts.push('churn.json');
|
|
243
|
+
if (!captured) {
|
|
244
|
+
writeJsonPrivate(path.join(dir, 'animation.json'), animationEvidence);
|
|
245
|
+
artifacts.push('animation.json');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (captured) {
|
|
250
|
+
const ctx: SnapshotContext = {
|
|
251
|
+
client,
|
|
252
|
+
dir,
|
|
253
|
+
snapId,
|
|
254
|
+
url,
|
|
255
|
+
viewport,
|
|
256
|
+
settled,
|
|
257
|
+
freezeAnimations,
|
|
258
|
+
captureUnsettled,
|
|
259
|
+
pixels,
|
|
260
|
+
state,
|
|
261
|
+
unstableRegions,
|
|
262
|
+
write: makeWriter(dir, artifacts),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const collectors = options.collectors ?? COLLECTORS;
|
|
266
|
+
const baseline = collectors.filter((c) => c.phase === 'baseline');
|
|
267
|
+
const mutating = collectors.filter((c) => c.phase === 'mutating');
|
|
268
|
+
|
|
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)));
|
|
273
|
+
|
|
274
|
+
// Baseline boundary — capture screenshot.png + dom.html BEFORE any
|
|
275
|
+
// mutating collector runs, so a mutating collector's DOM/focus/scroll/
|
|
276
|
+
// background change (or a failed restoration) can never contaminate
|
|
277
|
+
// these artifacts.
|
|
278
|
+
const screenshotResponse = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
|
|
279
|
+
writeBinaryPrivate(path.join(dir, 'screenshot.png'), Buffer.from(screenshotResponse.data, 'base64'));
|
|
280
|
+
artifacts.push('screenshot.png');
|
|
281
|
+
|
|
282
|
+
// I-5: an `outerHTML` read that throws or returns no `value` is a
|
|
283
|
+
// FAILED read, not the fact "the document is empty" — coercing it to
|
|
284
|
+
// `''` would write a valid-looking-but-empty `dom.html` indistinguishable
|
|
285
|
+
// from a genuinely empty document. Surface the failure as an explicit
|
|
286
|
+
// `meta.json` fact and skip writing the file (an absent artifact, never
|
|
287
|
+
// a benign empty one) rather than fabricate the read.
|
|
288
|
+
let domHtmlValue: string | undefined;
|
|
289
|
+
let domHtmlUnavailableReason: string | undefined;
|
|
290
|
+
try {
|
|
291
|
+
const domResponse = (await client.send('Runtime.evaluate', {
|
|
292
|
+
expression: 'document.documentElement.outerHTML',
|
|
293
|
+
returnByValue: true,
|
|
294
|
+
})) as { result?: { value?: string } };
|
|
295
|
+
domHtmlValue = domResponse.result?.value;
|
|
296
|
+
if (domHtmlValue === undefined) {
|
|
297
|
+
domHtmlUnavailableReason = 'dom-evaluate-returned-no-value';
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
domHtmlValue = undefined;
|
|
301
|
+
domHtmlUnavailableReason = 'dom-evaluate-threw';
|
|
302
|
+
}
|
|
303
|
+
if (domHtmlValue === undefined) {
|
|
304
|
+
domHtmlFact = { available: false, unavailableReason: domHtmlUnavailableReason };
|
|
305
|
+
} else {
|
|
306
|
+
writePrivateFile(path.join(dir, 'dom.html'), domHtmlValue);
|
|
307
|
+
artifacts.push('dom.html');
|
|
308
|
+
domHtmlFact = { available: true };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// I-6: restore right after the frozen baseline artifacts are captured
|
|
312
|
+
// — animations must stay paused through screenshot.png/dom.html (the
|
|
313
|
+
// entire point of `--freeze-animations`), but a mutating-phase
|
|
314
|
+
// collector (focus/scroll/states probing) should observe the page as
|
|
315
|
+
// it would otherwise behave, not one still forced.
|
|
316
|
+
await restoreAnimationsOnce();
|
|
317
|
+
|
|
318
|
+
// 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
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
// Evidence-only branch (unstable, not captured) — nothing depends on
|
|
326
|
+
// animations staying frozen any longer, so restore now.
|
|
327
|
+
await restoreAnimationsOnce();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const meta: SnapshotMeta = {
|
|
331
|
+
id: snapId,
|
|
332
|
+
url,
|
|
333
|
+
viewport,
|
|
334
|
+
settled,
|
|
335
|
+
capturedAt: new Date().toISOString(),
|
|
336
|
+
settleMs: settleResult.elapsedMs,
|
|
337
|
+
settleTimeoutMs,
|
|
338
|
+
freezeAnimations,
|
|
339
|
+
captureUnsettled,
|
|
340
|
+
pixels,
|
|
341
|
+
states: state.map((spec) => sanitizeString(spec)),
|
|
342
|
+
unstableRegionCount: unstableRegions.length,
|
|
343
|
+
...(freezeAnimations ? { animationsRestored: animationsRestored ?? false } : {}),
|
|
344
|
+
// #8: the browser-wide `Animation.setPlaybackRate({playbackRate:0})`
|
|
345
|
+
// override itself failing to apply is a genuine I-6 fact distinct from
|
|
346
|
+
// `animationsRestored` above (which reflects only the RESTORE step) —
|
|
347
|
+
// a page can restore cleanly from whatever WAS frozen at origin-capture
|
|
348
|
+
// time while still never having had the browser-wide override applied,
|
|
349
|
+
// meaning any animation created after freeze was never covered. Only
|
|
350
|
+
// ever written when the override genuinely failed; a successful
|
|
351
|
+
// override (or freeze never requested / origin capture itself failed,
|
|
352
|
+
// both already covered elsewhere) leaves this field absent — never a
|
|
353
|
+
// fabricated affirmative value standing in for "we don't actually know".
|
|
354
|
+
...(freezeOverrideApplied === false ? { freezeOverrideApplied: false } : {}),
|
|
355
|
+
// #72: only the noteworthy (incomplete) case is ever written — same
|
|
356
|
+
// convention as `freezeOverrideApplied` above. A clean freeze (or
|
|
357
|
+
// freeze not requested, or origin capture itself failed) leaves this
|
|
358
|
+
// absent; a consumer must never read the absence as "confirmed clean"
|
|
359
|
+
// on its own, only alongside the other freeze facts already emitted
|
|
360
|
+
// (`animationsRestored`, `freezeOverrideApplied`) that cover those
|
|
361
|
+
// other cases. `unfrozenCount` is only ever emitted alongside a `true`
|
|
362
|
+
// `freezeIncomplete`, matching the handle's own documented contract.
|
|
363
|
+
...(freezeIncomplete === true ? { freezeIncomplete: true as const, unfrozenCount: unfrozenCount ?? 0 } : {}),
|
|
364
|
+
...(domHtmlFact ? { domHtml: domHtmlFact } : {}),
|
|
365
|
+
};
|
|
366
|
+
writeJsonPrivate(path.join(dir, 'meta.json'), meta);
|
|
367
|
+
artifacts.push('meta.json');
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
id: snapId,
|
|
371
|
+
dir,
|
|
372
|
+
url,
|
|
373
|
+
viewport,
|
|
374
|
+
settled,
|
|
375
|
+
captured,
|
|
376
|
+
settleMs: settleResult.elapsedMs,
|
|
377
|
+
settleTimeoutMs,
|
|
378
|
+
unstableRegions,
|
|
379
|
+
artifacts,
|
|
380
|
+
meta,
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
// I-6 exception safety net: if anything above threw before either
|
|
384
|
+
// natural restore point ran, still restore the page against the
|
|
385
|
+
// captured origin — `restoreAnimationsAfterCapture` never throws, so
|
|
386
|
+
// this can't mask the original error, and it's a no-op once the handle
|
|
387
|
+
// has already been consumed.
|
|
388
|
+
await restoreAnimationsOnce();
|
|
389
|
+
}
|
|
390
|
+
}
|