@crouton-kit/capture 1.4.2 → 1.4.10
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/README.md +178 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LayerRecord, LayersReport } from './collectors/layers.js';
|
|
2
|
+
import { selectRecords } from '../../output/selection.js';
|
|
2
3
|
import type { SnapMeta } from '../../session/artifacts.js';
|
|
3
4
|
import {
|
|
4
5
|
annotateUnstableFacts,
|
|
@@ -27,6 +28,22 @@ interface LayerFact {
|
|
|
27
28
|
readonly rect: Rect;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const INLINE_IDENTIFIER_LIMIT = 50;
|
|
32
|
+
|
|
33
|
+
function boundedIdentifiers(ids: readonly number[], total: number): { listed: readonly number[]; omitted: number } {
|
|
34
|
+
const listed = ids.slice(0, INLINE_IDENTIFIER_LIMIT);
|
|
35
|
+
return { listed, omitted: Math.max(0, total - listed.length) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function layerJson(layer: LayerRecord): Record<string, unknown> {
|
|
39
|
+
const members = boundedIdentifiers(layer.memberBackendNodeIds, layer.memberCount);
|
|
40
|
+
return {
|
|
41
|
+
...layer,
|
|
42
|
+
memberBackendNodeIds: members.listed,
|
|
43
|
+
memberBackendNodeIdsOmitted: members.omitted,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
function sourceFor(layer: LayerRecord): string | undefined {
|
|
31
48
|
const provenance = layer.styleProvenance;
|
|
32
49
|
if (!provenance) return undefined;
|
|
@@ -75,8 +92,12 @@ function formatLayer(layer: LayerRecord, membershipAvailable: boolean, caveats:
|
|
|
75
92
|
else if (layer.compositingReasons.length) details.push(fact`Compositing reasons: ${layer.compositingReasons.join(', ')}`);
|
|
76
93
|
else details.push(text`Compositing reasons: none reported.`);
|
|
77
94
|
if (!membershipAvailable) details.push(text`Node membership was unavailable for this layer.`);
|
|
78
|
-
else
|
|
79
|
-
|
|
95
|
+
else {
|
|
96
|
+
const members = boundedIdentifiers(layer.memberBackendNodeIds, layer.memberCount);
|
|
97
|
+
details.push(members.omitted
|
|
98
|
+
? line(fact`Node membership: ${layer.memberCount} painted node(s); first ${members.listed.length} backend id(s): `, data(members.listed.join(', ') || '(none)', 1000), fact`; ${members.omitted} id(s) omitted.`)
|
|
99
|
+
: line(fact`Node membership: ${layer.memberCount} painted node(s); backend id(s): `, data(members.listed.join(', ') || '(none)', 1000), text`.`));
|
|
100
|
+
}
|
|
80
101
|
if (layer.styleProvenance) {
|
|
81
102
|
const provenance = layer.styleProvenance;
|
|
82
103
|
details.push(line(
|
|
@@ -104,14 +125,16 @@ function formatLayer(layer: LayerRecord, membershipAvailable: boolean, caveats:
|
|
|
104
125
|
|
|
105
126
|
function paintOrderLine(report: LayersReport, caveats: readonly UnstableCaveat[]): FactLine {
|
|
106
127
|
if (!report.paintOrder.available) return fact`DOMSnapshot paint order unavailable${report.paintOrder.reason ? `: ${report.paintOrder.reason}` : ''}.`;
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const base =
|
|
128
|
+
const total = report.paintOrder.backendNodeIds.length + report.paintOrder.truncated;
|
|
129
|
+
const ids = boundedIdentifiers(report.paintOrder.backendNodeIds, total);
|
|
130
|
+
const base = ids.omitted
|
|
131
|
+
? line(fact`DOMSnapshot paint order (back-to-front): ${total} painted node(s); first ${ids.listed.length} backend id(s): `, data(ids.listed.join(', ') || '(none)', 1000), fact`; ${ids.omitted} id(s) omitted.`)
|
|
132
|
+
: line(fact`DOMSnapshot paint order (back-to-front): ${total} painted node(s); backend id(s): `, data(ids.listed.join(', ') || '(none)', 1000), text`.`);
|
|
110
133
|
const caveat = caveatLine(caveats);
|
|
111
134
|
return caveat ? lineList([base, caveat]) : base;
|
|
112
135
|
}
|
|
113
136
|
|
|
114
|
-
export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
|
|
137
|
+
export function buildMeasureMapLayersResult(ref: SnapRef, limit?: number): RenderableResult {
|
|
115
138
|
const report = readLayers<LayersReport>(ref);
|
|
116
139
|
const meta = readMeta<SnapMeta>(ref);
|
|
117
140
|
const regions = unstableRegionsFor(ref);
|
|
@@ -138,9 +161,15 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
|
|
|
138
161
|
const layerTreeStatus = report.layerTree.available ? 'available' : 'unavailable';
|
|
139
162
|
const paintOrderStatus = report.paintOrder.available ? 'available' : 'unavailable';
|
|
140
163
|
const membershipStatus = report.membership.available ? 'available' : 'unavailable';
|
|
164
|
+
const paintedNodeCount = report.paintOrder.backendNodeIds.length + report.paintOrder.truncated;
|
|
165
|
+
const paintOrderJson = report.paintOrder.available
|
|
166
|
+
? { ...report.paintOrder, paintedNodeCount, ...(() => { const ids = boundedIdentifiers(report.paintOrder.backendNodeIds, paintedNodeCount); return { backendNodeIds: ids.listed, backendNodeIdsOmitted: ids.omitted }; })() }
|
|
167
|
+
: report.paintOrder;
|
|
141
168
|
const availability = report.layerTree.available
|
|
142
169
|
? fact`LayerTree facts available for ${report.layers.length} layer(s). DOMSnapshot paint order is ${paintOrderStatus}.`
|
|
143
170
|
: fact`LayerTree facts unavailable${report.layerTree.reason ? `: ${report.layerTree.reason}` : ''}. DOMSnapshot paint order is ${paintOrderStatus}.`;
|
|
171
|
+
const displayed = selectRecords(annotated, { limit }, 25);
|
|
172
|
+
const layerPaintOrder = selectRecords(report.layerPaintOrder, { limit }, 25);
|
|
144
173
|
const sections: FactLine[] = [availability, paintOrderLine(report, paintOrderCaveats)];
|
|
145
174
|
if (report.layersTruncated) sections.push(fact`${report.layersTruncated} compositor layer(s) were not listed because the snapshot collector capped the layer inventory.`);
|
|
146
175
|
if (report.membership.available) {
|
|
@@ -151,7 +180,8 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
|
|
|
151
180
|
if (!report.styleSheetHeaders.available) {
|
|
152
181
|
sections.push(fact`Stylesheet-header provenance availability: unavailable${report.styleSheetHeaders.reason ? `: ${report.styleSheetHeaders.reason}` : ''}.`);
|
|
153
182
|
}
|
|
154
|
-
sections.push(
|
|
183
|
+
if (annotated.length > displayed.length) sections.push(fact`Layer records: listing capped at ${displayed.length} of ${annotated.length} layers (--limit).`);
|
|
184
|
+
sections.push(...displayed.map(({ layerFact, caveats }) => formatLayer(layerFact.layer, report.membership.available, caveats)));
|
|
155
185
|
|
|
156
186
|
return {
|
|
157
187
|
tag: 'layer-map',
|
|
@@ -165,13 +195,27 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
|
|
|
165
195
|
},
|
|
166
196
|
attrs: {
|
|
167
197
|
layers: report.layers.length,
|
|
198
|
+
displayed: displayed.length,
|
|
168
199
|
'layer-tree': layerTreeStatus,
|
|
169
200
|
'paint-order': paintOrderStatus,
|
|
170
201
|
membership: membershipStatus,
|
|
202
|
+
'painted-nodes': report.paintOrder.available ? paintedNodeCount : undefined,
|
|
171
203
|
settled: meta.settled,
|
|
172
204
|
},
|
|
173
|
-
summary:
|
|
205
|
+
summary: report.paintOrder.available
|
|
206
|
+
? fact`Paint/compositor layer facts and ${paintedNodeCount} DOMSnapshot painted node(s) are reported from layers.json; identifier lists are limited to ${INLINE_IDENTIFIER_LIMIT} per corpus with omitted counts.`
|
|
207
|
+
: text`Paint/compositor layer facts, DOMSnapshot paint order, node membership, and available layer-style provenance are reported from layers.json.`,
|
|
174
208
|
sections,
|
|
209
|
+
jsonSections: [{
|
|
210
|
+
layerTree: report.layerTree,
|
|
211
|
+
layers: displayed.map(({ layerFact }) => layerJson(layerFact.layer)),
|
|
212
|
+
layersTruncated: report.layersTruncated,
|
|
213
|
+
paintOrder: paintOrderJson,
|
|
214
|
+
layerPaintOrder,
|
|
215
|
+
layerPaintOrderOmitted: report.layerPaintOrder.length - layerPaintOrder.length,
|
|
216
|
+
membership: report.membership,
|
|
217
|
+
styleSheetHeaders: report.styleSheetHeaders,
|
|
218
|
+
}],
|
|
175
219
|
followUp: fact`Inspect an element's cascade and stacking facts with \`capture measure explain ${ref.id} --selector <selector>\`.`,
|
|
176
220
|
};
|
|
177
221
|
}
|
|
@@ -182,6 +182,8 @@ function makeCollectorWriter(writer: RollbackWriter): { writer: SnapshotWriter;
|
|
|
182
182
|
|
|
183
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
184
|
export const SNAPSHOT_REQUEST_TIMEOUT_MS = 5_000;
|
|
185
|
+
/** Scroll-topology sampling can trigger a page's own scroll handler before its next CDP read. Keep that one collector bounded, but long enough to observe the resulting renderer work. */
|
|
186
|
+
export const SCROLL_COLLECTOR_REQUEST_TIMEOUT_MS = 30_000;
|
|
185
187
|
|
|
186
188
|
/** A snapshot operation timed out after writing zero or more partial artifacts. */
|
|
187
189
|
export class SnapshotCaptureTimeout extends Error {
|
|
@@ -199,10 +201,10 @@ export class SnapshotCaptureTimeout extends Error {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
/** Bounds one snapshot CDP request and labels the phase that owns it. */
|
|
202
|
-
function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void): CDPClient {
|
|
204
|
+
function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void, requestTimeoutCapMs = SNAPSHOT_REQUEST_TIMEOUT_MS): CDPClient {
|
|
203
205
|
const bounded = Object.create(client) as CDPClient;
|
|
204
206
|
bounded.send = (method: string, params: Record<string, unknown> = {}, timeout = 60_000, sessionId?: string): Promise<unknown> => {
|
|
205
|
-
const timeoutMs = Math.min(timeout,
|
|
207
|
+
const timeoutMs = Math.min(timeout, requestTimeoutCapMs);
|
|
206
208
|
let timer: NodeJS.Timeout | undefined;
|
|
207
209
|
let timedOut = false;
|
|
208
210
|
const markTimedOut = (error: SnapshotCaptureTimeout): void => {
|
|
@@ -403,7 +405,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
403
405
|
client: boundedClient(client, `collector:${collector.name}`, dir, (error) => {
|
|
404
406
|
requestTimeouts += 1;
|
|
405
407
|
timeoutError ??= error;
|
|
406
|
-
}),
|
|
408
|
+
}, collector.name === 'scroll' ? SCROLL_COLLECTOR_REQUEST_TIMEOUT_MS : SNAPSHOT_REQUEST_TIMEOUT_MS),
|
|
407
409
|
write: collectorWriter.writer,
|
|
408
410
|
});
|
|
409
411
|
// A styles timeout may be caught per-record by the provenance collector,
|
package/src/cdp/measure/sweep.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface SweepTransition {
|
|
|
23
23
|
readonly before: string;
|
|
24
24
|
readonly after: string;
|
|
25
25
|
readonly changes: readonly SweepChange[];
|
|
26
|
+
/** The observed endpoint difference could not be narrowed before the sample budget ran out. */
|
|
27
|
+
readonly uncertainty?: 'sampling_limit';
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface SweepChange {
|
|
@@ -42,6 +44,7 @@ export interface SweepRange {
|
|
|
42
44
|
readonly from: number | string;
|
|
43
45
|
readonly to: number | string;
|
|
44
46
|
readonly snapId: string;
|
|
47
|
+
readonly snapDir: string;
|
|
45
48
|
readonly fingerprint: string;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -91,6 +94,7 @@ export interface SweepRecovery {
|
|
|
91
94
|
interface StylesElement {
|
|
92
95
|
readonly id?: string;
|
|
93
96
|
readonly selector?: string;
|
|
97
|
+
readonly backendNodeId?: number | null;
|
|
94
98
|
readonly computed?: Record<string, string | null>;
|
|
95
99
|
readonly winningDeclarations?: readonly {
|
|
96
100
|
readonly property?: string;
|
|
@@ -194,15 +198,26 @@ function sourceFor(declaration: SweepDeclaration): SweepChange['provenance'] | u
|
|
|
194
198
|
return { selector: declaration.selector ?? undefined, specificity: declaration.specificity ?? undefined, source };
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
/** A cross-snapshot element key must survive an inserted/removed sibling; collector-local s-<index> ids do not. */
|
|
202
|
+
function snapshotElementKey(element: StylesElement): string | null {
|
|
203
|
+
if (typeof element.backendNodeId === 'number') return `backend:${element.backendNodeId}`;
|
|
204
|
+
if (element.selector) return `selector:${element.selector}`;
|
|
205
|
+
return element.id && !/^s-\d+$/.test(element.id) ? `id:${element.id}` : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
197
208
|
/** Extracts changed computed values and the winning declaration for the new value, when captured. */
|
|
198
209
|
export function changesBetweenSnapshots(beforeDir: string, afterDir: string): SweepChange[] {
|
|
199
210
|
const before = readJson<StylesReport>(path.join(beforeDir, 'styles.json'));
|
|
200
211
|
const after = readJson<StylesReport>(path.join(afterDir, 'styles.json'));
|
|
201
212
|
if (!before?.elements || !after?.elements) return [];
|
|
202
|
-
const prior = new Map(before.elements.
|
|
213
|
+
const prior = new Map(before.elements.flatMap((element) => {
|
|
214
|
+
const key = snapshotElementKey(element);
|
|
215
|
+
return key === null ? [] : [[key, element] as const];
|
|
216
|
+
}));
|
|
203
217
|
const changes: SweepChange[] = [];
|
|
204
218
|
for (const next of after.elements) {
|
|
205
|
-
const key = next
|
|
219
|
+
const key = snapshotElementKey(next);
|
|
220
|
+
if (key === null) continue;
|
|
206
221
|
const old = prior.get(key);
|
|
207
222
|
if (!old) continue;
|
|
208
223
|
const properties = new Set([...Object.keys(old.computed ?? {}), ...Object.keys(next.computed ?? {})]);
|
|
@@ -218,17 +233,19 @@ export function changesBetweenSnapshots(beforeDir: string, afterDir: string): Sw
|
|
|
218
233
|
}
|
|
219
234
|
|
|
220
235
|
/** 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'> {
|
|
236
|
+
export function analyzeSweepSamples(axis: SweepAxis, _from: number | string, _to: number | string, samples: readonly SweepSample[], uncertainties: readonly SweepUncertainty[] = []): Pick<SweepArtifact, 'transitions' | 'ranges'> {
|
|
222
237
|
const transitions: SweepTransition[] = [];
|
|
223
238
|
const ranges: SweepRange[] = [];
|
|
224
239
|
for (let i = 1; i < samples.length; i += 1) {
|
|
225
240
|
const previous = samples[i - 1];
|
|
226
241
|
const current = samples[i];
|
|
227
242
|
if (previous.fingerprint === current.fingerprint) {
|
|
228
|
-
ranges.push({ from: previous.value, to: current.value, snapId: previous.snapId, fingerprint: previous.fingerprint });
|
|
243
|
+
ranges.push({ from: previous.value, to: current.value, snapId: previous.snapId, snapDir: previous.snapDir, fingerprint: previous.fingerprint });
|
|
229
244
|
continue;
|
|
230
245
|
}
|
|
231
|
-
|
|
246
|
+
const samplingLimited = typeof previous.value === 'number' && typeof current.value === 'number'
|
|
247
|
+
&& uncertainties.some((interval) => interval.reason === 'sampling_limit' && interval.from === previous.value && interval.to === current.value);
|
|
248
|
+
transitions.push({ bracket: { from: previous.value, to: current.value }, before: previous.snapId, after: current.snapId, changes: changesBetweenSnapshots(previous.snapDir, current.snapDir), ...(samplingLimited ? { uncertainty: 'sampling_limit' as const } : {}) });
|
|
232
249
|
}
|
|
233
250
|
return { transitions, ranges };
|
|
234
251
|
}
|
|
@@ -256,7 +273,13 @@ export async function refineNumericSweep(samples: readonly SweepSample[], tolera
|
|
|
256
273
|
continue;
|
|
257
274
|
}
|
|
258
275
|
if (!byValue.has(middle)) byValue.set(middle, await capture(middle));
|
|
259
|
-
|
|
276
|
+
const midpoint = byValue.get(middle)!;
|
|
277
|
+
const left = byValue.get(from)!;
|
|
278
|
+
const right = byValue.get(to)!;
|
|
279
|
+
// One midpoint observes an otherwise-unsampled interval; only a changed
|
|
280
|
+
// fingerprint can justify spending more samples narrowing that half.
|
|
281
|
+
if (left.fingerprint !== midpoint.fingerprint) pending.push({ from, to: middle });
|
|
282
|
+
if (midpoint.fingerprint !== right.fingerprint) pending.push({ from: middle, to });
|
|
260
283
|
}
|
|
261
284
|
return { samples: [...byValue.values()].sort((a, b) => Number(a.value) - Number(b.value)), uncertainties };
|
|
262
285
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
|
|
5
|
+
import { getActiveSession } from '../../session-context.js';
|
|
6
|
+
import { createOneshotSession } from '../../session/commands.js';
|
|
7
|
+
import { writeBinaryPrivate } from '../../session/artifacts.js';
|
|
8
|
+
import { artifactPath, readGeometry, readMeta, readQueries, readStyles, readText, type SnapRef } from '../../output/artifact.js';
|
|
9
|
+
import { explainSnapshot, type ExplainAmbiguousSelector, type ExplainMissingSelector } from './explain.js';
|
|
10
|
+
import { measureElementContrast } from './check.js';
|
|
11
|
+
|
|
12
|
+
interface TextRecord {
|
|
13
|
+
readonly id?: string;
|
|
14
|
+
readonly geometryId?: string;
|
|
15
|
+
readonly backendNodeId?: number | null;
|
|
16
|
+
readonly selector?: string;
|
|
17
|
+
readonly text?: string;
|
|
18
|
+
readonly textLength?: number;
|
|
19
|
+
readonly lineCount?: number;
|
|
20
|
+
readonly truncated?: boolean;
|
|
21
|
+
readonly truncationStyle?: string;
|
|
22
|
+
readonly scrollWidth?: number;
|
|
23
|
+
readonly clientWidth?: number;
|
|
24
|
+
readonly writingMode?: string;
|
|
25
|
+
readonly direction?: string;
|
|
26
|
+
readonly fallbackUsed?: boolean;
|
|
27
|
+
readonly font?: { readonly family?: string; readonly size?: string; readonly weight?: string; readonly lineHeight?: string };
|
|
28
|
+
readonly platformFontsAvailable?: boolean;
|
|
29
|
+
readonly platformFonts?: readonly { readonly familyName?: string }[];
|
|
30
|
+
readonly lines?: readonly { readonly index?: number; readonly rect?: { readonly x?: number; readonly y?: number; readonly width?: number; readonly height?: number; readonly w?: number; readonly h?: number }; readonly baseline?: number; readonly baselineApproximate?: boolean; readonly wrapAfterChar?: number; readonly wrapAfterCharUnavailable?: boolean }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StyleRecord {
|
|
34
|
+
readonly id?: string;
|
|
35
|
+
readonly geometryId?: string;
|
|
36
|
+
readonly backendNodeId?: number | null;
|
|
37
|
+
readonly selector?: string;
|
|
38
|
+
readonly provenanceUnavailable?: boolean;
|
|
39
|
+
readonly winningDeclarations?: readonly { readonly property?: string; readonly value?: string | null; readonly selector?: string | null; readonly specificity?: string | null; readonly important?: boolean; readonly authored?: { readonly file?: string; readonly line?: number; readonly column?: number }; readonly generated?: { readonly sourceURL?: string; readonly line?: number; readonly column?: number }; readonly sourceStyleSheetUrl?: string; readonly winnerApproximate?: boolean; readonly winnerApproximateReason?: string }[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface GeometryRecord {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly selector?: string;
|
|
45
|
+
readonly backendNodeId?: number | null;
|
|
46
|
+
readonly domPath?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface TextArtifact {
|
|
50
|
+
readonly elements?: TextRecord[];
|
|
51
|
+
readonly available?: boolean;
|
|
52
|
+
readonly unavailableReason?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface QueriesArtifact {
|
|
56
|
+
readonly available?: boolean;
|
|
57
|
+
readonly unavailableReason?: string;
|
|
58
|
+
readonly environment?: { readonly width?: number; readonly height?: number };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TextStyleProvenance {
|
|
62
|
+
readonly element: string;
|
|
63
|
+
readonly property: string;
|
|
64
|
+
readonly value: string;
|
|
65
|
+
readonly selector: string;
|
|
66
|
+
readonly specificity: string;
|
|
67
|
+
readonly source: string;
|
|
68
|
+
readonly uncertainty?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TextStyleProvenanceReport {
|
|
72
|
+
readonly styles: readonly TextStyleProvenance[];
|
|
73
|
+
readonly unavailableFor: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TextCrop {
|
|
77
|
+
readonly path: string;
|
|
78
|
+
readonly requested: { readonly x: number; readonly y: number; readonly w: number; readonly h: number };
|
|
79
|
+
readonly delivered: { readonly x: number; readonly y: number; readonly w: number; readonly h: number };
|
|
80
|
+
readonly scale: { readonly x: number; readonly y: number };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TextMeasurement {
|
|
84
|
+
readonly kind: 'measurement';
|
|
85
|
+
readonly ref: SnapRef;
|
|
86
|
+
readonly selector: string;
|
|
87
|
+
readonly matchCount: number;
|
|
88
|
+
readonly element: { readonly id: string; readonly selector?: string; readonly backendNodeId?: number | null; readonly rect?: { readonly x: number; readonly y: number; readonly width?: number; readonly height?: number } | null };
|
|
89
|
+
readonly text?: TextRecord;
|
|
90
|
+
readonly textAvailable: boolean;
|
|
91
|
+
readonly textUnavailableReason?: string;
|
|
92
|
+
readonly contrast: ReturnType<typeof measureElementContrast>;
|
|
93
|
+
readonly styles: readonly TextStyleProvenance[];
|
|
94
|
+
readonly styleProvenanceUnavailableFor: readonly string[];
|
|
95
|
+
readonly meta: { readonly settled?: boolean; readonly settleMs?: number; readonly viewport?: string | null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type TextSnapshotResult = TextMeasurement | ExplainMissingSelector | ExplainAmbiguousSelector;
|
|
99
|
+
|
|
100
|
+
function sameTarget(record: { backendNodeId?: number | null; id?: string; geometryId?: string }, element: { backendNodeId?: number | null; id: string }): boolean {
|
|
101
|
+
return (typeof element.backendNodeId === 'number' && record.backendNodeId === element.backendNodeId) || record.id === element.id || record.geometryId === element.id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sourceOf(declaration: NonNullable<StyleRecord['winningDeclarations']>[number]): string {
|
|
105
|
+
if (declaration.authored?.file) return `${declaration.authored.file}:${declaration.authored.line ?? 0}:${declaration.authored.column ?? 0} (authored)`;
|
|
106
|
+
if (declaration.generated?.sourceURL) return `${declaration.generated.sourceURL}:${declaration.generated.line ?? 0}:${declaration.generated.column ?? 0} (generated)`;
|
|
107
|
+
if (declaration.sourceStyleSheetUrl) return `${declaration.sourceStyleSheetUrl} (generated source)`;
|
|
108
|
+
return 'selector-only provenance';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parentPath(domPath: string | undefined): string | undefined {
|
|
112
|
+
if (domPath === undefined) return undefined;
|
|
113
|
+
const index = domPath.lastIndexOf('/');
|
|
114
|
+
return index < 0 ? '' : domPath.slice(0, index);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function elementLabel(element: { id: string; selector?: string }): string {
|
|
118
|
+
return element.selector ?? element.id;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function styleProvenance(ref: SnapRef, element: GeometryRecord, contrast: ReturnType<typeof measureElementContrast>): TextStyleProvenanceReport {
|
|
122
|
+
const records = readStyles<{ elements?: StyleRecord[] }>(ref).elements ?? [];
|
|
123
|
+
const geometry = readGeometry<{ elements?: GeometryRecord[] }>(ref).elements ?? [];
|
|
124
|
+
const byPath = new Map(geometry.filter((candidate) => candidate.domPath !== undefined).map((candidate) => [candidate.domPath!, candidate]));
|
|
125
|
+
const backgroundElements: GeometryRecord[] = [];
|
|
126
|
+
let candidate: GeometryRecord | undefined = element;
|
|
127
|
+
while (candidate) {
|
|
128
|
+
backgroundElements.push(candidate);
|
|
129
|
+
if (contrast.available && candidate.id === contrast.backgroundSourceElementId) break;
|
|
130
|
+
candidate = byPath.get(parentPath(candidate.domPath) ?? '');
|
|
131
|
+
}
|
|
132
|
+
const targets = new Map<string, { element: GeometryRecord; properties: ReadonlySet<string> }>();
|
|
133
|
+
targets.set(element.id, { element, properties: new Set(['color', 'background-color', 'backgroundColor']) });
|
|
134
|
+
for (const ancestor of backgroundElements) {
|
|
135
|
+
const existing = targets.get(ancestor.id);
|
|
136
|
+
targets.set(ancestor.id, { element: ancestor, properties: existing ? new Set([...existing.properties, 'background-color', 'backgroundColor']) : new Set(['background-color', 'backgroundColor']) });
|
|
137
|
+
}
|
|
138
|
+
const styles: TextStyleProvenance[] = [];
|
|
139
|
+
const unavailableFor: string[] = [];
|
|
140
|
+
for (const { element: target, properties } of targets.values()) {
|
|
141
|
+
const record = records.find((candidate) => sameTarget(candidate, target));
|
|
142
|
+
if (!record || record.provenanceUnavailable) {
|
|
143
|
+
unavailableFor.push(elementLabel(target));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const declaration of record.winningDeclarations ?? []) {
|
|
147
|
+
if (!declaration.property || !properties.has(declaration.property)) continue;
|
|
148
|
+
styles.push({
|
|
149
|
+
element: elementLabel(target),
|
|
150
|
+
property: declaration.property,
|
|
151
|
+
value: declaration.value ?? '(no computed value)',
|
|
152
|
+
selector: declaration.selector ?? '(no author declaration)',
|
|
153
|
+
specificity: declaration.specificity ?? (declaration.selector === 'inline' ? 'inline' : 'none'),
|
|
154
|
+
source: sourceOf(declaration),
|
|
155
|
+
...(declaration.winnerApproximate ? { uncertainty: declaration.winnerApproximateReason ?? 'simplified cascade model' } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { styles, unavailableFor };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reads one target's recorded text, font, color, and cascade facts without driving a browser. */
|
|
163
|
+
export function measureTextSnapshot(ref: SnapRef, selector: string): TextSnapshotResult {
|
|
164
|
+
const textArtifact = readText<TextArtifact>(ref);
|
|
165
|
+
if (textArtifact.available === false && selector.startsWith('text:')) throw new Error(`text collection is unavailable${textArtifact.unavailableReason ? `: ${textArtifact.unavailableReason}` : ''}; cannot resolve a text: selector`);
|
|
166
|
+
const selection = explainSnapshot(ref, selector, { text: true });
|
|
167
|
+
if (selection.kind !== 'explanation') return selection;
|
|
168
|
+
const element = selection.element;
|
|
169
|
+
const textAvailable = textArtifact.available !== false;
|
|
170
|
+
const text = textAvailable ? (textArtifact.elements ?? []).find((record) => sameTarget(record, element)) : undefined;
|
|
171
|
+
const contrast = measureElementContrast(ref, element.id);
|
|
172
|
+
const provenance = styleProvenance(ref, element, contrast);
|
|
173
|
+
return {
|
|
174
|
+
kind: 'measurement',
|
|
175
|
+
ref,
|
|
176
|
+
selector,
|
|
177
|
+
matchCount: selection.matchCount,
|
|
178
|
+
element,
|
|
179
|
+
text,
|
|
180
|
+
textAvailable,
|
|
181
|
+
...(textArtifact.available === false ? { textUnavailableReason: textArtifact.unavailableReason ?? 'no reason was recorded' } : {}),
|
|
182
|
+
contrast,
|
|
183
|
+
styles: provenance.styles,
|
|
184
|
+
styleProvenanceUnavailableFor: provenance.unavailableFor,
|
|
185
|
+
meta: { ...selection.meta, viewport: readMeta<{ viewport?: string | null }>(ref).viewport },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function capturedCssViewport(ref: SnapRef): { width: number; height: number } {
|
|
190
|
+
const queries = readQueries<QueriesArtifact>(ref);
|
|
191
|
+
if (queries.available === false) throw new Error(`the snapshot query environment is unavailable${queries.unavailableReason ? `: ${queries.unavailableReason}` : ''}; cannot map CSS geometry to screenshot pixels`);
|
|
192
|
+
const width = queries.environment?.width;
|
|
193
|
+
const height = queries.environment?.height;
|
|
194
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width! <= 0 || height! <= 0) throw new Error('the snapshot has no positive recorded CSS viewport in queries.json; cannot map CSS geometry to screenshot pixels');
|
|
195
|
+
return { width: width!, height: height! };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Writes a crop into the active session or a fresh measure one-shot bundle, never into the source snapshot. */
|
|
199
|
+
export function writeTextCrop(measurement: TextMeasurement, artifactDir?: string): TextCrop {
|
|
200
|
+
const rect = measurement.element.rect;
|
|
201
|
+
const width = rect?.width;
|
|
202
|
+
const height = rect?.height;
|
|
203
|
+
if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y) || !Number.isFinite(width) || !Number.isFinite(height) || width! <= 0 || height! <= 0) throw new Error('the selected element has no positive recorded geometry for a crop');
|
|
204
|
+
const source = PNG.sync.read(fs.readFileSync(artifactPath(measurement.ref, 'screenshot.png')));
|
|
205
|
+
const capturedViewport = capturedCssViewport(measurement.ref);
|
|
206
|
+
const scaleX = source.width / capturedViewport.width;
|
|
207
|
+
const scaleY = source.height / capturedViewport.height;
|
|
208
|
+
const requested = { x: rect.x, y: rect.y, w: width!, h: height! };
|
|
209
|
+
const x = Math.max(0, Math.floor(requested.x * scaleX));
|
|
210
|
+
const y = Math.max(0, Math.floor(requested.y * scaleY));
|
|
211
|
+
const right = Math.min(source.width, Math.ceil((requested.x + requested.w) * scaleX));
|
|
212
|
+
const bottom = Math.min(source.height, Math.ceil((requested.y + requested.h) * scaleY));
|
|
213
|
+
if (right <= x || bottom <= y) throw new Error('the selected element has no recorded intersection with screenshot.png');
|
|
214
|
+
const crop = new PNG({ width: right - x, height: bottom - y });
|
|
215
|
+
PNG.bitblt(source, crop, x, y, crop.width, crop.height, 0, 0);
|
|
216
|
+
const active = getActiveSession();
|
|
217
|
+
const root = active?.dir ?? createOneshotSession('measure', artifactDir).dir;
|
|
218
|
+
const filename = `text-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.png`;
|
|
219
|
+
const output = path.join(root, 'measure', 'text', filename);
|
|
220
|
+
writeBinaryPrivate(output, PNG.sync.write(crop));
|
|
221
|
+
return {
|
|
222
|
+
path: output,
|
|
223
|
+
requested,
|
|
224
|
+
delivered: { x, y, w: crop.width, h: crop.height },
|
|
225
|
+
scale: { x: scaleX, y: scaleY },
|
|
226
|
+
};
|
|
227
|
+
}
|