@crouton-kit/capture 1.3.21 → 1.4.1
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 +2880 -1759
- 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/exec.ts +1 -1
- package/src/cdp/commands/page/index.ts +2 -2
- package/src/cdp/commands/page/shot.ts +118 -6
- package/src/cdp/connection.ts +19 -12
- package/src/cdp/detect.ts +61 -20
- 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/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 +3 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -253,6 +253,8 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
253
253
|
const state = options.state ?? [];
|
|
254
254
|
const viewport = options.viewport ?? null;
|
|
255
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;
|
|
256
258
|
// Apply the shared artifact-string cap once at the source. URL contents
|
|
257
259
|
// otherwise survive unchanged in meta.json, the result, and collector context.
|
|
258
260
|
const url = options.url ? sanitizeString(options.url) : null;
|
|
@@ -377,6 +379,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
|
|
|
377
379
|
pixels,
|
|
378
380
|
state,
|
|
379
381
|
unstableRegions,
|
|
382
|
+
...({ stateDiffPaddingCssPx } as {}),
|
|
380
383
|
write: makeWriter(dir, artifacts),
|
|
381
384
|
};
|
|
382
385
|
|
package/src/cdp/screenshot.ts
CHANGED
|
@@ -16,6 +16,16 @@ export interface CapturedRegion {
|
|
|
16
16
|
readonly height: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A CSS-pixel crop request. The capture is intersected with the live visual viewport before it reaches CDP. */
|
|
20
|
+
export interface ScreenshotCrop {
|
|
21
|
+
readonly x: number;
|
|
22
|
+
readonly y: number;
|
|
23
|
+
readonly width: number;
|
|
24
|
+
readonly height: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number };
|
|
28
|
+
|
|
19
29
|
/** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
|
|
20
30
|
function validRegion(region: CapturedRegion): CapturedRegion | undefined {
|
|
21
31
|
const finite = [region.x, region.y, region.width, region.height].every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
@@ -23,6 +33,15 @@ function validRegion(region: CapturedRegion): CapturedRegion | undefined {
|
|
|
23
33
|
return region;
|
|
24
34
|
}
|
|
25
35
|
|
|
36
|
+
/** Intersects page-coordinate CSS regions without rounding their geometry. */
|
|
37
|
+
function intersectRegions(a: CapturedRegion, b: CapturedRegion): CapturedRegion | undefined {
|
|
38
|
+
const x = Math.max(a.x, b.x);
|
|
39
|
+
const y = Math.max(a.y, b.y);
|
|
40
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
41
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
42
|
+
return validRegion({ x, y, width: right - x, height: bottom - y });
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/**
|
|
27
46
|
* Downscales a PNG so its longest side fits within maxDim, using a box
|
|
28
47
|
* filter. Used when a clipped/scaled CDP capture is unavailable and the raw
|
|
@@ -82,7 +101,7 @@ function downscalePngToFit(png: Buffer, maxDim: number): Buffer {
|
|
|
82
101
|
export async function captureScreenshot(
|
|
83
102
|
client: CDPClient,
|
|
84
103
|
viewport?: { width: number; height: number },
|
|
85
|
-
options?:
|
|
104
|
+
options?: ScreenshotOptions,
|
|
86
105
|
): Promise<Buffer> {
|
|
87
106
|
const result = await withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
|
|
88
107
|
viewportScopedCapture(client, viewport, options),
|
|
@@ -100,7 +119,7 @@ export async function captureScreenshot(
|
|
|
100
119
|
export async function captureScreenshotWithCssViewport(
|
|
101
120
|
client: CDPClient,
|
|
102
121
|
viewport?: { width: number; height: number },
|
|
103
|
-
options?:
|
|
122
|
+
options?: ScreenshotOptions,
|
|
104
123
|
): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
|
|
105
124
|
return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
|
|
106
125
|
viewportScopedCapture(client, viewport, options, true),
|
|
@@ -117,7 +136,7 @@ export async function captureScreenshotWithCssViewport(
|
|
|
117
136
|
async function viewportScopedCapture(
|
|
118
137
|
client: CDPClient,
|
|
119
138
|
viewport?: { width: number; height: number },
|
|
120
|
-
options?:
|
|
139
|
+
options?: ScreenshotOptions,
|
|
121
140
|
includeCssViewport = false,
|
|
122
141
|
): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
|
|
123
142
|
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
@@ -185,16 +204,29 @@ async function viewportScopedCapture(
|
|
|
185
204
|
}, 5000)) as { result: { value: number } };
|
|
186
205
|
const dpr = dprResult.result.value ?? 1;
|
|
187
206
|
|
|
188
|
-
const
|
|
189
|
-
const
|
|
207
|
+
const visualViewport = validRegion({ x: sx, y: sy, width: vw, height: vh });
|
|
208
|
+
const requestedCrop = options?.crop;
|
|
209
|
+
const crop = requestedCrop && visualViewport
|
|
210
|
+
? intersectRegions(requestedCrop, visualViewport)
|
|
211
|
+
: visualViewport;
|
|
212
|
+
if (!crop) {
|
|
213
|
+
throw new Error('The requested CSS crop does not intersect the live visual viewport.');
|
|
214
|
+
}
|
|
215
|
+
const zoom = options?.zoom ?? 1;
|
|
216
|
+
const actualMaxSide = Math.max(crop.width, crop.height) * dpr * zoom;
|
|
217
|
+
const scale = actualMaxSide > MAX_DIM ? MAX_DIM / (Math.max(crop.width, crop.height) * dpr) : zoom / dpr;
|
|
190
218
|
screenshotOpts = {
|
|
191
219
|
...screenshotOpts,
|
|
192
|
-
clip: { x:
|
|
220
|
+
clip: { x: crop.x, y: crop.y, width: crop.width, height: crop.height, scale },
|
|
193
221
|
};
|
|
194
|
-
// The clip is what the image is cut from,
|
|
195
|
-
// fractional viewport it was derived from.
|
|
196
|
-
capturedRegion =
|
|
197
|
-
} catch {
|
|
222
|
+
// The clip is what the image is cut from, including a CSS crop's
|
|
223
|
+
// viewport intersection — not the fractional viewport it was derived from.
|
|
224
|
+
capturedRegion = crop;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// A crop without CSS viewport metrics cannot be located honestly; unlike
|
|
227
|
+
// an ordinary full-viewport shot, it must not silently become an
|
|
228
|
+
// unrelated uncropped image.
|
|
229
|
+
if (options?.crop) throw error;
|
|
198
230
|
// Capture without downscaling when the optional metrics probe is unavailable.
|
|
199
231
|
}
|
|
200
232
|
|
|
@@ -221,7 +253,7 @@ async function viewportScopedCapture(
|
|
|
221
253
|
|
|
222
254
|
// If the browser rejects clipped capture entirely, an unclipped viewport
|
|
223
255
|
// capture is still better than a false successful 0-byte artifact.
|
|
224
|
-
if (png.length === 0 && screenshotOpts.clip && !options?.fullPage) {
|
|
256
|
+
if (png.length === 0 && screenshotOpts.clip && !options?.fullPage && !options?.crop) {
|
|
225
257
|
const retry = (await client.send('Page.captureScreenshot', {
|
|
226
258
|
format: 'png',
|
|
227
259
|
captureBeyondViewport: false,
|
package/src/cdp/targets.ts
CHANGED
|
@@ -122,6 +122,30 @@ export async function findTabByIdInPorts(
|
|
|
122
122
|
return null;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export class AmbiguousPageTargetError extends Error {
|
|
126
|
+
constructor(candidates: readonly { readonly port: number; readonly tab: CDPTarget }[]) {
|
|
127
|
+
super(`More than one page tab is available:\n${candidates.map(({ port, tab }) => ` ${tab.id.slice(0, 8)} port ${port} ${tab.url}`).join('\n')}\nUse --target <tabId> or --url <pattern> to target a tab.`);
|
|
128
|
+
this.name = 'AmbiguousPageTargetError';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Resolves an omitted target only when all selected endpoints expose one page tab. */
|
|
133
|
+
export async function findUnambiguousPageTabInPorts(
|
|
134
|
+
ports: readonly number[],
|
|
135
|
+
lister: (port: number) => Promise<CDPTarget[]> = listTargets,
|
|
136
|
+
): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
137
|
+
const candidates: Array<{ port: number; tab: CDPTarget }> = [];
|
|
138
|
+
for (const port of ports) {
|
|
139
|
+
try {
|
|
140
|
+
for (const tab of await lister(port)) if (tab.type === 'page') candidates.push({ port, tab });
|
|
141
|
+
} catch {
|
|
142
|
+
// An unavailable endpoint cannot supply a target.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
|
|
146
|
+
return candidates[0] ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
125
149
|
function normalizeUrlForMatch(value: string): { full: string; host: string; path: string } | null {
|
|
126
150
|
try {
|
|
127
151
|
const url = new URL(value);
|
package/src/cdp/types.ts
CHANGED
|
@@ -78,6 +78,21 @@ export interface ParsedArgs {
|
|
|
78
78
|
pixels?: boolean;
|
|
79
79
|
/** `snap --state <state[:selector]>` — repeatable; one entry per occurrence. */
|
|
80
80
|
state?: string[];
|
|
81
|
+
/** `snap --state-padding <px>` — CSS-px padding around the state target when
|
|
82
|
+
* cropping its rendered/diff artifact. */
|
|
83
|
+
statePadding?: number;
|
|
84
|
+
/** `snap --list-crops` — print every crop path instead of the count and dir. */
|
|
85
|
+
listCrops?: boolean;
|
|
86
|
+
/** `shot --crop <x,y,w,h>` — crop region in CSS px; composite grammar is
|
|
87
|
+
* validated by the owning leaf, which documents it. */
|
|
88
|
+
crop?: string;
|
|
89
|
+
/** `shot --crop-selector <selector>` — crop to this element's live box. */
|
|
90
|
+
cropSelector?: string;
|
|
91
|
+
/** `shot --pad <px>` — CSS-px padding added around a resolved crop rect. */
|
|
92
|
+
pad?: number;
|
|
93
|
+
/** `shot --zoom <factor>` — scale multiplier for the captured crop;
|
|
94
|
+
* fractional grammar is validated by the owning leaf, which documents it. */
|
|
95
|
+
zoom?: string;
|
|
81
96
|
/** `check --for <list>` — comma-separated check names or categories. */
|
|
82
97
|
for?: string;
|
|
83
98
|
/** `diff --before <snap>` */
|
package/src/contracts/index.ts
CHANGED