@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,330 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
import type { CDPClient } from '../client.js';
|
|
6
|
+
import { ensurePrivateDir, writeJsonPrivate } from '../../session/artifacts.js';
|
|
7
|
+
import type { SnapshotMeta } from './types.js';
|
|
8
|
+
|
|
9
|
+
export const SWEEP_AXES = ['width', 'dpr', 'zoom', 'color-scheme', 'reduced-motion'] as const;
|
|
10
|
+
export type SweepAxis = (typeof SWEEP_AXES)[number];
|
|
11
|
+
|
|
12
|
+
export interface SweepSample {
|
|
13
|
+
readonly value: number | string;
|
|
14
|
+
readonly snapId: string;
|
|
15
|
+
readonly snapDir: string;
|
|
16
|
+
readonly fingerprint: string;
|
|
17
|
+
readonly settled: boolean;
|
|
18
|
+
readonly unstableRegions: readonly { id: string; selector?: string; reason?: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SweepTransition {
|
|
22
|
+
readonly bracket: { readonly from: number | string; readonly to: number | string };
|
|
23
|
+
readonly before: string;
|
|
24
|
+
readonly after: string;
|
|
25
|
+
readonly changes: readonly SweepChange[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SweepChange {
|
|
29
|
+
readonly selector: string;
|
|
30
|
+
readonly property: string;
|
|
31
|
+
readonly before: string | null;
|
|
32
|
+
readonly after: string | null;
|
|
33
|
+
readonly provenance?: {
|
|
34
|
+
readonly selector?: string;
|
|
35
|
+
readonly specificity?: string;
|
|
36
|
+
readonly source?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Adjacent observed samples with matching fingerprints; this is not a claim about unobserved values between them. */
|
|
41
|
+
export interface SweepRange {
|
|
42
|
+
readonly from: number | string;
|
|
43
|
+
readonly to: number | string;
|
|
44
|
+
readonly snapId: string;
|
|
45
|
+
readonly fingerprint: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SweepUncertainty {
|
|
49
|
+
readonly from: number;
|
|
50
|
+
readonly to: number;
|
|
51
|
+
readonly reason: 'sampling_limit' | 'resolution_limit';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SweepArtifact {
|
|
55
|
+
readonly axis: SweepAxis;
|
|
56
|
+
readonly from: number | string;
|
|
57
|
+
readonly to: number | string;
|
|
58
|
+
readonly capturedAt: string;
|
|
59
|
+
readonly samples: readonly SweepSample[];
|
|
60
|
+
readonly transitions: readonly SweepTransition[];
|
|
61
|
+
readonly ranges: readonly SweepRange[];
|
|
62
|
+
readonly uncertainties: readonly SweepUncertainty[];
|
|
63
|
+
readonly sampleLimit?: number;
|
|
64
|
+
/** Effective environment facts captured before sampling; CDP does not expose arbitrary pre-existing override configuration. */
|
|
65
|
+
readonly environmentRestoration?: {
|
|
66
|
+
readonly observed: readonly string[];
|
|
67
|
+
readonly unobservable: readonly string[];
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SweepRecovery {
|
|
72
|
+
readonly axis: SweepAxis;
|
|
73
|
+
readonly capturedAt: string;
|
|
74
|
+
readonly reason: 'evidence_only' | 'capture_failed';
|
|
75
|
+
/** Whether restoration was not reached, completed, or threw while handling the failed sweep. */
|
|
76
|
+
readonly environmentRestoration: 'not_attempted' | 'restored' | 'failed';
|
|
77
|
+
readonly samples: readonly {
|
|
78
|
+
value: number | string;
|
|
79
|
+
snapId: string;
|
|
80
|
+
snapDir: string;
|
|
81
|
+
/** The capture lifecycle state known by the orchestrator; failed records may contain artifacts written before the throw. */
|
|
82
|
+
status: 'pending' | 'captured' | 'evidence_only' | 'failed';
|
|
83
|
+
captured: boolean;
|
|
84
|
+
settled: boolean;
|
|
85
|
+
unstableRegions: readonly { id: string; selector?: string; reason?: string }[];
|
|
86
|
+
artifacts: readonly string[];
|
|
87
|
+
failure?: 'capture_threw';
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface StylesElement {
|
|
92
|
+
readonly id?: string;
|
|
93
|
+
readonly selector?: string;
|
|
94
|
+
readonly computed?: Record<string, string | null>;
|
|
95
|
+
readonly winningDeclarations?: readonly {
|
|
96
|
+
readonly property?: string;
|
|
97
|
+
readonly selector?: string | null;
|
|
98
|
+
readonly specificity?: string | null;
|
|
99
|
+
readonly authored?: { readonly file?: string; readonly line?: number; readonly column?: number };
|
|
100
|
+
readonly generated?: { readonly sourceURL?: string; readonly line?: number; readonly column?: number };
|
|
101
|
+
}[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface StylesReport {
|
|
105
|
+
readonly elements?: readonly StylesElement[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface GeometryReport {
|
|
109
|
+
readonly elements?: readonly { readonly id?: string; readonly selector?: string; readonly visible?: boolean; readonly display?: string }[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface TextElement {
|
|
113
|
+
readonly id?: string;
|
|
114
|
+
readonly selector?: string;
|
|
115
|
+
readonly backendNodeId?: number | null;
|
|
116
|
+
readonly lines?: readonly unknown[];
|
|
117
|
+
readonly wrapOffsets?: readonly number[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface TextReport {
|
|
121
|
+
readonly elements?: readonly TextElement[];
|
|
122
|
+
readonly nodes?: readonly TextElement[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DISCRETE_COMPUTED_PROPERTIES = new Set([
|
|
126
|
+
'display', 'visibility', 'position', 'float', 'clear', 'overflow', 'overflow-x', 'overflow-y',
|
|
127
|
+
'flex-direction', 'flex-wrap', 'grid-auto-flow', 'justify-content', 'align-items', 'align-content',
|
|
128
|
+
'white-space', 'word-break', 'overflow-wrap', 'text-overflow', 'content-visibility', 'pointer-events',
|
|
129
|
+
'writing-mode', 'direction', 'font-style', 'color', 'background-color',
|
|
130
|
+
'grid-template-areas', 'color-scheme', 'mix-blend-mode', 'isolation',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
function readJson<T>(filePath: string): T | undefined {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function gridTrackCount(value: string | null | undefined): number | undefined {
|
|
142
|
+
if (!value) return undefined;
|
|
143
|
+
const repeat = /^repeat\(\s*(\d+)\s*,/.exec(value);
|
|
144
|
+
if (repeat) return Number(repeat[1]);
|
|
145
|
+
const tracks = value.trim().split(/\s+/).filter(Boolean);
|
|
146
|
+
return tracks.length || undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function colorCategory(value: string | null | undefined): string | undefined {
|
|
150
|
+
if (!value) return undefined;
|
|
151
|
+
const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/.exec(value);
|
|
152
|
+
if (!rgb) return 'other';
|
|
153
|
+
const [red, green, blue, alpha] = rgb.slice(1).map(Number);
|
|
154
|
+
if (alpha !== undefined && alpha === 0) return 'transparent';
|
|
155
|
+
const maximum = Math.max(red, green, blue);
|
|
156
|
+
const dominant = `${red === maximum ? 'r' : ''}${green === maximum ? 'g' : ''}${blue === maximum ? 'b' : ''}`;
|
|
157
|
+
return `${dominant}-${maximum + Math.min(red, green, blue) >= 255 ? 'light' : 'dark'}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fingerprintComputed(property: string, value: string | null): string | null | undefined {
|
|
161
|
+
if (property === 'color' || property === 'background-color') return colorCategory(value);
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A deterministic signature containing only categorical rendered-state facts. Fluid geometry, numeric styles, collector identities, and CSS provenance are deliberately excluded. Each collector's document-order records stay ordered so anonymous elements retain their structural association. */
|
|
166
|
+
export function fingerprintSnapshotDir(snapDir: string): string {
|
|
167
|
+
const geometry = readJson<GeometryReport>(path.join(snapDir, 'geometry.json'));
|
|
168
|
+
const styles = readJson<StylesReport>(path.join(snapDir, 'styles.json'));
|
|
169
|
+
const text = readJson<TextReport>(path.join(snapDir, 'text.json'));
|
|
170
|
+
const substrate = {
|
|
171
|
+
geometry: geometry?.elements?.map((element) => ({ visible: element.visible, display: element.display })) ?? { missing: 'geometry.json' },
|
|
172
|
+
styles: styles?.elements?.map((element) => ({
|
|
173
|
+
computed: Object.fromEntries(Object.entries(element.computed ?? {}).filter(([property]) => DISCRETE_COMPUTED_PROPERTIES.has(property)).sort(([a], [b]) => a.localeCompare(b)).map(([property, value]) => [property, fingerprintComputed(property, value)])),
|
|
174
|
+
gridTrackCount: gridTrackCount(element.computed?.['grid-template-columns']),
|
|
175
|
+
})) ?? { missing: 'styles.json' },
|
|
176
|
+
text: (text?.elements ?? text?.nodes)?.map((element) => ({ lineCount: element.lines?.length, wrapOffsets: element.wrapOffsets })) ?? { missing: 'text.json' },
|
|
177
|
+
};
|
|
178
|
+
return crypto.createHash('sha256').update(JSON.stringify(substrate)).digest('hex');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface SweepDeclaration {
|
|
182
|
+
readonly property?: string;
|
|
183
|
+
readonly selector?: string | null;
|
|
184
|
+
readonly specificity?: string | null;
|
|
185
|
+
readonly authored?: { readonly file?: string; readonly line?: number; readonly column?: number };
|
|
186
|
+
readonly generated?: { readonly sourceURL?: string; readonly line?: number; readonly column?: number };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sourceFor(declaration: SweepDeclaration): SweepChange['provenance'] | undefined {
|
|
190
|
+
const authored = declaration.authored;
|
|
191
|
+
const generated = declaration.generated;
|
|
192
|
+
const source = authored?.file ? `${authored.file}${authored.line === undefined ? '' : `:${authored.line}${authored.column === undefined ? '' : `:${authored.column}`}`}` : generated?.sourceURL ? `${generated.sourceURL}${generated.line === undefined ? '' : `:${generated.line}${generated.column === undefined ? '' : `:${generated.column}`}`}` : undefined;
|
|
193
|
+
if (!declaration.selector && !declaration.specificity && !source) return undefined;
|
|
194
|
+
return { selector: declaration.selector ?? undefined, specificity: declaration.specificity ?? undefined, source };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Extracts changed computed values and the winning declaration for the new value, when captured. */
|
|
198
|
+
export function changesBetweenSnapshots(beforeDir: string, afterDir: string): SweepChange[] {
|
|
199
|
+
const before = readJson<StylesReport>(path.join(beforeDir, 'styles.json'));
|
|
200
|
+
const after = readJson<StylesReport>(path.join(afterDir, 'styles.json'));
|
|
201
|
+
if (!before?.elements || !after?.elements) return [];
|
|
202
|
+
const prior = new Map(before.elements.map((element) => [element.id ?? element.selector ?? '', element]));
|
|
203
|
+
const changes: SweepChange[] = [];
|
|
204
|
+
for (const next of after.elements) {
|
|
205
|
+
const key = next.id ?? next.selector ?? '';
|
|
206
|
+
const old = prior.get(key);
|
|
207
|
+
if (!old) continue;
|
|
208
|
+
const properties = new Set([...Object.keys(old.computed ?? {}), ...Object.keys(next.computed ?? {})]);
|
|
209
|
+
for (const property of properties) {
|
|
210
|
+
const beforeValue = old.computed?.[property] ?? null;
|
|
211
|
+
const afterValue = next.computed?.[property] ?? null;
|
|
212
|
+
if (beforeValue === afterValue) continue;
|
|
213
|
+
const declaration = next.winningDeclarations?.find((candidate) => candidate.property === property);
|
|
214
|
+
changes.push({ selector: next.selector ?? key, property, before: beforeValue, after: afterValue, ...(declaration ? { provenance: sourceFor(declaration) } : {}) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return changes;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Builds adjacent observed changes and matching-fingerprint spans; neither denotes unobserved values as stable. */
|
|
221
|
+
export function analyzeSweepSamples(axis: SweepAxis, _from: number | string, _to: number | string, samples: readonly SweepSample[]): Pick<SweepArtifact, 'transitions' | 'ranges'> {
|
|
222
|
+
const transitions: SweepTransition[] = [];
|
|
223
|
+
const ranges: SweepRange[] = [];
|
|
224
|
+
for (let i = 1; i < samples.length; i += 1) {
|
|
225
|
+
const previous = samples[i - 1];
|
|
226
|
+
const current = samples[i];
|
|
227
|
+
if (previous.fingerprint === current.fingerprint) {
|
|
228
|
+
ranges.push({ from: previous.value, to: current.value, snapId: previous.snapId, fingerprint: previous.fingerprint });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
transitions.push({ bracket: { from: previous.value, to: current.value }, before: previous.snapId, after: current.snapId, changes: changesBetweenSnapshots(previous.snapDir, current.snapDir) });
|
|
232
|
+
}
|
|
233
|
+
return { transitions, ranges };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function refineNumericSweep(samples: readonly SweepSample[], tolerance: number, capture: (value: number) => Promise<SweepSample>, sampleLimit = 96): Promise<{ samples: SweepSample[]; uncertainties: SweepUncertainty[] }> {
|
|
237
|
+
const byValue = new Map(samples.map((sample) => [Number(sample.value), sample]));
|
|
238
|
+
const uncertainties: SweepUncertainty[] = [];
|
|
239
|
+
const ordered = [...byValue.keys()].sort((a, b) => a - b);
|
|
240
|
+
const pending: Array<{ from: number; to: number }> = [];
|
|
241
|
+
for (let i = 1; i < ordered.length; i += 1) pending.push({ from: ordered[i - 1], to: ordered[i] });
|
|
242
|
+
// Breadth-first subdivision gives every coarse interval a sampled midpoint before spending the budget on finer detail in any one interval.
|
|
243
|
+
while (pending.length) {
|
|
244
|
+
const { from, to } = pending.shift()!;
|
|
245
|
+
if (to - from <= tolerance) {
|
|
246
|
+
uncertainties.push({ from, to, reason: 'resolution_limit' });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (byValue.size >= sampleLimit) {
|
|
250
|
+
uncertainties.push({ from, to, reason: 'sampling_limit' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const middle = tolerance >= 1 ? Math.floor((from + to) / 2) : Number(((from + to) / 2).toFixed(4));
|
|
254
|
+
if (middle <= from || middle >= to) {
|
|
255
|
+
uncertainties.push({ from, to, reason: 'resolution_limit' });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!byValue.has(middle)) byValue.set(middle, await capture(middle));
|
|
259
|
+
pending.push({ from, to: middle }, { from: middle, to });
|
|
260
|
+
}
|
|
261
|
+
return { samples: [...byValue.values()].sort((a, b) => Number(a.value) - Number(b.value)), uncertainties };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function isSweepAxis(value: string | undefined): value is SweepAxis {
|
|
265
|
+
return value !== undefined && (SWEEP_AXES as readonly string[]).includes(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function numericSweepValues(from: number, to: number, axis: Extract<SweepAxis, 'width' | 'dpr' | 'zoom'>): number[] {
|
|
269
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to) throw new Error(`--from must be smaller than --to for ${axis}`);
|
|
270
|
+
const step = axis === 'width' ? Math.max(1, Math.ceil((to - from) / 8)) : (to - from) / 8;
|
|
271
|
+
const values = [from];
|
|
272
|
+
for (let value = from + step; value < to; value += step) values.push(axis === 'width' ? Math.round(value) : Number(value.toFixed(4)));
|
|
273
|
+
values.push(to);
|
|
274
|
+
return [...new Set(values)].sort((a, b) => a - b);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type SweepMedia = 'screen' | 'print' | 'speech';
|
|
278
|
+
|
|
279
|
+
export interface SweepEnvironment {
|
|
280
|
+
readonly width: number;
|
|
281
|
+
readonly height: number;
|
|
282
|
+
readonly dpr: number;
|
|
283
|
+
readonly pageScale: number;
|
|
284
|
+
/** An effective media type observable through CSS media queries; omitted when the prior CDP type cannot be determined. */
|
|
285
|
+
readonly media?: SweepMedia;
|
|
286
|
+
readonly colorScheme: 'light' | 'dark';
|
|
287
|
+
readonly reducedMotion: 'reduce' | 'no-preference';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function readSweepEnvironment(client: CDPClient): Promise<SweepEnvironment> {
|
|
291
|
+
const response = await client.send('Runtime.evaluate', { expression: '({width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, pageScale: window.visualViewport?.scale || 1, media: matchMedia("print").matches ? "print" : matchMedia("speech").matches ? "speech" : matchMedia("screen").matches ? "screen" : null, colorScheme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference"})', returnByValue: true }) as { result?: { value?: Partial<SweepEnvironment> } };
|
|
292
|
+
const value = response.result?.value;
|
|
293
|
+
if (!value || !Number.isFinite(value.width) || !Number.isFinite(value.height) || !Number.isFinite(value.dpr) || !Number.isFinite(value.pageScale) || (value.media !== undefined && value.media !== 'screen' && value.media !== 'print' && value.media !== 'speech' && value.media !== null) || (value.colorScheme !== 'light' && value.colorScheme !== 'dark') || (value.reducedMotion !== 'reduce' && value.reducedMotion !== 'no-preference')) throw new Error('could not read the page emulation environment before sampling');
|
|
294
|
+
return { width: Math.round(value.width), height: Math.round(value.height), dpr: value.dpr, pageScale: value.pageScale, ...(value.media ? { media: value.media } : {}), colorScheme: value.colorScheme, reducedMotion: value.reducedMotion };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function mediaParams(baseline: SweepEnvironment): { media?: SweepMedia } {
|
|
298
|
+
return baseline.media === undefined ? {} : { media: baseline.media };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function applySweepEmulation(client: CDPClient, axis: SweepAxis, value: number | string, baseline: SweepEnvironment): Promise<void> {
|
|
302
|
+
if (axis === 'width') await client.send('Emulation.setDeviceMetricsOverride', { width: Math.round(Number(value)), height: baseline.height, deviceScaleFactor: baseline.dpr, mobile: false });
|
|
303
|
+
else if (axis === 'dpr') await client.send('Emulation.setDeviceMetricsOverride', { width: baseline.width, height: baseline.height, deviceScaleFactor: Number(value), mobile: false });
|
|
304
|
+
else if (axis === 'zoom') await client.send('Emulation.setPageScaleFactor', { pageScaleFactor: Number(value) });
|
|
305
|
+
else await client.send('Emulation.setEmulatedMedia', { ...mediaParams(baseline), features: [{ name: 'prefers-color-scheme', value: axis === 'color-scheme' ? String(value) : baseline.colorScheme }, { name: 'prefers-reduced-motion', value: axis === 'reduced-motion' ? String(value) : baseline.reducedMotion }] });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Restores the observed pre-sweep viewport, scale, and supported media settings; an unobservable media type is never replaced with an invented one. */
|
|
309
|
+
export async function restoreSweepEnvironment(client: CDPClient, baseline: SweepEnvironment): Promise<void> {
|
|
310
|
+
await client.send('Emulation.setDeviceMetricsOverride', { width: baseline.width, height: baseline.height, deviceScaleFactor: baseline.dpr, mobile: false });
|
|
311
|
+
await client.send('Emulation.setPageScaleFactor', { pageScaleFactor: baseline.pageScale });
|
|
312
|
+
await client.send('Emulation.setEmulatedMedia', { ...mediaParams(baseline), features: [{ name: 'prefers-color-scheme', value: baseline.colorScheme }, { name: 'prefers-reduced-motion', value: baseline.reducedMotion }] });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function readSweepSnapshot(snapId: string, snapDir: string, value: number | string, unstableRegions: SweepSample['unstableRegions'] = [], capturedSettled?: boolean): SweepSample {
|
|
316
|
+
const meta = readJson<SnapshotMeta>(path.join(snapDir, 'meta.json'));
|
|
317
|
+
return { value, snapId, snapDir, fingerprint: fingerprintSnapshotDir(snapDir), settled: capturedSettled ?? (meta?.settled === true), unstableRegions };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function writeSweepArtifact(dir: string, artifact: SweepArtifact): string {
|
|
321
|
+
const artifactPath = path.join(ensurePrivateDir(dir), 'sweep.json');
|
|
322
|
+
writeJsonPrivate(artifactPath, artifact);
|
|
323
|
+
return artifactPath;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function writeSweepRecoveryArtifact(dir: string, recovery: SweepRecovery): string {
|
|
327
|
+
const artifactPath = path.join(ensurePrivateDir(dir), 'sweep-recovery.json');
|
|
328
|
+
writeJsonPrivate(artifactPath, recovery);
|
|
329
|
+
return artifactPath;
|
|
330
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot substrate types — the contract every `measure snap` collector
|
|
3
|
+
* (U07–U12) and the orchestrator (`snapshot.ts`) share. This file is the
|
|
4
|
+
* spec: read it before writing or changing a collector.
|
|
5
|
+
*
|
|
6
|
+
* ## The U04 artifact contract collectors must honor
|
|
7
|
+
* - `geometry.json` is ALWAYS `{ elements: ElementRecord[], unstableRegions?:
|
|
8
|
+
* UnstableRegion[] }`. `unstableRegions` is present ONLY on a
|
|
9
|
+
* `--capture-unsettled` capture of an unsettled page — never on a
|
|
10
|
+
* settled capture, and never omitted-but-empty vs. absent inconsistently.
|
|
11
|
+
* See `src/output/artifact.ts`'s module doc for the reader side of this.
|
|
12
|
+
* - Every per-element record any collector writes (in `geometry.json` and
|
|
13
|
+
* elsewhere) should carry the identifying fields in {@link ElementRecord}
|
|
14
|
+
* — a stable `id`, and whichever of `selector`/`backendNodeId`/`axId`/
|
|
15
|
+
* `axName`/`text` it has facts for — so `src/output/selector.ts`'s
|
|
16
|
+
* selector-input resolution works uniformly across every collector file.
|
|
17
|
+
* - `meta.json` MUST ALWAYS exist for any snap dir this orchestrator
|
|
18
|
+
* touches, including an unsettled, evidence-only capture (no
|
|
19
|
+
* `--capture-unsettled`). `session stop` reads it to build the bundle
|
|
20
|
+
* manifest, and `src/output/artifact.ts` treats its presence as the
|
|
21
|
+
* marker that a ref actually names something.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CDPClient } from '../client.js';
|
|
25
|
+
import type { SnapMeta } from '../../session/artifacts.js';
|
|
26
|
+
import type { UnstableRegion } from '../../output/artifact.js';
|
|
27
|
+
import type { ElementRecord } from '../../output/selector.js';
|
|
28
|
+
|
|
29
|
+
export type { UnstableRegion, ElementRecord };
|
|
30
|
+
|
|
31
|
+
/** The already-connected CDP client + (optional) target identity a snapshot capture drives. */
|
|
32
|
+
export interface CaptureSnapshotTarget {
|
|
33
|
+
readonly client: CDPClient;
|
|
34
|
+
readonly tabId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Input to {@link captureSnapshotSubstrate} — mirrors `measure snap`'s CLI options 1:1. */
|
|
38
|
+
export interface CaptureSnapshotOptions {
|
|
39
|
+
readonly target: CaptureSnapshotTarget;
|
|
40
|
+
readonly url: string | null;
|
|
41
|
+
/** Absolute artifact directory for this snap, caller-resolved (session `measure/snaps/{id}` or a oneshot dir). */
|
|
42
|
+
readonly path: string;
|
|
43
|
+
/** Snapshot id; defaults to `basename(path)`. */
|
|
44
|
+
readonly snapId?: string;
|
|
45
|
+
/** `--settle-timeout <ms>`; default {@link DEFAULT_SETTLE_TIMEOUT_MS} (5000). */
|
|
46
|
+
readonly settleTimeout?: number;
|
|
47
|
+
/** `--freeze-animations`; default `false`. */
|
|
48
|
+
readonly freezeAnimations?: boolean;
|
|
49
|
+
/** `--capture-unsettled`; default `false`. */
|
|
50
|
+
readonly captureUnsettled?: boolean;
|
|
51
|
+
/** `--pixels`; default `false`. */
|
|
52
|
+
readonly pixels?: boolean;
|
|
53
|
+
/** `--state <state[:selector]>` (repeatable); default `[]`. */
|
|
54
|
+
readonly state?: readonly string[];
|
|
55
|
+
readonly viewport?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Poll interval for the settledness loop; default {@link DEFAULT_POLL_INTERVAL_MS}
|
|
58
|
+
* (100ms). Not part of the CLI surface — a test-only override so the
|
|
59
|
+
* settledness suite doesn't need a fake-clock injection seam in
|
|
60
|
+
* production code. Omitting it reproduces the documented default.
|
|
61
|
+
*/
|
|
62
|
+
readonly pollIntervalMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Quiet-window threshold for the settledness loop; default
|
|
65
|
+
* {@link DEFAULT_QUIET_THRESHOLD_MS} (300ms). Same test-only-override
|
|
66
|
+
* rationale as `pollIntervalMs`.
|
|
67
|
+
*/
|
|
68
|
+
readonly quietThresholdMs?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Collector set to run, ordered by {@link CollectorPhase}. Not part of
|
|
71
|
+
* the CLI surface — a test-only override so the phase-ordering suite can
|
|
72
|
+
* inject stub collectors that record their call order. Omitting it runs
|
|
73
|
+
* the real collector set `snapshot.ts` defines.
|
|
74
|
+
*/
|
|
75
|
+
readonly collectors?: readonly CollectorDescriptor[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `meta.json`'s on-disk shape — a {@link SnapMeta} (the U03 contract) plus snapshot-specific detail. */
|
|
79
|
+
export interface SnapshotMeta extends SnapMeta {
|
|
80
|
+
readonly settleMs: number;
|
|
81
|
+
readonly settleTimeoutMs: number;
|
|
82
|
+
readonly freezeAnimations: boolean;
|
|
83
|
+
readonly captureUnsettled: boolean;
|
|
84
|
+
readonly pixels: boolean;
|
|
85
|
+
readonly states: readonly string[];
|
|
86
|
+
readonly unstableRegionCount: number;
|
|
87
|
+
/** 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
|
+
readonly animationsRestored?: boolean;
|
|
89
|
+
/** Present (and always `false`) only when the browser-wide `Animation.setPlaybackRate({playbackRate:0})` freeze override itself threw during origin capture (I-6, #8) — distinct from `animationsRestored` above, which reflects only the RESTORE step. The per-animation `.pause()` calls captured in the same evaluate as the origin capture are still real for whatever existed at freeze time, but any animation the page creates AFTER that point was never covered by the override. Absent whenever the override genuinely succeeded, freeze wasn't requested, or the origin capture itself failed (that failure is already surfaced via a pessimistic `animationsRestored:false`) — never a fabricated affirmative standing in for "unknown". */
|
|
90
|
+
readonly freezeOverrideApplied?: false;
|
|
91
|
+
/** Present (and always `true`) only when at least one animation's own `.pause()` call inside {@link FREEZE_ANIMATIONS_SCRIPT} failed (#72) — that animation was left running through the frozen baseline capture even though `rateOverrideApplied`/`freezeOverrideApplied` can both be genuinely clean. Absent whenever every enumerated animation was confirmed paused, freeze wasn't requested, or the origin capture itself failed (already surfaced via a pessimistic `animationsRestored:false`) — never a fabricated `false` standing in for "unknown". Always emitted alongside `unfrozenCount` when `true`. */
|
|
92
|
+
readonly freezeIncomplete?: true;
|
|
93
|
+
/** Count of animations whose `.pause()` call failed (#72) — only present when `freezeIncomplete` is `true`. `0` there means the pause-failure tally itself could not be read back (the count is genuinely UNKNOWN, not confirmed zero); `freezeIncomplete` is the operative gate for that case, not this count. */
|
|
94
|
+
readonly unfrozenCount?: number;
|
|
95
|
+
/** 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
|
+
readonly domHtml?: { readonly available: boolean; readonly unavailableReason?: string };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Return value of {@link captureSnapshotSubstrate}. */
|
|
100
|
+
export interface CaptureSnapshotResult {
|
|
101
|
+
readonly id: string;
|
|
102
|
+
readonly dir: string;
|
|
103
|
+
readonly url: string | null;
|
|
104
|
+
readonly viewport: string | null;
|
|
105
|
+
readonly settled: boolean;
|
|
106
|
+
/** `settled || captureUnsettled` — whether the full collector substrate was written. */
|
|
107
|
+
readonly captured: boolean;
|
|
108
|
+
readonly settleMs: number;
|
|
109
|
+
readonly settleTimeoutMs: number;
|
|
110
|
+
readonly unstableRegions: readonly UnstableRegion[];
|
|
111
|
+
/** Filenames written under `dir`, relative to it. */
|
|
112
|
+
readonly artifacts: readonly string[];
|
|
113
|
+
readonly meta: SnapshotMeta;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The write surface a {@link SnapshotContext} hands to every collector.
|
|
118
|
+
* Both methods resolve `filename` against the snap `dir` and THROW if the
|
|
119
|
+
* resolved target would escape it (an absolute path, or a `path.relative`
|
|
120
|
+
* from `dir` that climbs out via `..`) — the U03 secure-fs helpers only
|
|
121
|
+
* enforce the global `CAPTURE_ROOT` boundary, so this containment check is
|
|
122
|
+
* what actually scopes a collector's writes to this snap's directory.
|
|
123
|
+
*/
|
|
124
|
+
export interface SnapshotWriter {
|
|
125
|
+
/** Writes `value` as pretty JSON to `filename` under the snap dir (via `writeJsonPrivate`) and records it as a written artifact. Throws if `filename` would escape the snap dir. */
|
|
126
|
+
json(filename: string, value: unknown): void;
|
|
127
|
+
/** Writes `data` as a binary file to `filename` under the snap dir (via `writeBinaryPrivate`) and records it as a written artifact. Throws if `filename` would escape the snap dir. */
|
|
128
|
+
binary(filename: string, data: Buffer): void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shared, read-only capture context every collector (U07–U12) receives.
|
|
133
|
+
* Collectors read from this and write only through `write` — never touch
|
|
134
|
+
* `fs` directly, never resolve their own path under `dir`.
|
|
135
|
+
*/
|
|
136
|
+
export interface SnapshotContext {
|
|
137
|
+
readonly client: CDPClient;
|
|
138
|
+
/** Absolute snap artifact directory. */
|
|
139
|
+
readonly dir: string;
|
|
140
|
+
readonly snapId: string;
|
|
141
|
+
readonly url: string | null;
|
|
142
|
+
readonly viewport: string | null;
|
|
143
|
+
/** Whether the page reached settledness (not merely whether a full substrate is being written — see `captureUnsettled`). */
|
|
144
|
+
readonly settled: boolean;
|
|
145
|
+
readonly freezeAnimations: boolean;
|
|
146
|
+
readonly captureUnsettled: boolean;
|
|
147
|
+
readonly pixels: boolean;
|
|
148
|
+
/** `--state` values requested for this capture. */
|
|
149
|
+
readonly state: readonly string[];
|
|
150
|
+
/** Non-empty ONLY when the page was unstable and a full substrate was still captured (`--capture-unsettled`). Collectors that key elements should stamp matching regions' ids onto affected records; `geometry.ts` spreads this directly into `geometry.json`'s `unstableRegions` field. */
|
|
151
|
+
readonly unstableRegions: readonly UnstableRegion[];
|
|
152
|
+
readonly write: SnapshotWriter;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A collector's whole contract: read from `ctx`, write via `ctx.write`, resolve. */
|
|
156
|
+
export type Collector = (ctx: SnapshotContext) => Promise<void>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which snapshot phase a collector belongs to — the ordering guarantee the
|
|
160
|
+
* orchestrator enforces:
|
|
161
|
+
* - `baseline`: pure/measuring collectors that must observe the page as it
|
|
162
|
+
* settled. They run in PARALLEL, and ALL finish before `screenshot.png`
|
|
163
|
+
* + `dom.html` are captured at the baseline boundary.
|
|
164
|
+
* - `mutating`: collectors that mutate DOM/focus/scroll/background
|
|
165
|
+
* (focus, scroll, states, pixels). They run AFTER the baseline artifacts
|
|
166
|
+
* are captured, and SERIALIZED (one at a time) so their mutations and
|
|
167
|
+
* restorations cannot contaminate the baseline artifacts or each other.
|
|
168
|
+
*/
|
|
169
|
+
export type CollectorPhase = 'baseline' | 'mutating';
|
|
170
|
+
|
|
171
|
+
/** A collector paired with the phase that decides when and how it runs. */
|
|
172
|
+
export interface CollectorDescriptor {
|
|
173
|
+
readonly name: string;
|
|
174
|
+
readonly phase: CollectorPhase;
|
|
175
|
+
readonly fn: Collector;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Churn/evidence artifact shapes — written only on the evidence branch
|
|
180
|
+
// (unsettled capture), by `settle.ts`/`snapshot.ts`.
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
/** One `churn.json` region — grouped mutation or animation evidence for one distinct source. */
|
|
184
|
+
export interface ChurnRegionRecord {
|
|
185
|
+
readonly id: string;
|
|
186
|
+
readonly selector?: string;
|
|
187
|
+
readonly reason?: string;
|
|
188
|
+
readonly mutationCount: number;
|
|
189
|
+
readonly firstMutationAtMs?: number;
|
|
190
|
+
readonly lastMutationAtMs?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `churn.json`'s on-disk shape — written only when a snap did NOT settle. */
|
|
194
|
+
export interface ChurnReport {
|
|
195
|
+
readonly settled: false;
|
|
196
|
+
readonly settleTimeoutMs: number;
|
|
197
|
+
readonly elapsedMs: number;
|
|
198
|
+
/** Count of mutations actually kept (bounded by the churn observer's 200-record cap) — `regions[].mutationCount` sums to this, not to the true observed total. */
|
|
199
|
+
readonly totalMutations: number;
|
|
200
|
+
readonly resizeCount: number;
|
|
201
|
+
/** Present (with the dropped count) only when the churn observer's 200-record cap was hit — the observer counted more raw mutations than it kept, so `totalMutations`/`regions` reflect the capped set, not every mutation the page produced (I-5). Absent when nothing was dropped. */
|
|
202
|
+
readonly mutationsTruncated?: number;
|
|
203
|
+
readonly regions: readonly ChurnRegionRecord[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One entry in `document.getAnimations()`'s inventory. */
|
|
207
|
+
export interface AnimationEvidenceRecord {
|
|
208
|
+
readonly selector: string | null;
|
|
209
|
+
readonly animationName: string | null;
|
|
210
|
+
readonly durationMs: number | null;
|
|
211
|
+
readonly iterationCount: number | 'infinite' | null;
|
|
212
|
+
readonly infinite: boolean;
|
|
213
|
+
readonly playState: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** `animation.json`'s on-disk shape when written as evidence (unsettled, not captured). */
|
|
217
|
+
export interface AnimationEvidence {
|
|
218
|
+
readonly animations: readonly AnimationEvidenceRecord[];
|
|
219
|
+
readonly infiniteCount: number;
|
|
220
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { CollectorCoverage, SNAPSHOT_FACET_NAMES, SnapshotFacetName } from '../../../contracts/snapshot.js';
|
|
5
|
+
import { SnapAcquisitionRequest } from './snap-selection.js';
|
|
6
|
+
export type CollectorPhase = 'baseline' | 'mutating';
|
|
7
|
+
export interface DeclaredArtifact { readonly key: string; readonly path: string; readonly media_type: string; readonly retained_arrays?: readonly { readonly key: string; readonly json_pointer: string; readonly retained_count: number }[]; }
|
|
8
|
+
export interface NamedCoverage { readonly name: string; readonly coverage: CollectorCoverage; }
|
|
9
|
+
export interface SnapshotCollectorContext { readonly request: SnapAcquisitionRequest; readonly root: string; write(artifact: DeclaredArtifact, bytes: Uint8Array): void; restore(name: string, restore: () => Promise<{ readonly restored: boolean; readonly reason?: string }>): void; }
|
|
10
|
+
export interface CollectorResult<N extends SnapshotFacetName> { readonly primary: NamedCoverage; readonly subpopulations: Record<string, CollectorCoverage>; readonly artifacts: readonly DeclaredArtifact[]; }
|
|
11
|
+
export interface RegisteredSnapshotCollector<N extends SnapshotFacetName = SnapshotFacetName> { readonly facet: N; readonly phase: CollectorPhase; readonly requested: (request: SnapAcquisitionRequest) => boolean; readonly collect: (ctx: SnapshotCollectorContext) => Promise<CollectorResult<N>>; }
|
|
12
|
+
export class SnapshotCollectorRegistry { constructor(readonly entries: readonly RegisteredSnapshotCollector[]) {} }
|
|
13
|
+
export function defineSnapshotCollectors(entries: readonly RegisteredSnapshotCollector[]): SnapshotCollectorRegistry { const seen = new Set(entries.map(entry => entry.facet)); if (entries.length !== SNAPSHOT_FACET_NAMES.length || seen.size !== entries.length || SNAPSHOT_FACET_NAMES.some(facet => !seen.has(facet)) || entries.some(entry => entry.phase !== 'baseline' && entry.phase !== 'mutating')) throw new Error('snapshot collector registration must contain each fixed facet exactly once'); return new SnapshotCollectorRegistry([...entries].sort((a, b) => SNAPSHOT_FACET_NAMES.indexOf(a.facet) - SNAPSHOT_FACET_NAMES.indexOf(b.facet))); }
|
|
14
|
+
const unavailable = (reason: string): CollectorCoverage => ({ retained_count: 0, availability: { state: 'unavailable', reason }, truncation: { state: 'unknown' }, source_total: { state: 'unavailable', reason } });
|
|
15
|
+
/** U7 replaces registrations, not orchestration. Placeholder collectors intentionally acquire no browser evidence. */
|
|
16
|
+
const placeholderSubpopulations = (facet: SnapshotFacetName): Record<string, CollectorCoverage> => facet === 'queries' ? { 'media-queries': unavailable('collector-unavailable'), 'container-queries': unavailable('collector-unavailable') } : facet === 'focus' ? { forward: unavailable('collector-unavailable'), reverse: unavailable('collector-unavailable') } : facet === 'scroll' ? { 'snap-descendants': unavailable('collector-unavailable'), 'sticky-fixed-descendants': unavailable('collector-unavailable'), 'sampled-visible-children': unavailable('collector-unavailable') } : {};
|
|
17
|
+
export const defaultSnapshotCollectors = defineSnapshotCollectors(SNAPSHOT_FACET_NAMES.map(facet => ({ facet, phase: 'baseline' as const, requested: request => facet !== 'pixels' || request.pixels ? facet !== 'states' || request.states.length > 0 : false, collect: async () => ({ primary: { name: facet, coverage: unavailable('collector-unavailable') }, subpopulations: placeholderSubpopulations(facet), artifacts: [] }) })));
|
|
18
|
+
export function createArtifactWriter(root: string, restorers: Array<{ name: string; restore: () => Promise<{ restored: boolean; reason?: string }> }>): Pick<SnapshotCollectorContext, 'write' | 'restore'> { const paths = new Set<string>(); return { write(artifact, bytes) { if (!artifact.path || path.isAbsolute(artifact.path) || artifact.path.includes('\\') || artifact.path.split('/').some(item => !item || item === '.' || item === '..') || paths.has(artifact.path)) throw new Error('invalid or duplicate collector artifact path'); paths.add(artifact.path); const output = path.join(root, artifact.path); fs.mkdirSync(path.dirname(output), { recursive: true, mode: 0o700 }); fs.writeFileSync(output, bytes); }, restore(name, restore) { restorers.push({ name, restore }); } }; }
|
|
19
|
+
export function sourceArtifactDeclaration(root: string, artifact: DeclaredArtifact) { const data = fs.readFileSync(path.join(root, artifact.path)); return { key: artifact.key, path: artifact.path, bytes: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex'), media_type: artifact.media_type, retained_arrays: artifact.retained_arrays ?? [] }; }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SnapshotFacet, SnapshotMetaV2, SnapshotTargetAttestation } from '../../../contracts/snapshot.js';
|
|
2
|
+
import { allocateSnapshotStaging, cleanupSnapshotStaging, finalizeSnapshotManifest, publishSnapshot, SnapshotIndexEntry } from '../../../output/artifact-lifecycle.js';
|
|
3
|
+
import { createArtifactWriter, defaultSnapshotCollectors, DeclaredArtifact, SnapshotCollectorRegistry, sourceArtifactDeclaration } from './collectors.js';
|
|
4
|
+
import { SnapAcquisitionRequest } from './snap-selection.js';
|
|
5
|
+
import { ResolvedTarget } from './target.js';
|
|
6
|
+
import { withTargetMutationLock } from './target-lock.js';
|
|
7
|
+
export interface PinnedSession { readonly sessionId: string; readonly source: 'active' | 'explicit'; readonly generation: number | null; readonly target: ResolvedTarget; readonly directory: string; }
|
|
8
|
+
export interface SessionSnapshotAuthority { withPinnedOpenSession<T>(selection: Extract<SnapAcquisitionRequest['selection'], { kind: 'named-session' | 'active-session' }>, fn: (session: PinnedSession) => Promise<T>): Promise<T>; }
|
|
9
|
+
export interface SnapshotOrchestratorDeps { readonly artifactRoot?: string; readonly collectors?: SnapshotCollectorRegistry; readonly resolveTarget: (request: SnapAcquisitionRequest) => Promise<{ readonly target: ResolvedTarget; readonly attestation: SnapshotTargetAttestation; readonly association: 'one-shot' | { readonly sessionId: string }; readonly close?: () => Promise<boolean> }>; readonly sessions?: SessionSnapshotAuthority; readonly coordinateAuthority?: Record<string, unknown>; readonly contentInputs?: Record<string, unknown>; }
|
|
10
|
+
export interface SnapshotAcquisitionResult { readonly snapshot: SnapshotIndexEntry; readonly attestation: SnapshotTargetAttestation; }
|
|
11
|
+
const sameTarget = (a: ResolvedTarget, b: ResolvedTarget): boolean => a.identity.host === b.identity.host && a.identity.port === b.identity.port && a.identity.fullTargetId === b.identity.fullTargetId;
|
|
12
|
+
const unavailable = { retained_count: 0, availability: { state: 'unavailable' as const, reason: 'collector-unavailable' }, truncation: { state: 'unknown' as const }, source_total: { state: 'unavailable' as const, reason: 'collector-unavailable' } };
|
|
13
|
+
const facet = (requested: boolean, primary: { name: string; coverage: any } | undefined, subpopulations: Record<string, any> | undefined): SnapshotFacet => { const coverage = primary?.coverage ?? unavailable; const blocked = coverage.availability !== 'available'; return { status: !requested ? 'not-requested' : blocked ? 'unavailable' : 'available', primary_population: primary ?? { name: 'unavailable', coverage }, subpopulations: subpopulations ?? {}, unavailable_reason: !requested ? null : blocked ? coverage.availability.reason : null }; };
|
|
14
|
+
/** Private/unreachable acquisition seam. Resolution, pinning, locking, collection, restoration, close, and publication are one lifecycle. */
|
|
15
|
+
export async function acquireSnapshotV2(request: SnapAcquisitionRequest, deps: SnapshotOrchestratorDeps): Promise<SnapshotAcquisitionResult> {
|
|
16
|
+
const run = async (pinned?: PinnedSession): Promise<SnapshotAcquisitionResult> => {
|
|
17
|
+
const resolved = await deps.resolveTarget(request);
|
|
18
|
+
if (pinned && !sameTarget(pinned.target, resolved.target)) throw new Error('session target changed while pinned');
|
|
19
|
+
const target = pinned?.target ?? resolved.target;
|
|
20
|
+
return withTargetMutationLock(target.identity, async () => {
|
|
21
|
+
let staging: ReturnType<typeof allocateSnapshotStaging> | undefined; let published = false; let closeAttempted = false; let primary: unknown; let restoration: unknown; let close: unknown;
|
|
22
|
+
try {
|
|
23
|
+
staging = allocateSnapshotStaging(deps.artifactRoot);
|
|
24
|
+
const restorers: Array<{ name: string; restore: () => Promise<{ restored: boolean; reason?: string }> }> = []; const writer = createArtifactWriter(staging.directory, restorers); const results = new Map<string, { primary: any; subpopulations: Record<string, any>; artifacts: readonly DeclaredArtifact[] }>(); const registry = deps.collectors ?? defaultSnapshotCollectors;
|
|
25
|
+
const baseline = registry.entries.filter(entry => entry.phase === 'baseline' && entry.requested(request));
|
|
26
|
+
const settled = await Promise.allSettled(baseline.map(entry => entry.collect({ request, root: staging!.directory, ...writer }).then(result => results.set(entry.facet, result))));
|
|
27
|
+
primary = settled.find(item => item.status === 'rejected' && item.reason)?.reason;
|
|
28
|
+
if (!primary) for (const entry of registry.entries.filter(entry => entry.phase === 'mutating' && entry.requested(request))) { try { results.set(entry.facet, await entry.collect({ request, root: staging.directory, ...writer })); } catch (error) { primary = error; break; } }
|
|
29
|
+
for (const restorer of [...restorers].reverse()) try { const result = await restorer.restore(); if (!result.restored) restoration ??= new Error(result.reason ?? `restoration ${restorer.name} unconfirmed`); } catch (error) { restoration ??= error; }
|
|
30
|
+
if (resolved.close) try { closeAttempted = true; if (!(await resolved.close())) close = new Error('temporary_target_cleanup_unconfirmed'); } catch (error) { close = error; }
|
|
31
|
+
if (close || restoration || primary) throw close ?? restoration ?? primary;
|
|
32
|
+
const facets = {} as SnapshotMetaV2['facets']; const artifacts: DeclaredArtifact[] = [];
|
|
33
|
+
for (const entry of registry.entries) { const result = results.get(entry.facet); (facets as any)[entry.facet] = facet(entry.requested(request), result?.primary, result?.subpopulations); if (result) artifacts.push(...result.artifacts); }
|
|
34
|
+
const meta: Omit<SnapshotMetaV2, 'snapshotId'> = { schemaVersion: 2, request: { selection: request.selection }, settled: true, timing: {}, coordinateAuthority: deps.coordinateAuthority ?? {}, contentInputs: deps.contentInputs ?? {}, target: resolved.attestation, facets, source_artifact_manifest: { schemaVersion: 2, artifacts: artifacts.map(artifact => sourceArtifactDeclaration(staging!.directory, artifact)).sort((a, b) => a.path.localeCompare(b.path)) }, sourcePixelCoverage: {} };
|
|
35
|
+
finalizeSnapshotManifest(staging, meta); const snapshot = publishSnapshot(staging, resolved.association, deps.artifactRoot); published = true; return { snapshot, attestation: resolved.attestation };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
primary ??= error;
|
|
38
|
+
if (resolved.close && !closeAttempted) try { closeAttempted = true; if (!(await resolved.close())) close = new Error('temporary_target_cleanup_unconfirmed'); } catch (closeError) { close = closeError; }
|
|
39
|
+
if (staging && !published) cleanupSnapshotStaging(staging, deps.artifactRoot);
|
|
40
|
+
throw close ?? restoration ?? primary;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
if (request.selection.kind === 'named-session' || request.selection.kind === 'active-session') { if (!deps.sessions) throw new Error('session snapshot authority is required'); return deps.sessions.withPinnedOpenSession(request.selection, run); }
|
|
45
|
+
return run();
|
|
46
|
+
}
|