@crouton-kit/capture 1.3.20 → 1.4.0
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 +2890 -1756
- package/package.json +1 -1
- package/src/capture.ts +3 -2
- package/src/cdp/args.ts +18 -3
- package/src/cdp/commands/measure/explain.ts +22 -7
- package/src/cdp/commands/measure/index.ts +8 -4
- package/src/cdp/commands/measure/map-paint.ts +273 -0
- package/src/cdp/commands/measure/snap.ts +34 -7
- package/src/cdp/commands/page/click.ts +32 -4
- package/src/cdp/commands/page/exec.ts +1 -1
- package/src/cdp/commands/page/index.ts +2 -2
- package/src/cdp/commands/page/scroll.ts +6 -4
- package/src/cdp/commands/page/shot.ts +118 -6
- package/src/cdp/commands/page/type.ts +6 -4
- package/src/cdp/connection.ts +19 -12
- package/src/cdp/measure/collectors/geometry.ts +66 -9
- package/src/cdp/measure/collectors/pixels.ts +9 -1
- package/src/cdp/measure/collectors/states.ts +243 -4
- package/src/cdp/measure/collectors/styles.ts +67 -43
- package/src/cdp/measure/explain.ts +123 -23
- package/src/cdp/measure/ink-source.ts +116 -0
- package/src/cdp/measure/ink.ts +180 -0
- package/src/cdp/measure/snapshot.ts +27 -13
- package/src/cdp/screenshot.ts +43 -11
- package/src/cdp/targets.ts +24 -0
- package/src/cdp/types.ts +15 -0
- package/src/contracts/index.ts +0 -2
- package/src/output/render.ts +1 -0
- package/src/output/selector.ts +238 -5
- package/src/session/commands.ts +47 -32
- package/src/command-help.ts +0 -29
- package/src/command-resolution.ts +0 -22
- package/src/command-walker.ts +0 -35
- package/src/contracts/results.ts +0 -208
- package/src/contracts/routes.ts +0 -140
- package/src/output/exact-raw.ts +0 -114
- package/src/registry.ts +0 -208
|
@@ -18,11 +18,15 @@ import {
|
|
|
18
18
|
} from '../../output/artifact.js';
|
|
19
19
|
import { parseSelectorInput, resolveSelectorInput, type ElementRecord, type SelectorInputKind } from '../../output/selector.js';
|
|
20
20
|
import { fact, line, text, type FactLine } from '../../output/render.js';
|
|
21
|
+
import { deriveInkBox, type InkEdges, type InkRect } from './ink.js';
|
|
22
|
+
import { baseInkSource, forcedStateInkSource, isInkSource, quadRect } from './ink-source.js';
|
|
21
23
|
|
|
22
24
|
export interface ExplainDetailOptions {
|
|
23
25
|
readonly size?: boolean;
|
|
24
26
|
readonly text?: boolean;
|
|
25
27
|
readonly form?: boolean;
|
|
28
|
+
/** One requested post-force state recorded by `measure snap --state`. */
|
|
29
|
+
readonly state?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface ArtifactElement extends ElementRecord {
|
|
@@ -47,6 +51,8 @@ interface GeometryElement extends ArtifactElement {
|
|
|
47
51
|
readonly boxModel?: Record<string, unknown> | null;
|
|
48
52
|
readonly stackingContext?: { readonly creates?: boolean; readonly reasons?: readonly string[] };
|
|
49
53
|
readonly clipping?: { readonly clippedBy?: string; readonly clippedFraction?: number } | null;
|
|
54
|
+
/** Present on snapshots that recorded uncapped ink-relevant computed styles with geometry. */
|
|
55
|
+
readonly paint?: Record<string, string | null>;
|
|
50
56
|
readonly visibility?: { readonly visible?: boolean; readonly opacity?: number };
|
|
51
57
|
readonly layout?: Record<string, unknown>;
|
|
52
58
|
}
|
|
@@ -97,6 +103,8 @@ export interface ExplainSuccess {
|
|
|
97
103
|
export interface MissingSelectorRecovery {
|
|
98
104
|
readonly recordCount: number;
|
|
99
105
|
readonly kind: SelectorInputKind;
|
|
106
|
+
/** Present when a CSS attribute/quoted value was ranked as AX/text evidence before generated CSS paths. */
|
|
107
|
+
readonly interpretedValue?: string;
|
|
100
108
|
readonly candidates: readonly string[];
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -177,6 +185,21 @@ function selectorIdentifierDistance(requested: string, candidate: string): numbe
|
|
|
177
185
|
return requestedTokens.reduce((total, token) => total + Math.min(...candidateTokens.map((other) => editDistance(token, other) / Math.max(token.length, other.length))), 0);
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
function quotedSelectorValue(value: string): string | undefined {
|
|
189
|
+
const attribute = /\[[^\]]*?=\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|([^\]\s]+))\]/.exec(value);
|
|
190
|
+
const quoted = attribute?.[1] ?? attribute?.[2] ?? attribute?.[3] ?? /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/.exec(value)?.slice(1).find(Boolean);
|
|
191
|
+
return quoted?.replace(/\\(.)/g, '$1');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rankCandidates(candidates: string[], requested: string, valueOf: (candidate: string) => string): string[] {
|
|
195
|
+
return candidates.sort((a, b) => {
|
|
196
|
+
const aValue = valueOf(a).toLowerCase(), bValue = valueOf(b).toLowerCase();
|
|
197
|
+
const aIdentifierDistance = selectorIdentifierDistance(requested, aValue), bIdentifierDistance = selectorIdentifierDistance(requested, bValue);
|
|
198
|
+
const aDistance = editDistance(requested.toLowerCase(), aValue), bDistance = editDistance(requested.toLowerCase(), bValue);
|
|
199
|
+
return aIdentifierDistance - bIdentifierDistance || aDistance - bDistance || a.length - b.length || a.localeCompare(b);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
function recoveryForms(elements: readonly ElementRecord[], requested: string): MissingSelectorRecovery {
|
|
181
204
|
const input = parseSelectorInput(requested);
|
|
182
205
|
const formFor = (element: ElementRecord): string | undefined => {
|
|
@@ -189,14 +212,17 @@ function recoveryForms(elements: readonly ElementRecord[], requested: string): M
|
|
|
189
212
|
}
|
|
190
213
|
};
|
|
191
214
|
const valueOf = (candidate: string) => input.kind === 'css' ? candidate : candidate.slice(candidate.indexOf(':') + 1);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
215
|
+
const cssCandidates = rankCandidates([...new Set(elements.map(formFor).filter((candidate): candidate is string => Boolean(candidate)))], input.value, valueOf);
|
|
216
|
+
const interpretedValue = input.kind === 'css' ? quotedSelectorValue(input.value) : undefined;
|
|
217
|
+
if (!interpretedValue) {
|
|
218
|
+
return { recordCount: elements.length, kind: input.kind, candidates: cssCandidates.slice(0, RECOVERY_CANDIDATE_LIMIT) };
|
|
219
|
+
}
|
|
220
|
+
const identityCandidates = rankCandidates([...new Set(elements.flatMap((element) => [
|
|
221
|
+
element.axName ? `ax:${element.axName}` : undefined,
|
|
222
|
+
element.text ? `text:${element.text}` : undefined,
|
|
223
|
+
]).filter((candidate): candidate is string => Boolean(candidate)))], interpretedValue, (candidate) => candidate.slice(candidate.indexOf(':') + 1));
|
|
224
|
+
const candidates = [...identityCandidates, ...cssCandidates.filter((candidate) => !identityCandidates.includes(candidate))];
|
|
225
|
+
return { recordCount: elements.length, kind: input.kind, interpretedValue, candidates: candidates.slice(0, RECOVERY_CANDIDATE_LIMIT) };
|
|
200
226
|
}
|
|
201
227
|
|
|
202
228
|
function sourceDescription(declaration: WinningDeclaration): string | undefined {
|
|
@@ -257,30 +283,85 @@ function cascadeSection(target: GeometryElement, styles: readonly StyleElement[]
|
|
|
257
283
|
return { kind: 'cascade', facts };
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
function
|
|
286
|
+
function formatBox(rect: InkRect): string {
|
|
287
|
+
return `x=${rect.x}…${rect.x + rect.width} y=${rect.y}…${rect.y + rect.height} (w=${rect.width} h=${rect.height})`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function overflowEdges(box: InkRect, clip: InkRect): InkEdges {
|
|
291
|
+
return {
|
|
292
|
+
top: Math.max(0, clip.y - box.y),
|
|
293
|
+
right: Math.max(0, box.x + box.width - (clip.x + clip.width)),
|
|
294
|
+
bottom: Math.max(0, box.y + box.height - (clip.y + clip.height)),
|
|
295
|
+
left: Math.max(0, clip.x - box.x),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatOverflow(edges: InkEdges): string {
|
|
300
|
+
const named = (Object.entries(edges) as Array<[keyof InkEdges, number]>).filter(([, value]) => value > 0).map(([edge, value]) => `${edge}=${value}px`);
|
|
301
|
+
return named.length ? named.join(' ') : 'none';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function stackingClippingSection(target: GeometryElement, geometry: readonly GeometryElement[], states: unknown, state?: string): RawSection {
|
|
261
305
|
const facts: ExplainFact[] = [];
|
|
262
306
|
const ancestors = ancestorElements(geometry, target);
|
|
263
307
|
const stacking = ancestors.filter((element) => element.stackingContext?.creates);
|
|
264
308
|
const root = ancestors[0];
|
|
265
|
-
if (root) facts.push(factFor(root, fact`Stacking climb root: ${root.selector ?? root.tag ?? root.id}; z-index=${root.zIndex ?? 'auto'}.`));
|
|
309
|
+
if (root) facts.push(factFor(root, fact`Stacking climb root: ${root.selector ?? root.tag ?? root.id}; z-index=${root.zIndex ?? 'auto'} (base-state geometry record).`));
|
|
266
310
|
for (const ancestor of stacking) {
|
|
267
|
-
facts.push(factFor(ancestor, fact`Stacking ancestor ${ancestor.selector ?? ancestor.id}: z-index=${ancestor.zIndex ?? 'auto'}; creates context from ${(ancestor.stackingContext?.reasons ?? []).join(', ') || 'recorded context trigger'}.`));
|
|
311
|
+
facts.push(factFor(ancestor, fact`Stacking ancestor ${ancestor.selector ?? ancestor.id}: z-index=${ancestor.zIndex ?? 'auto'}; creates context from ${(ancestor.stackingContext?.reasons ?? []).join(', ') || 'recorded context trigger'} (base-state geometry record).`));
|
|
312
|
+
}
|
|
313
|
+
facts.push(factFor(target, fact`Target stacking record: z-index=${target.zIndex ?? 'auto'}; creates-context=${String(Boolean(target.stackingContext?.creates))}; reasons=${(target.stackingContext?.reasons ?? []).join(', ') || 'none recorded'} (base-state geometry record).`));
|
|
314
|
+
|
|
315
|
+
let source = baseInkSource(target);
|
|
316
|
+
if (state) {
|
|
317
|
+
const forced = forcedStateInkSource(states, target, state);
|
|
318
|
+
if (isInkSource(forced)) source = forced;
|
|
319
|
+
else facts.push(factFor(target, fact`Requested state "${state}" supplied no post-force ink source: ${forced.unavailable}; border/ink and clipping comparisons use the base-state geometry record.`));
|
|
320
|
+
}
|
|
321
|
+
const borderBox = isInkSource(source) ? source.rect : undefined;
|
|
322
|
+
const ink = isInkSource(source) ? deriveInkBox(source.rect, source.styles) : undefined;
|
|
323
|
+
if (ink?.inkBox && borderBox) {
|
|
324
|
+
const contributors = ink.contributors.length
|
|
325
|
+
? ink.contributors.map((contributor) => `${contributor.source}${contributor.index === undefined ? '' : ` ${contributor.index}`} (top=${contributor.edges.top}px right=${contributor.edges.right}px bottom=${contributor.edges.bottom}px left=${contributor.edges.left}px)`).join('; ')
|
|
326
|
+
: 'none';
|
|
327
|
+
facts.push(factFor(target, ink.nominal
|
|
328
|
+
? fact`Border box (${source.provenance}): ${formatBox(borderBox)}. Nominal ink box (blur-radius extent, ${source.provenance}): ${formatBox(ink.inkBox)}; contributors: ${contributors}.`
|
|
329
|
+
: fact`Border box (${source.provenance}): ${formatBox(borderBox)}. Ink box (${source.provenance}): ${formatBox(ink.inkBox)}; contributors: ${contributors}.`));
|
|
330
|
+
} else if (ink) {
|
|
331
|
+
const reason = ink.missingStyles.length
|
|
332
|
+
? `computed styles were not captured: ${ink.missingStyles.join(', ')}`
|
|
333
|
+
: `paint extents could not be resolved: ${ink.unresolved.join(', ')}`;
|
|
334
|
+
facts.push(factFor(target, fact`Ink box (${source.provenance}) was not calculated because ${reason}.`));
|
|
335
|
+
} else {
|
|
336
|
+
facts.push(factFor(target, text`Ink box was not calculated because ${source.unavailable}.`));
|
|
268
337
|
}
|
|
269
|
-
facts.push(factFor(target, fact`Target stacking record: z-index=${target.zIndex ?? 'auto'}; creates-context=${String(Boolean(target.stackingContext?.creates))}; reasons=${(target.stackingContext?.reasons ?? []).join(', ') || 'none recorded'}.`));
|
|
270
338
|
|
|
271
|
-
const
|
|
339
|
+
const overflowAncestors = ancestors.filter((element) => {
|
|
272
340
|
const layout = element.layout ?? {};
|
|
273
|
-
return [layout.overflowX, layout.overflowY].some((value) => typeof value === 'string' &&
|
|
341
|
+
return [layout.overflowX, layout.overflowY].some((value) => typeof value === 'string' && /\b(?:hidden|auto|scroll|clip)\b/.test(value));
|
|
342
|
+
});
|
|
343
|
+
const clipPathAncestors = ancestors.filter((element) => {
|
|
344
|
+
const clipPath = element.paint?.['clip-path'];
|
|
345
|
+
return typeof clipPath === 'string' && clipPath !== 'none';
|
|
274
346
|
});
|
|
275
|
-
for (const ancestor of
|
|
347
|
+
for (const ancestor of overflowAncestors) {
|
|
276
348
|
const layout = ancestor.layout ?? {};
|
|
277
|
-
const
|
|
278
|
-
|
|
349
|
+
const clip = quadRect((ancestor.boxModel ?? {}).padding);
|
|
350
|
+
const radius = ancestor.paint?.['border-radius'];
|
|
351
|
+
if (!clip) {
|
|
352
|
+
facts.push(factFor(ancestor, fact`Clipping ancestor ${ancestor.selector ?? ancestor.id}: overflow-x=${String(layout.overflowX ?? 'unknown')}; overflow-y=${String(layout.overflowY ?? 'unknown')} (base-state geometry record); padding-box clip was not captured, so border-box and ink-box clipping were not calculated.`));
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const borderEdges = borderBox ? overflowEdges(borderBox, clip) : undefined;
|
|
356
|
+
const inkEdges = ink?.inkBox ? overflowEdges(ink.inkBox, clip) : undefined;
|
|
357
|
+
const targetSource = isInkSource(source) ? source.provenance : source.unavailable;
|
|
358
|
+
facts.push(factFor(ancestor, fact`Clipping ancestor ${ancestor.selector ?? ancestor.id}: overflow-x=${String(layout.overflowX ?? 'unknown')}; overflow-y=${String(layout.overflowY ?? 'unknown')}; rectangular padding clip (base-state geometry record) ${formatBox(clip)}; border-radius=${radius ?? 'not captured'}${radius && radius !== '0px' ? ' (rounded clip shape not resolved)' : ''}; target border/ink source=${targetSource}; border-box-overflows-padding-clip=${String(Boolean(borderEdges && formatOverflow(borderEdges) !== 'none'))}; border-box-overflow=${borderEdges ? formatOverflow(borderEdges) : 'not calculated'}; ink-box-overflows-padding-clip=${String(Boolean(inkEdges && formatOverflow(inkEdges) !== 'none'))}; ink-box-overflow=${inkEdges ? formatOverflow(inkEdges) : 'not calculated'}.`));
|
|
359
|
+
}
|
|
360
|
+
for (const ancestor of clipPathAncestors) {
|
|
361
|
+
facts.push(factFor(ancestor, fact`Clip-path ancestor ${ancestor.selector ?? ancestor.id}: clip-path=${ancestor.paint?.['clip-path']} (base-state geometry record); shape geometry was not captured, so border-box and ink-box clipping were not calculated for this source.`));
|
|
279
362
|
}
|
|
280
|
-
if (
|
|
281
|
-
facts.push(factFor(target,
|
|
282
|
-
} else if (!target.clipping) {
|
|
283
|
-
facts.push(factFor(target, text`No clipping source was recorded for the target.`));
|
|
363
|
+
if (!overflowAncestors.length && !clipPathAncestors.length) {
|
|
364
|
+
facts.push(factFor(target, text`No overflow or clip-path clipping ancestor was recorded for the target.`));
|
|
284
365
|
}
|
|
285
366
|
return { kind: 'stacking-clipping', facts };
|
|
286
367
|
}
|
|
@@ -361,7 +442,26 @@ function queriesStatesSection(target: GeometryElement, queries: Record<string, u
|
|
|
361
442
|
const records = (Array.isArray(states.elements) ? states.elements : [])
|
|
362
443
|
.filter((entry): entry is ArtifactElement & Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
|
|
363
444
|
.filter((entry) => sameTarget(entry, target));
|
|
364
|
-
|
|
445
|
+
// A forced state records the element it forced plus the descendants and following peers whose
|
|
446
|
+
// style or geometry moved. Counting only the forced-target rows reports `matching records=0` for
|
|
447
|
+
// an element whose post-force values this same output just derived from an affected row.
|
|
448
|
+
const affectedRows = (Array.isArray(states.elements) ? states.elements : [])
|
|
449
|
+
.filter((entry): entry is ArtifactElement & Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
|
|
450
|
+
.flatMap((entry) => {
|
|
451
|
+
const affected = entry.affected as Record<string, unknown> | undefined;
|
|
452
|
+
const rows = affected && typeof affected === 'object' && Array.isArray(affected.elements) ? affected.elements : [];
|
|
453
|
+
return rows
|
|
454
|
+
.filter((row): row is ArtifactElement & Record<string, unknown> => Boolean(row && typeof row === 'object'))
|
|
455
|
+
.filter((row) => String(row.relation ?? '') !== 'target' && sameTarget(row, target))
|
|
456
|
+
.map((row) => ({ state: String(entry.state ?? 'unknown'), forced: String(entry.selector ?? 'unknown'), row }));
|
|
457
|
+
});
|
|
458
|
+
facts.push(factFor(target, fact`State matrix context: requested=${requested.join(', ') || 'none'}; records forcing this element=${records.length}; records where it is an affected descendant or peer=${affectedRows.length}.`));
|
|
459
|
+
for (const { state, forced, row } of affectedRows) {
|
|
460
|
+
const changes = row.style && typeof row.style === 'object' && Array.isArray((row.style as Record<string, unknown>).changed)
|
|
461
|
+
? ((row.style as Record<string, unknown>).changed as unknown[]).map(String).join(', ')
|
|
462
|
+
: 'none recorded';
|
|
463
|
+
facts.push(factFor(target, fact`State ${state}: recorded as ${String(row.relation ?? 'unknown')} of forced target ${forced}; style changes=${changes}; geometry-changed=${String((row.geometry as Record<string, unknown> | undefined)?.changed ?? false)}; hittest-changed=${String((row.hittest as Record<string, unknown> | undefined)?.changed ?? false)}.`));
|
|
464
|
+
}
|
|
365
465
|
for (const record of records) {
|
|
366
466
|
const changes = record.style && typeof record.style === 'object' && Array.isArray((record.style as Record<string, unknown>).changed)
|
|
367
467
|
? ((record.style as Record<string, unknown>).changed as unknown[]).map(String).join(', ')
|
|
@@ -478,7 +578,7 @@ export function explainSnapshot(ref: SnapRef, selector: string, detailOpts: Expl
|
|
|
478
578
|
const rawSections: RawSection[] = [
|
|
479
579
|
elementSection(target, matches.length),
|
|
480
580
|
cascadeSection(target, styles),
|
|
481
|
-
stackingClippingSection(target, geometry),
|
|
581
|
+
stackingClippingSection(target, geometry, states, detailOpts.state),
|
|
482
582
|
focusScrollSection(target, focus, scroll),
|
|
483
583
|
queriesStatesSection(target, queries, states, styles),
|
|
484
584
|
];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { InkRect, InkStyles } from './ink.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selects which recorded rect + computed-style table an ink derivation runs over:
|
|
5
|
+
* the base-state geometry record, or one element's post-force values under a
|
|
6
|
+
* requested `--state`. Both callers of `deriveInkBox` over snapshot artifacts
|
|
7
|
+
* (`measure explain` and `measure map paint`) resolve their input here so the
|
|
8
|
+
* two leaves cannot disagree about what a forced-state ink box is derived from.
|
|
9
|
+
*/
|
|
10
|
+
export interface InkSource {
|
|
11
|
+
readonly rect: InkRect;
|
|
12
|
+
readonly styles: InkStyles;
|
|
13
|
+
/** Factual description of where `rect` and `styles` were read from, for the caller to report. */
|
|
14
|
+
readonly provenance: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InkSourceUnavailable {
|
|
18
|
+
/** Factual reason no rect/style pair was found, phrased for direct inclusion in output. */
|
|
19
|
+
readonly unavailable: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type InkSourceResult = InkSource | InkSourceUnavailable;
|
|
23
|
+
|
|
24
|
+
export function isInkSource(result: InkSourceResult): result is InkSource {
|
|
25
|
+
return (result as InkSource).rect !== undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InkTargetIdentity {
|
|
29
|
+
readonly selector?: string;
|
|
30
|
+
readonly backendNodeId?: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GeometryLike extends InkTargetIdentity {
|
|
34
|
+
readonly boxModel?: Record<string, unknown> | null;
|
|
35
|
+
/** Ink-relevant computed styles recorded beside geometry; absent on snapshots taken before that collection existed. */
|
|
36
|
+
readonly paint?: Record<string, string | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Converts a CDP box-model quad (8 numbers) to its axis-aligned bounding rect. */
|
|
40
|
+
export function quadRect(value: unknown): InkRect | undefined {
|
|
41
|
+
if (!Array.isArray(value) || value.length !== 8 || !value.every((entry) => Number.isFinite(entry))) return undefined;
|
|
42
|
+
const xs = [value[0], value[2], value[4], value[6]] as number[];
|
|
43
|
+
const ys = [value[1], value[3], value[5], value[7]] as number[];
|
|
44
|
+
const left = Math.min(...xs), top = Math.min(...ys);
|
|
45
|
+
return { x: left, y: top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function baseInkSource(element: GeometryLike): InkSourceResult {
|
|
49
|
+
const rect = quadRect((element.boxModel ?? {}).border);
|
|
50
|
+
if (!rect) return { unavailable: `this target's border-box quad was not captured` };
|
|
51
|
+
return { rect, styles: element.paint ?? {}, provenance: 'base-state geometry record' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rectFrom(value: unknown): InkRect | undefined {
|
|
55
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
56
|
+
const raw = value as Record<string, unknown>;
|
|
57
|
+
const [x, y, width, height] = [raw.x, raw.y, raw.width ?? raw.w, raw.height ?? raw.h];
|
|
58
|
+
if (![x, y, width, height].every((entry) => typeof entry === 'number' && Number.isFinite(entry))) return undefined;
|
|
59
|
+
return { x: x as number, y: y as number, width: width as number, height: height as number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stylesFrom(value: unknown): InkStyles | undefined {
|
|
63
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
64
|
+
const after = (value as Record<string, unknown>).after;
|
|
65
|
+
if (!after || typeof after !== 'object') return undefined;
|
|
66
|
+
return after as InkStyles;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function identityMatches(row: InkTargetIdentity, target: InkTargetIdentity): boolean {
|
|
70
|
+
if (typeof target.backendNodeId === 'number' && typeof row.backendNodeId === 'number') return row.backendNodeId === target.backendNodeId;
|
|
71
|
+
return Boolean(target.selector && row.selector === target.selector);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface StateRow extends InkTargetIdentity {
|
|
75
|
+
readonly relation?: string;
|
|
76
|
+
readonly geometry?: unknown;
|
|
77
|
+
readonly style?: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolves one element's post-force rect and computed styles from `states.json`.
|
|
82
|
+
* The rect is that element's `getBoundingClientRect` AABB after the force was
|
|
83
|
+
* applied — a border box, which is what `deriveInkBox` grows. Under a forced
|
|
84
|
+
* state the paint change frequently lands on a descendant of the forced target
|
|
85
|
+
* (a hover ring drawn on a child), so `affected.elements` rows are searched
|
|
86
|
+
* alongside the forced target's own record.
|
|
87
|
+
*/
|
|
88
|
+
export function forcedStateInkSource(states: unknown, target: InkTargetIdentity, state: string): InkSourceResult {
|
|
89
|
+
const elements = (states && typeof states === 'object' && Array.isArray((states as Record<string, unknown>).elements))
|
|
90
|
+
? (states as { elements: unknown[] }).elements.filter((entry): entry is Record<string, unknown> => Boolean(entry && typeof entry === 'object'))
|
|
91
|
+
: [];
|
|
92
|
+
const forState = elements.filter((record) => String(record.state ?? '') === state);
|
|
93
|
+
if (!forState.length) {
|
|
94
|
+
const recorded = [...new Set(elements.map((record) => String(record.state ?? '')).filter(Boolean))];
|
|
95
|
+
return { unavailable: `this snapshot recorded no "${state}" state${recorded.length ? `; it recorded ${recorded.join(', ')}` : ''}` };
|
|
96
|
+
}
|
|
97
|
+
const unsupported = forState.filter((record) => record.supported === false);
|
|
98
|
+
for (const record of forState) {
|
|
99
|
+
if (record.supported === false) continue;
|
|
100
|
+
const affected = (record.affected && typeof record.affected === 'object' && Array.isArray((record.affected as Record<string, unknown>).elements))
|
|
101
|
+
? ((record.affected as { elements: unknown[] }).elements.filter((entry): entry is StateRow => Boolean(entry && typeof entry === 'object')))
|
|
102
|
+
: [];
|
|
103
|
+
const own: StateRow = { selector: record.selector as string | undefined, backendNodeId: record.backendNodeId as number | null | undefined, relation: 'target', geometry: record.geometry, style: record.style };
|
|
104
|
+
for (const row of [own, ...affected]) {
|
|
105
|
+
if (!identityMatches(row, target)) continue;
|
|
106
|
+
const rect = rectFrom((row.geometry as Record<string, unknown> | undefined)?.after);
|
|
107
|
+
const styles = stylesFrom(row.style);
|
|
108
|
+
if (!rect) return { unavailable: `the "${state}" record for this target captured no post-force rect` };
|
|
109
|
+
if (!styles) return { unavailable: `the "${state}" record for this target captured no post-force computed styles` };
|
|
110
|
+
const id = String(record.id ?? state);
|
|
111
|
+
return { rect, styles, provenance: `forced state "${state}" (states.json record ${id}, ${row.relation ?? 'affected'} row, post-force values)` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (unsupported.length === forState.length) return { unavailable: `state "${state}" was recorded as unsupported on its forced target` };
|
|
115
|
+
return { unavailable: `no "${state}" record matched this target; forced-state rows cover the forced target, its light-DOM descendants, and its following-sibling subtrees` };
|
|
116
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export interface InkRect {
|
|
2
|
+
readonly x: number;
|
|
3
|
+
readonly y: number;
|
|
4
|
+
readonly width: number;
|
|
5
|
+
readonly height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InkStyles {
|
|
9
|
+
readonly 'box-shadow'?: string | null;
|
|
10
|
+
readonly 'outline-width'?: string | null;
|
|
11
|
+
readonly 'outline-style'?: string | null;
|
|
12
|
+
readonly 'outline-offset'?: string | null;
|
|
13
|
+
readonly filter?: string | null;
|
|
14
|
+
readonly transform?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InkEdges {
|
|
18
|
+
readonly top: number;
|
|
19
|
+
readonly right: number;
|
|
20
|
+
readonly bottom: number;
|
|
21
|
+
readonly left: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InkContributor {
|
|
25
|
+
readonly source: 'box-shadow' | 'outline' | 'filter:drop-shadow';
|
|
26
|
+
readonly index?: number;
|
|
27
|
+
readonly edges: InkEdges;
|
|
28
|
+
/** A blur has no finite specified painted edge; its radius is a nominal AABB convention. */
|
|
29
|
+
readonly nominal?: true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InkResult {
|
|
33
|
+
/** `null` when a needed computed-style value was not captured or a paint-growing filter could not be resolved. */
|
|
34
|
+
readonly inkBox: InkRect | null;
|
|
35
|
+
readonly contributors: readonly InkContributor[];
|
|
36
|
+
readonly missingStyles: readonly string[];
|
|
37
|
+
readonly unresolved: readonly string[];
|
|
38
|
+
/** True when an included blur uses the parser's nominal blur-radius extent. */
|
|
39
|
+
readonly nominal: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REQUIRED_STYLES = ['box-shadow', 'outline-width', 'outline-style', 'outline-offset', 'filter', 'transform'] as const;
|
|
43
|
+
|
|
44
|
+
function zeroEdges(): InkEdges {
|
|
45
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function splitTopLevel(value: string, delimiter: ',' | ' '): string[] {
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let start = 0;
|
|
52
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
53
|
+
const char = value[index]!;
|
|
54
|
+
if (char === '(') depth += 1;
|
|
55
|
+
else if (char === ')' && depth > 0) depth -= 1;
|
|
56
|
+
else if (depth === 0 && (delimiter === ',' ? char === ',' : /\s/.test(char))) {
|
|
57
|
+
const part = value.slice(start, index).trim();
|
|
58
|
+
if (part) parts.push(part);
|
|
59
|
+
start = index + 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const tail = value.slice(start).trim();
|
|
63
|
+
if (tail) parts.push(tail);
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function px(value: string): number | undefined {
|
|
68
|
+
const match = /^(-?(?:\d+|\d*\.\d+))px$/.exec(value) ?? (/^-?(?:\d+|\d*\.\d+)$/.test(value) ? [value, value] : undefined);
|
|
69
|
+
if (!match) return undefined;
|
|
70
|
+
const parsed = Number(match[1]);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function edgesForShadow(offsetX: number, offsetY: number, blur: number, spread: number): InkEdges {
|
|
75
|
+
// CSS computes these values to px. Blur's painted boundary is not a hard CSS edge;
|
|
76
|
+
// its radius is the recorded geometric extent used for this conservative ink AABB.
|
|
77
|
+
const growth = Math.max(0, blur + spread);
|
|
78
|
+
return {
|
|
79
|
+
left: Math.max(0, growth - offsetX),
|
|
80
|
+
right: Math.max(0, growth + offsetX),
|
|
81
|
+
top: Math.max(0, growth - offsetY),
|
|
82
|
+
bottom: Math.max(0, growth + offsetY),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseShadow(value: string, source: InkContributor['source'], index: number, allowSpread: boolean): InkContributor | undefined {
|
|
87
|
+
const tokens = splitTopLevel(value, ' ').filter((token) => token.toLowerCase() !== 'inset');
|
|
88
|
+
const lengths = tokens.map(px).filter((length): length is number => length !== undefined);
|
|
89
|
+
if (lengths.length < 2 || lengths.length > (allowSpread ? 4 : 3)) return undefined;
|
|
90
|
+
const [offsetX, offsetY, blur = 0, spread = 0] = lengths;
|
|
91
|
+
return { source, index, edges: edgesForShadow(offsetX!, offsetY!, blur, allowSpread ? spread : 0), ...(blur > 0 ? { nominal: true as const } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dropShadowArguments(filter: string): string[] {
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
const lower = filter.toLowerCase();
|
|
97
|
+
let cursor = 0;
|
|
98
|
+
while (cursor < lower.length) {
|
|
99
|
+
const start = lower.indexOf('drop-shadow(', cursor);
|
|
100
|
+
if (start < 0) break;
|
|
101
|
+
let depth = 1;
|
|
102
|
+
let end = start + 'drop-shadow('.length;
|
|
103
|
+
while (end < filter.length && depth > 0) {
|
|
104
|
+
if (filter[end] === '(') depth += 1;
|
|
105
|
+
else if (filter[end] === ')') depth -= 1;
|
|
106
|
+
end += 1;
|
|
107
|
+
}
|
|
108
|
+
if (depth === 0) out.push(filter.slice(start + 'drop-shadow('.length, end - 1));
|
|
109
|
+
cursor = end;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function containsUnresolvedPaintFilter(filter: string): boolean {
|
|
115
|
+
const remainder = filter.replace(/drop-shadow\((?:[^()]|\([^()]*\))*\)/gi, '').trim();
|
|
116
|
+
return /(?:\bblur\(|\burl\()/i.test(remainder);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function combineEdges(contributors: readonly InkContributor[]): InkEdges {
|
|
120
|
+
return contributors.reduce<InkEdges>((extent, contributor) => ({
|
|
121
|
+
top: Math.max(extent.top, contributor.edges.top),
|
|
122
|
+
right: Math.max(extent.right, contributor.edges.right),
|
|
123
|
+
bottom: Math.max(extent.bottom, contributor.edges.bottom),
|
|
124
|
+
left: Math.max(extent.left, contributor.edges.left),
|
|
125
|
+
}), zeroEdges());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Derives an axis-aligned ink box from a recorded border box and computed-style table without a browser. */
|
|
129
|
+
export function deriveInkBox(rect: InkRect, styles: InkStyles): InkResult {
|
|
130
|
+
const missingStyles = REQUIRED_STYLES.filter((property) => styles[property] === undefined || styles[property] === null);
|
|
131
|
+
if (missingStyles.length) return { inkBox: null, contributors: [], missingStyles, unresolved: [], nominal: false };
|
|
132
|
+
if (styles.transform !== 'none') return { inkBox: null, contributors: [], missingStyles: [], unresolved: ['transform'], nominal: false };
|
|
133
|
+
|
|
134
|
+
const contributors: InkContributor[] = [];
|
|
135
|
+
const unresolved: string[] = [];
|
|
136
|
+
const boxShadow = styles['box-shadow']!.trim();
|
|
137
|
+
if (boxShadow && boxShadow !== 'none') {
|
|
138
|
+
splitTopLevel(boxShadow, ',').forEach((shadow, index) => {
|
|
139
|
+
if (/\binset\b/i.test(shadow)) return;
|
|
140
|
+
const contributor = parseShadow(shadow, 'box-shadow', index + 1, true);
|
|
141
|
+
if (contributor) contributors.push(contributor);
|
|
142
|
+
else unresolved.push(`box-shadow ${index + 1}`);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (styles['outline-style'] !== 'none') {
|
|
147
|
+
const width = px(styles['outline-width']!.trim());
|
|
148
|
+
const offset = px(styles['outline-offset']!.trim());
|
|
149
|
+
if (width === undefined || offset === undefined) unresolved.push('outline');
|
|
150
|
+
else {
|
|
151
|
+
const extent = Math.max(0, width + offset);
|
|
152
|
+
contributors.push({ source: 'outline', edges: { top: extent, right: extent, bottom: extent, left: extent } });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const filter = styles.filter!.trim();
|
|
157
|
+
if (filter && filter !== 'none') {
|
|
158
|
+
dropShadowArguments(filter).forEach((shadow, index) => {
|
|
159
|
+
const contributor = parseShadow(shadow, 'filter:drop-shadow', index + 1, false);
|
|
160
|
+
if (contributor) contributors.push(contributor);
|
|
161
|
+
else unresolved.push(`filter drop-shadow ${index + 1}`);
|
|
162
|
+
});
|
|
163
|
+
if (containsUnresolvedPaintFilter(filter)) unresolved.push('filter');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (unresolved.length) return { inkBox: null, contributors, missingStyles: [], unresolved, nominal: contributors.some((contributor) => contributor.nominal) };
|
|
167
|
+
const extent = combineEdges(contributors);
|
|
168
|
+
return {
|
|
169
|
+
inkBox: {
|
|
170
|
+
x: rect.x - extent.left,
|
|
171
|
+
y: rect.y - extent.top,
|
|
172
|
+
width: rect.width + extent.left + extent.right,
|
|
173
|
+
height: rect.height + extent.top + extent.bottom,
|
|
174
|
+
},
|
|
175
|
+
contributors,
|
|
176
|
+
missingStyles: [],
|
|
177
|
+
unresolved: [],
|
|
178
|
+
nominal: contributors.some((contributor) => contributor.nominal),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -199,28 +199,30 @@ export class SnapshotCaptureTimeout extends Error {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/** Bounds one snapshot CDP request and labels the phase that owns it. */
|
|
202
|
-
function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: () => void): CDPClient {
|
|
202
|
+
function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void): CDPClient {
|
|
203
203
|
const bounded = Object.create(client) as CDPClient;
|
|
204
204
|
bounded.send = (method: string, params: Record<string, unknown> = {}, timeout = 60_000, sessionId?: string): Promise<unknown> => {
|
|
205
205
|
const timeoutMs = Math.min(timeout, SNAPSHOT_REQUEST_TIMEOUT_MS);
|
|
206
206
|
let timer: NodeJS.Timeout | undefined;
|
|
207
207
|
let timedOut = false;
|
|
208
|
-
const markTimedOut = (): void => {
|
|
208
|
+
const markTimedOut = (error: SnapshotCaptureTimeout): void => {
|
|
209
209
|
if (timedOut) return;
|
|
210
210
|
timedOut = true;
|
|
211
|
-
onTimeout?.();
|
|
211
|
+
onTimeout?.(error);
|
|
212
212
|
};
|
|
213
213
|
const deadline = new Promise<never>((_, reject) => {
|
|
214
214
|
timer = setTimeout(() => {
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
const error = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
|
|
216
|
+
markTimedOut(error);
|
|
217
|
+
reject(error);
|
|
217
218
|
}, timeoutMs);
|
|
218
219
|
});
|
|
219
220
|
const request = Promise.resolve().then(() => client.send(method, params, timeoutMs, sessionId)).catch((error: unknown) => {
|
|
220
221
|
if (error instanceof SnapshotCaptureTimeout) throw error;
|
|
221
222
|
if (error instanceof Error && error.message.startsWith('CDP request timeout')) {
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
const timeoutError = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
|
|
224
|
+
markTimedOut(timeoutError);
|
|
225
|
+
throw timeoutError;
|
|
224
226
|
}
|
|
225
227
|
throw error;
|
|
226
228
|
});
|
|
@@ -251,6 +253,8 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
251
253
|
const state = options.state ?? [];
|
|
252
254
|
const viewport = options.viewport ?? null;
|
|
253
255
|
const colorScheme = options.colorScheme;
|
|
256
|
+
// This narrow extension is parsed by `measure snap`; the shared SnapshotContext remains backwards-compatible for every other collector.
|
|
257
|
+
const stateDiffPaddingCssPx = (options as CaptureSnapshotOptions & { stateDiffPaddingCssPx?: number }).stateDiffPaddingCssPx ?? 8;
|
|
254
258
|
// Apply the shared artifact-string cap once at the source. URL contents
|
|
255
259
|
// otherwise survive unchanged in meta.json, the result, and collector context.
|
|
256
260
|
const url = options.url ? sanitizeString(options.url) : null;
|
|
@@ -375,6 +379,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
375
379
|
pixels,
|
|
376
380
|
state,
|
|
377
381
|
unstableRegions,
|
|
382
|
+
...({ stateDiffPaddingCssPx } as {}),
|
|
378
383
|
write: makeWriter(dir, artifacts),
|
|
379
384
|
};
|
|
380
385
|
|
|
@@ -387,25 +392,34 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
387
392
|
|
|
388
393
|
const startedAt = Date.now();
|
|
389
394
|
let requestTimeouts = 0;
|
|
395
|
+
let timeoutError: SnapshotCaptureTimeout | undefined;
|
|
390
396
|
const collectorWriter = makeCollectorWriter(ctx.write);
|
|
391
397
|
try {
|
|
392
|
-
// Await the collector itself, not a wall-clock race around it.
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
// connection collector starts.
|
|
398
|
+
// Await the collector itself, not a wall-clock race around it. The
|
|
399
|
+
// collector owns its cleanup, and timeout handling below decides whether
|
|
400
|
+
// this capture can safely move on to another shared-connection collector.
|
|
396
401
|
await collector.fn({
|
|
397
402
|
...ctx,
|
|
398
|
-
client: boundedClient(client, `collector:${collector.name}`, dir, () => {
|
|
403
|
+
client: boundedClient(client, `collector:${collector.name}`, dir, (error) => {
|
|
404
|
+
requestTimeouts += 1;
|
|
405
|
+
timeoutError ??= error;
|
|
406
|
+
}),
|
|
399
407
|
write: collectorWriter.writer,
|
|
400
408
|
});
|
|
409
|
+
// A styles timeout may be caught per-record by the provenance collector,
|
|
410
|
+
// but its browser operation can still belong to this shared connection.
|
|
411
|
+
// End this capture instead of letting another collector issue commands
|
|
412
|
+
// under that outstanding operation.
|
|
413
|
+
if (collector.name === 'styles' && timeoutError) throw timeoutError;
|
|
401
414
|
const ms = Date.now() - startedAt;
|
|
402
415
|
collectorWriter.commit();
|
|
403
416
|
return requestTimeouts > 0
|
|
404
417
|
? { name: collector.name, status: 'degraded', ms, requestTimeouts }
|
|
405
418
|
: { name: collector.name, status: 'ok', ms };
|
|
406
|
-
} catch {
|
|
419
|
+
} catch (error) {
|
|
407
420
|
const ms = Date.now() - startedAt;
|
|
408
421
|
collectorWriter.discard();
|
|
422
|
+
if (collector.name === 'styles' && error instanceof SnapshotCaptureTimeout) throw error;
|
|
409
423
|
return { name: collector.name, status: 'error', ms, ...(requestTimeouts > 0 ? { requestTimeouts } : {}) };
|
|
410
424
|
}
|
|
411
425
|
};
|