@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
|
@@ -19,11 +19,13 @@ import * as fs from 'fs';
|
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
import { type ParsedArgs } from '../../types.js';
|
|
21
21
|
import { withConnection } from '../../connection.js';
|
|
22
|
-
import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
|
|
22
|
+
import { captureScreenshotWithCssViewport, type CapturedRegion, type ScreenshotCrop } from '../../screenshot.js';
|
|
23
23
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
24
24
|
import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
|
|
25
25
|
import { nextStepPath } from '../../../session-context.js';
|
|
26
26
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
27
|
+
import { resolveLiveTarget, type LiveClient, type ResolutionFailure } from '../../../interact.js';
|
|
28
|
+
import { emitResolutionError } from './click.js';
|
|
27
29
|
import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
|
|
28
30
|
import {
|
|
29
31
|
emitResult,
|
|
@@ -39,13 +41,17 @@ input:
|
|
|
39
41
|
--viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
|
|
40
42
|
--full-page transient override to the full scrollable content height for this capture
|
|
41
43
|
--color-scheme <dark|light> transient prefers-color-scheme override for this capture; cleared afterward
|
|
44
|
+
--crop <x,y,w,h> capture this page-coordinate CSS-pixel rect, intersected with the live visual viewport; x/y may be fractional, w/h must be positive
|
|
45
|
+
--crop-selector <sel> resolve exactly one live target (bare CSS, ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
|
|
46
|
+
--pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
|
|
47
|
+
--zoom <factor> positive decimal CSS-to-image scale requested for a CSS crop; the 1600px image cap may produce a smaller reported scale
|
|
42
48
|
--out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
|
|
43
49
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
44
50
|
--json mirror the result as JSON
|
|
45
51
|
output:
|
|
46
|
-
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and any requested transient emulation
|
|
52
|
+
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, crop provenance, and any requested transient emulation
|
|
47
53
|
effects:
|
|
48
|
-
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture.`;
|
|
54
|
+
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; an empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
|
|
49
55
|
|
|
50
56
|
// ---------------------------------------------------------------------------
|
|
51
57
|
// Test-injectable dependency seam (the CDP-stub test pattern; the capture
|
|
@@ -119,6 +125,15 @@ function writeScreenshot(outPath: string, png: Buffer): void {
|
|
|
119
125
|
|
|
120
126
|
export type EmulationMode = 'none' | 'viewport' | 'full-page';
|
|
121
127
|
|
|
128
|
+
interface CropProvenance {
|
|
129
|
+
readonly source: 'coordinates' | 'selector';
|
|
130
|
+
readonly requested: ScreenshotCrop;
|
|
131
|
+
readonly selector?: string;
|
|
132
|
+
readonly backendNodeId?: number;
|
|
133
|
+
readonly pad?: number;
|
|
134
|
+
readonly zoom: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
/** Renders a CSS-pixel measurement without inventing precision: an integer stays an integer, a fractional zoomed viewport keeps six decimals. */
|
|
123
138
|
function round6(value: number): number {
|
|
124
139
|
return Math.round(value * 1e6) / 1e6;
|
|
@@ -132,6 +147,7 @@ export function buildScreenshotResult(f: {
|
|
|
132
147
|
emulation: EmulationMode;
|
|
133
148
|
viewport?: { width: number; height: number };
|
|
134
149
|
colorScheme?: ColorScheme;
|
|
150
|
+
crop?: CropProvenance;
|
|
135
151
|
}): RenderableResult {
|
|
136
152
|
// The denominator is the CSS region the image was actually cut from, so the
|
|
137
153
|
// ratio stays true under page zoom, device pixel ratio, and the local
|
|
@@ -143,6 +159,9 @@ export function buildScreenshotResult(f: {
|
|
|
143
159
|
scale && f.cssViewport
|
|
144
160
|
? fact`CSS-to-image scale: ${scale.x.toFixed(6)} image px/CSS px horizontally and ${scale.y.toFixed(6)} image px/CSS px vertically (captured CSS region ${round6(f.cssViewport.width)}×${round6(f.cssViewport.height)} at page origin ${round6(f.cssViewport.x)},${round6(f.cssViewport.y)}).`
|
|
145
161
|
: text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
|
|
162
|
+
f.crop
|
|
163
|
+
? fact`CSS crop: ${f.crop.source === 'selector' ? `selector ${f.crop.selector} (backend:${f.crop.backendNodeId}), border box plus ${f.crop.pad}px padding` : 'coordinates'} requested ${formatRect(f.crop.requested)} at zoom ${f.crop.zoom}; the captured CSS region above is its intersection with the source visual viewport.`
|
|
164
|
+
: text`CSS crop: none — the captured CSS region above is the source visual viewport.`,
|
|
146
165
|
f.emulation === 'none'
|
|
147
166
|
? text`viewport emulation: none — the browser's actual current viewport was captured.`
|
|
148
167
|
: f.emulation === 'viewport'
|
|
@@ -168,6 +187,10 @@ export function buildScreenshotResult(f: {
|
|
|
168
187
|
'css-to-image-y': scale?.y.toFixed(6),
|
|
169
188
|
emulation: f.emulation,
|
|
170
189
|
'color-scheme': f.colorScheme,
|
|
190
|
+
'crop-source': f.crop?.source,
|
|
191
|
+
'crop-selector': f.crop?.selector,
|
|
192
|
+
'crop-backend-node-id': f.crop?.backendNodeId,
|
|
193
|
+
'requested-zoom': f.crop?.zoom,
|
|
171
194
|
},
|
|
172
195
|
summary: f.dimensions
|
|
173
196
|
? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
|
|
@@ -176,6 +199,38 @@ export function buildScreenshotResult(f: {
|
|
|
176
199
|
};
|
|
177
200
|
}
|
|
178
201
|
|
|
202
|
+
function formatRect(rect: ScreenshotCrop): string {
|
|
203
|
+
return `x=${round6(rect.x)} y=${round6(rect.y)} w=${round6(rect.width)} h=${round6(rect.height)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseCrop(value: string): ScreenshotCrop | undefined {
|
|
207
|
+
const match = /^(-?(?:\d+\.?\d*|\.\d+)),(-?(?:\d+\.?\d*|\.\d+)),(\d+(?:\.\d*)?|\.\d+),(\d+(?:\.\d*)?|\.\d+)$/.exec(value);
|
|
208
|
+
if (!match) return undefined;
|
|
209
|
+
const [x, y, width, height] = match.slice(1).map(Number);
|
|
210
|
+
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return undefined;
|
|
211
|
+
return { x, y, width, height };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseZoom(value: string): number | undefined {
|
|
215
|
+
if (!/^(?:\d+\.?\d*|\.\d+)$/.test(value)) return undefined;
|
|
216
|
+
const zoom = Number(value);
|
|
217
|
+
return Number.isFinite(zoom) && zoom > 0 ? zoom : undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function borderRect(model: unknown): ScreenshotCrop | undefined {
|
|
221
|
+
const border = (model as { border?: unknown } | undefined)?.border;
|
|
222
|
+
if (!Array.isArray(border) || border.length !== 8 || !border.every((point) => typeof point === 'number' && Number.isFinite(point))) return undefined;
|
|
223
|
+
const xs = [border[0], border[2], border[4], border[6]] as number[];
|
|
224
|
+
const ys = [border[1], border[3], border[5], border[7]] as number[];
|
|
225
|
+
const x = Math.min(...xs);
|
|
226
|
+
const y = Math.min(...ys);
|
|
227
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function padded(rect: ScreenshotCrop, pad: number): ScreenshotCrop {
|
|
231
|
+
return { x: rect.x - pad, y: rect.y - pad, width: rect.width + 2 * pad, height: rect.height + 2 * pad };
|
|
232
|
+
}
|
|
233
|
+
|
|
179
234
|
// ---------------------------------------------------------------------------
|
|
180
235
|
// page shot
|
|
181
236
|
// ---------------------------------------------------------------------------
|
|
@@ -191,7 +246,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
191
246
|
{
|
|
192
247
|
tag: 'error',
|
|
193
248
|
attrs: { command: 'page shot', code: 'invalid_input' },
|
|
194
|
-
summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]\`.`,
|
|
249
|
+
summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--crop <x,y,w,h> | --crop-selector <sel> [--pad <px>]] [--zoom <factor>] [--out <path>]\`.`,
|
|
195
250
|
},
|
|
196
251
|
{ json: parsed.json },
|
|
197
252
|
);
|
|
@@ -199,6 +254,44 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
199
254
|
return;
|
|
200
255
|
}
|
|
201
256
|
|
|
257
|
+
const crop = parsed.crop === undefined ? undefined : parseCrop(parsed.crop);
|
|
258
|
+
if (parsed.crop !== undefined && !crop) {
|
|
259
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop' }, summary: fact`received: --crop ${parsed.crop}; expected x,y,w,h as finite CSS pixels without whitespace, with positive w and h.` }, { json: parsed.json });
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (parsed.crop !== undefined && parsed.cropSelector !== undefined) {
|
|
264
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'conflicting_crop' }, summary: text`--crop and --crop-selector cannot be combined; choose coordinates or one live target.` }, { json: parsed.json });
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (parsed.cropSelector !== undefined && !parsed.cropSelector.trim()) {
|
|
269
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_crop_selector' }, summary: text`received an empty --crop-selector; expected a bare CSS selector, ax:<name>, axid:<id>, or backend:<id>.` }, { json: parsed.json });
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (parsed.pad !== undefined && parsed.cropSelector === undefined) {
|
|
274
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'pad_requires_crop_selector' }, summary: text`--pad applies only to --crop-selector.` }, { json: parsed.json });
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const zoom = parsed.zoom === undefined ? 1 : parseZoom(parsed.zoom);
|
|
279
|
+
if (!zoom) {
|
|
280
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'invalid_zoom' }, summary: fact`received: --zoom ${parsed.zoom}; expected a positive decimal factor.` }, { json: parsed.json });
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (parsed.zoom !== undefined && !crop && parsed.cropSelector === undefined) {
|
|
285
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'zoom_requires_crop' }, summary: text`--zoom requires --crop or --crop-selector.` }, { json: parsed.json });
|
|
286
|
+
process.exitCode = 1;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (parsed.fullPage && (crop || parsed.cropSelector !== undefined)) {
|
|
290
|
+
emitResult({ tag: 'error', attrs: { command: 'page shot', code: 'crop_conflicts_with_full_page' }, summary: text`--crop and --crop-selector capture from the visual viewport and cannot be combined with --full-page.` }, { json: parsed.json });
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
202
295
|
let viewport: Viewport | undefined;
|
|
203
296
|
if (parsed.viewport !== undefined) {
|
|
204
297
|
try {
|
|
@@ -241,13 +334,32 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
241
334
|
const result = await deps.withConnection(
|
|
242
335
|
{ ...parsed, command: 'shot' },
|
|
243
336
|
async (client) => withAppliedColorScheme(client, colorScheme, async () => {
|
|
244
|
-
|
|
337
|
+
let cropProvenance: CropProvenance | undefined;
|
|
338
|
+
let requestedCrop = crop;
|
|
339
|
+
if (parsed.cropSelector !== undefined) {
|
|
340
|
+
const resolved = await resolveLiveTarget(client as unknown as LiveClient, parsed.cropSelector);
|
|
341
|
+
if (!resolved.ok) return { failure: resolved } as const;
|
|
342
|
+
await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: resolved.backendNodeId });
|
|
343
|
+
const box = (await client.send('DOM.getBoxModel', { backendNodeId: resolved.backendNodeId })) as { model?: unknown };
|
|
344
|
+
const rect = borderRect(box.model);
|
|
345
|
+
if (!rect) throw new Error(`Resolved target backend:${resolved.backendNodeId} has no measurable border box for --crop-selector.`);
|
|
346
|
+
const pad = parsed.pad ?? 0;
|
|
347
|
+
requestedCrop = padded(rect, pad);
|
|
348
|
+
cropProvenance = { source: 'selector', requested: requestedCrop, selector: parsed.cropSelector, backendNodeId: resolved.backendNodeId, pad, zoom };
|
|
349
|
+
} else if (requestedCrop) {
|
|
350
|
+
cropProvenance = { source: 'coordinates', requested: requestedCrop, zoom };
|
|
351
|
+
}
|
|
352
|
+
const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
|
|
245
353
|
const outPath = await resolveOutPath(parsed);
|
|
246
354
|
writeScreenshot(outPath, screenshot.png);
|
|
247
|
-
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
|
|
355
|
+
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, crop: cropProvenance } as const;
|
|
248
356
|
}),
|
|
249
357
|
{ settle: 0 },
|
|
250
358
|
);
|
|
251
359
|
|
|
360
|
+
if ('failure' in result) {
|
|
361
|
+
emitResolutionError(parsed, 'page shot', result.failure as ResolutionFailure);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
252
364
|
emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
|
|
253
365
|
}
|
package/src/cdp/connection.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import { performance } from 'node:perf_hooks';
|
|
3
3
|
import { CDPClient } from './client.js';
|
|
4
|
-
import {
|
|
4
|
+
import { detectCdpPortsAsync } from './detect.js';
|
|
5
|
+
import { AmbiguousPageTargetError, findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints, findUnambiguousPageTabInPorts } from './targets.js';
|
|
5
6
|
import { type CDPTarget, type ParsedArgs } from './types.js';
|
|
6
7
|
import { ConsoleRecorder, printConsoleSummary } from './console-recorder.js';
|
|
7
8
|
import { HARRecorder } from './har-recorder.js';
|
|
@@ -13,7 +14,7 @@ import { getActiveSession, updateActiveSession, type ActiveSessionState } from '
|
|
|
13
14
|
import { withSessionScopeLifecycle } from '../session/coordinator.js';
|
|
14
15
|
import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
|
|
15
16
|
import { recDirFor, readRecorderJson } from './motion/recorder.js';
|
|
16
|
-
import { captureError
|
|
17
|
+
import { captureError } from '../errors.js';
|
|
17
18
|
|
|
18
19
|
function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
|
|
19
20
|
if (!url) return null;
|
|
@@ -51,10 +52,10 @@ export interface ConnectionSeams {
|
|
|
51
52
|
sleep: (ms: number) => Promise<void>;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
55
|
-
return parsed.target
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
async function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
56
|
+
if (parsed.target) return findTabByIdAcrossEndpoints(parsed.target, parsed.port);
|
|
57
|
+
if (parsed.url) return findTabByUrlAcrossEndpoints(parsed.url, parsed.port);
|
|
58
|
+
return findUnambiguousPageTabInPorts(parsed.port === undefined ? (await detectCdpPortsAsync()).map((endpoint) => endpoint.port) : [parsed.port]);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
let seams: ConnectionSeams = {
|
|
@@ -210,11 +211,15 @@ export async function connectForCommand(
|
|
|
210
211
|
return routed;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
let resolved: { port: number; tab: CDPTarget } | null;
|
|
215
|
+
try {
|
|
216
|
+
resolved = await seams.resolveTab(parsed);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof AmbiguousPageTargetError) {
|
|
219
|
+
throw captureError('precondition', 'target_unavailable', error.message, error);
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
215
222
|
}
|
|
216
|
-
|
|
217
|
-
const resolved = await seams.resolveTab(parsed);
|
|
218
223
|
const tab = resolved?.tab ?? null;
|
|
219
224
|
|
|
220
225
|
if (!tab) {
|
|
@@ -222,7 +227,9 @@ export async function connectForCommand(
|
|
|
222
227
|
throw captureError(
|
|
223
228
|
'precondition',
|
|
224
229
|
'target_unavailable',
|
|
225
|
-
|
|
230
|
+
query === undefined
|
|
231
|
+
? 'No page tab found. Run "capture tab list" to see available tabs.'
|
|
232
|
+
: `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture tab list" to see available tabs.`,
|
|
226
233
|
);
|
|
227
234
|
}
|
|
228
235
|
|
|
@@ -320,7 +327,7 @@ async function finishLocalHar(parsed: ParsedArgs, collectors: Collectors): Promi
|
|
|
320
327
|
if (batch.entries.length > 0 || batch.incompleteLifecycles.length > 0) {
|
|
321
328
|
await seams.appendHar(parsed.har, batch);
|
|
322
329
|
console.error(
|
|
323
|
-
` [har:${parsed.har}] +${batch.entries.length} entries +${batch.incompleteLifecycles.length} incomplete`,
|
|
330
|
+
` [har:${parsed.har}] +${batch.entries.length} entries +${batch.incompleteLifecycles.length} incomplete — incomplete lifecycles are retained as their own records (request plus whatever response was observed), separate from log.entries; read them with capture session har`,
|
|
324
331
|
);
|
|
325
332
|
}
|
|
326
333
|
}
|
package/src/cdp/detect.ts
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A CDP-endpoint JSON probe that CANNOT leak a socket handle. Node's built-in
|
|
6
|
+
* `fetch` (undici) leaves a dangling socket + request handle when a
|
|
7
|
+
* connect-phase abort fires against a port that accepts TCP but never sends an
|
|
8
|
+
* HTTP response (e.g. a docker-proxied or half-open listener). That handle
|
|
9
|
+
* keeps this short-lived CLI process alive for undici's ~7s internal socket
|
|
10
|
+
* timeout AFTER all real work is done, so a single dead port on the machine
|
|
11
|
+
* adds ~7s to every command that runs port detection. A raw `http.get` whose
|
|
12
|
+
* request we explicitly `destroy()` on timeout releases the handle immediately.
|
|
13
|
+
*
|
|
14
|
+
* Resolves to the parsed JSON body on a 2xx response, or `null` on any
|
|
15
|
+
* timeout, connection error, non-2xx status, or unparseable body.
|
|
16
|
+
*/
|
|
17
|
+
function httpGetJson(
|
|
18
|
+
port: number,
|
|
19
|
+
path: string,
|
|
20
|
+
timeoutMs: number,
|
|
21
|
+
): Promise<unknown | null> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const req = http.get(
|
|
24
|
+
{ host: 'localhost', port, path, timeout: timeoutMs },
|
|
25
|
+
(res) => {
|
|
26
|
+
const status = res.statusCode ?? 0;
|
|
27
|
+
if (status < 200 || status >= 300) {
|
|
28
|
+
res.resume();
|
|
29
|
+
resolve(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let body = '';
|
|
33
|
+
res.setEncoding('utf-8');
|
|
34
|
+
res.on('data', (chunk) => {
|
|
35
|
+
body += chunk;
|
|
36
|
+
});
|
|
37
|
+
res.on('end', () => {
|
|
38
|
+
try {
|
|
39
|
+
resolve(JSON.parse(body));
|
|
40
|
+
} catch {
|
|
41
|
+
resolve(null);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
res.on('error', () => resolve(null));
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
req.on('timeout', () => {
|
|
48
|
+
req.destroy();
|
|
49
|
+
resolve(null);
|
|
50
|
+
});
|
|
51
|
+
req.on('error', () => resolve(null));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
2
54
|
|
|
3
55
|
export const BROWSER_PATTERNS: Record<string, string[]> = {
|
|
4
56
|
'company.thebrowser.browser': ['arc'],
|
|
@@ -40,18 +92,12 @@ interface CdpProbeResult {
|
|
|
40
92
|
// being confused for the intended browser.
|
|
41
93
|
async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
|
|
42
94
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (resp.ok) {
|
|
50
|
-
const version = (await resp.json()) as {
|
|
51
|
-
Browser?: string;
|
|
52
|
-
'User-Agent'?: string;
|
|
53
|
-
webSocketDebuggerUrl?: string;
|
|
54
|
-
};
|
|
95
|
+
const version = (await httpGetJson(port, '/json/version', 500)) as {
|
|
96
|
+
Browser?: string;
|
|
97
|
+
'User-Agent'?: string;
|
|
98
|
+
webSocketDebuggerUrl?: string;
|
|
99
|
+
} | null;
|
|
100
|
+
if (version) {
|
|
55
101
|
const browser = version.Browser;
|
|
56
102
|
const userAgent = version['User-Agent'] ?? '';
|
|
57
103
|
if (!browser) return null;
|
|
@@ -83,14 +129,9 @@ async function probeCdpPort(port: number): Promise<CdpProbeResult | null> {
|
|
|
83
129
|
|
|
84
130
|
async function probeHasPageTarget(port: number): Promise<boolean> {
|
|
85
131
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
signal: controller.signal,
|
|
90
|
-
});
|
|
91
|
-
clearTimeout(timeout);
|
|
92
|
-
if (!resp.ok) return false;
|
|
93
|
-
const list = (await resp.json()) as Array<{ type?: string }>;
|
|
132
|
+
const list = (await httpGetJson(port, '/json/list', 500)) as Array<{
|
|
133
|
+
type?: string;
|
|
134
|
+
}> | null;
|
|
94
135
|
return Array.isArray(list) && list.some((t) => t.type === 'page');
|
|
95
136
|
} catch {
|
|
96
137
|
return false;
|
|
@@ -284,6 +284,7 @@ function buildGeometryWalkScript(maxElements: number): string {
|
|
|
284
284
|
idx: idx,
|
|
285
285
|
tag: tag.toLowerCase(),
|
|
286
286
|
selector: __selectorOf(el),
|
|
287
|
+
attributes: Array.prototype.map.call(el.attributes, function (attr) { return [attr.name, attr.value]; }),
|
|
287
288
|
domPath: __domPathOf(el),
|
|
288
289
|
text: directText(el),
|
|
289
290
|
frame: { frameId: frameId, isTopFrame: isTop, ancestorFrameIds: ancestorFrameIds.slice() },
|
|
@@ -292,6 +293,16 @@ function buildGeometryWalkScript(maxElements: number): string {
|
|
|
292
293
|
stackingContext: { creates: reasons.length > 0, reasons: reasons },
|
|
293
294
|
visibility: { visible: visible, opacity: opacity, displayNone: displayNone, visibilityHidden: visibilityHidden, zeroSize: zeroSize },
|
|
294
295
|
clipping: clippingInfo(el, rect),
|
|
296
|
+
paint: {
|
|
297
|
+
'box-shadow': style.boxShadow,
|
|
298
|
+
'outline-width': style.outlineWidth,
|
|
299
|
+
'outline-style': style.outlineStyle,
|
|
300
|
+
'outline-offset': style.outlineOffset,
|
|
301
|
+
filter: style.filter,
|
|
302
|
+
transform: style.transform,
|
|
303
|
+
'clip-path': style.clipPath,
|
|
304
|
+
'border-radius': style.borderRadius,
|
|
305
|
+
},
|
|
295
306
|
layout: {
|
|
296
307
|
boxSizing: style.boxSizing,
|
|
297
308
|
position: style.position,
|
|
@@ -342,14 +353,11 @@ function buildGeometryWalkScript(maxElements: number): string {
|
|
|
342
353
|
pushedFact.iframeContentUnavailable = true;
|
|
343
354
|
pushedFact.iframeContentUnavailableReason = 'content-document-read-threw';
|
|
344
355
|
}
|
|
345
|
-
if (innerDoc && innerDoc.
|
|
356
|
+
if (innerDoc && innerDoc.documentElement) {
|
|
346
357
|
frameCounter += 1;
|
|
347
358
|
var childFrameId = 'frame-' + frameCounter;
|
|
348
359
|
var childAncestors = ancestorFrameIds.concat([frameId]);
|
|
349
|
-
|
|
350
|
-
for (var ii = 0; ii < innerKids.length; ii++) {
|
|
351
|
-
walk(innerKids[ii], childFrameId, false, childAncestors, null);
|
|
352
|
-
}
|
|
360
|
+
walk(innerDoc.documentElement, childFrameId, false, childAncestors, null);
|
|
353
361
|
}
|
|
354
362
|
return;
|
|
355
363
|
}
|
|
@@ -360,10 +368,7 @@ function buildGeometryWalkScript(maxElements: number): string {
|
|
|
360
368
|
}
|
|
361
369
|
}
|
|
362
370
|
|
|
363
|
-
|
|
364
|
-
for (var bi = 0; bi < bodyKids.length; bi++) {
|
|
365
|
-
walk(bodyKids[bi], 'frame-0', true, [], null);
|
|
366
|
-
}
|
|
371
|
+
if (document.documentElement) walk(document.documentElement, 'frame-0', true, [], null);
|
|
367
372
|
|
|
368
373
|
return { facts: facts, elements: elements, meta: { elementsTruncated: elementsOverflowCount } };
|
|
369
374
|
})()`;
|
|
@@ -569,6 +574,17 @@ interface RawGridFact {
|
|
|
569
574
|
};
|
|
570
575
|
}
|
|
571
576
|
|
|
577
|
+
interface RawPaintFact {
|
|
578
|
+
readonly 'box-shadow': string;
|
|
579
|
+
readonly 'outline-width': string;
|
|
580
|
+
readonly 'outline-style': string;
|
|
581
|
+
readonly 'outline-offset': string;
|
|
582
|
+
readonly filter: string;
|
|
583
|
+
readonly transform: string;
|
|
584
|
+
readonly 'clip-path': string;
|
|
585
|
+
readonly 'border-radius': string;
|
|
586
|
+
}
|
|
587
|
+
|
|
572
588
|
interface RawLayoutFact {
|
|
573
589
|
readonly boxSizing: string;
|
|
574
590
|
readonly position: string;
|
|
@@ -609,6 +625,7 @@ interface RawGeometryFact {
|
|
|
609
625
|
readonly idx: number;
|
|
610
626
|
readonly tag: string;
|
|
611
627
|
readonly selector?: string;
|
|
628
|
+
readonly attributes?: readonly (readonly [string, string])[];
|
|
612
629
|
readonly domPath: string;
|
|
613
630
|
readonly text?: string;
|
|
614
631
|
readonly frame: RawFrameFact;
|
|
@@ -617,6 +634,8 @@ interface RawGeometryFact {
|
|
|
617
634
|
readonly stackingContext: RawStackingContextFact;
|
|
618
635
|
readonly visibility: RawVisibilityFact;
|
|
619
636
|
readonly clipping: RawClippingFact | null;
|
|
637
|
+
/** Ink-relevant computed styles retained here rather than styles.json so every retained geometry element is queryable, including shadow/iframe records and records beyond styles.json's 150-element cap. */
|
|
638
|
+
readonly paint: RawPaintFact;
|
|
620
639
|
readonly layout: RawLayoutFact;
|
|
621
640
|
/** Present only on an `<iframe>` element's own fact when its `contentDocument` read threw (G6) -- see {@link GeometryElementRecord.iframeContentUnavailable}. */
|
|
622
641
|
readonly iframeContentUnavailable?: boolean;
|
|
@@ -639,6 +658,10 @@ export interface GeometryBoxModel {
|
|
|
639
658
|
export interface GeometryElementRecord extends Omit<ElementRecord, 'backendNodeId'> {
|
|
640
659
|
readonly id: string;
|
|
641
660
|
readonly tag: string;
|
|
661
|
+
/** The element's own HTML attributes, capped by {@link capAttributes}. */
|
|
662
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
663
|
+
/** Number of own attributes omitted by the per-element cap, when nonzero. */
|
|
664
|
+
readonly attributesTruncated?: number;
|
|
642
665
|
readonly domPath: string;
|
|
643
666
|
readonly frame: RawFrameFact;
|
|
644
667
|
readonly shadow: RawShadowFact | null;
|
|
@@ -649,6 +672,8 @@ export interface GeometryElementRecord extends Omit<ElementRecord, 'backendNodeI
|
|
|
649
672
|
readonly stackingContext: RawStackingContextFact;
|
|
650
673
|
readonly visibility: RawVisibilityFact;
|
|
651
674
|
readonly clipping: RawClippingFact | null;
|
|
675
|
+
/** Uncapped, per-geometry-record computed styles used for ink extents and clip-source description. Absent in snapshots captured before ink evidence existed. */
|
|
676
|
+
readonly paint?: RawPaintFact;
|
|
652
677
|
readonly layout: LayoutFact;
|
|
653
678
|
/** `null` (never an omitted key) when this record's identity did not resolve — see {@link identityUnresolved}. */
|
|
654
679
|
readonly backendNodeId: number | null;
|
|
@@ -723,9 +748,24 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
|
|
|
723
748
|
|
|
724
749
|
/** Cap for ordinary computed-CSS-value strings (position/display/min-max sizes/flex+grid provenance). */
|
|
725
750
|
const CSS_VALUE_MAX = 200;
|
|
751
|
+
/** Cap on how many HTML attributes are retained for one geometry element. */
|
|
752
|
+
const MAX_ATTRIBUTES = 32;
|
|
753
|
+
/** Cap on attribute names; values use the ordinary CSS/page-string cap. */
|
|
754
|
+
const ATTRIBUTE_NAME_MAX = 100;
|
|
726
755
|
/** Cap on how many grid template-track strings are kept per axis. */
|
|
727
756
|
const MAX_GRID_TRACKS = 64;
|
|
728
757
|
|
|
758
|
+
function capAttributes(attributes: readonly (readonly [string, string])[]): { readonly values: Readonly<Record<string, string>>; readonly truncated: number } {
|
|
759
|
+
const capped = capArray(attributes, MAX_ATTRIBUTES);
|
|
760
|
+
return {
|
|
761
|
+
values: Object.fromEntries(capped.items.map(([name, value]) => [
|
|
762
|
+
sanitizeString(name, { max: ATTRIBUTE_NAME_MAX }),
|
|
763
|
+
sanitizeString(value, { max: CSS_VALUE_MAX }),
|
|
764
|
+
])),
|
|
765
|
+
truncated: capped.truncated,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
729
769
|
function capTrackList(values: readonly string[]): { items: string[]; truncated: number } {
|
|
730
770
|
const { items, truncated } = capArray(values, MAX_GRID_TRACKS);
|
|
731
771
|
return { items: items.map((v) => sanitizeString(v, { max: CSS_VALUE_MAX })), truncated };
|
|
@@ -976,6 +1016,7 @@ export const collectGeometry: Collector = async (ctx) => {
|
|
|
976
1016
|
}
|
|
977
1017
|
|
|
978
1018
|
const geometryUnavailable = geometryUnavailableReason !== undefined;
|
|
1019
|
+
const capturedAttributes = capAttributes(fact.attributes ?? []);
|
|
979
1020
|
const rect = quads.length > 0 ? unionRect(quads) : { x: 0, y: 0, width: 0, height: 0 };
|
|
980
1021
|
// Only a REAL (non-throwing) quads read can strengthen zeroSize/visible
|
|
981
1022
|
// beyond the JS-side facts -- when the quads read itself never proved
|
|
@@ -988,6 +1029,8 @@ export const collectGeometry: Collector = async (ctx) => {
|
|
|
988
1029
|
id: `el-${fact.idx}`,
|
|
989
1030
|
tag: sanitizeString(fact.tag, { max: 64 }),
|
|
990
1031
|
selector: fact.selector !== undefined ? sanitizeString(fact.selector, { max: 300 }) : undefined,
|
|
1032
|
+
attributes: capturedAttributes.values,
|
|
1033
|
+
...(capturedAttributes.truncated ? { attributesTruncated: capturedAttributes.truncated } : {}),
|
|
991
1034
|
domPath: sanitizeString(fact.domPath, { max: 500 }),
|
|
992
1035
|
...resolvedIdentity(backendNodeId),
|
|
993
1036
|
text: fact.text !== undefined ? sanitizeString(fact.text, { max: 200 }) : undefined,
|
|
@@ -1012,6 +1055,20 @@ export const collectGeometry: Collector = async (ctx) => {
|
|
|
1012
1055
|
clippedFraction: fact.clipping.clippedFraction,
|
|
1013
1056
|
}
|
|
1014
1057
|
: null,
|
|
1058
|
+
...(fact.paint
|
|
1059
|
+
? {
|
|
1060
|
+
paint: {
|
|
1061
|
+
'box-shadow': sanitizeString(fact.paint['box-shadow']),
|
|
1062
|
+
'outline-width': sanitizeString(fact.paint['outline-width']),
|
|
1063
|
+
'outline-style': sanitizeString(fact.paint['outline-style']),
|
|
1064
|
+
'outline-offset': sanitizeString(fact.paint['outline-offset']),
|
|
1065
|
+
filter: sanitizeString(fact.paint.filter),
|
|
1066
|
+
transform: sanitizeString(fact.paint.transform),
|
|
1067
|
+
'clip-path': sanitizeString(fact.paint['clip-path']),
|
|
1068
|
+
'border-radius': sanitizeString(fact.paint['border-radius']),
|
|
1069
|
+
},
|
|
1070
|
+
}
|
|
1071
|
+
: {}),
|
|
1015
1072
|
layout: capLayoutFact(fact.layout),
|
|
1016
1073
|
...(geometryUnavailable ? { geometryUnavailable: true as const, geometryUnavailableReason } : {}),
|
|
1017
1074
|
...(fact.iframeContentUnavailable
|
|
@@ -272,6 +272,7 @@ export const collectPixels: Collector = async (ctx) => {
|
|
|
272
272
|
ctx.write.json('pixels.json', {
|
|
273
273
|
scope: PIXELS_SCOPE,
|
|
274
274
|
elements: [],
|
|
275
|
+
cropsByBackend: {},
|
|
275
276
|
backgroundOverrideRestored,
|
|
276
277
|
captureFailed: true,
|
|
277
278
|
elementsTotal: 0,
|
|
@@ -311,6 +312,10 @@ export const collectPixels: Collector = async (ctx) => {
|
|
|
311
312
|
const described = await Promise.all(nodeIds.map((nodeId) => describeElementForCrop(client, nodeId)));
|
|
312
313
|
|
|
313
314
|
const elements: PixelElementRecord[] = [];
|
|
315
|
+
// Direct lookup from a live/snapshot backend id to its existing crop path.
|
|
316
|
+
// The element rows remain the authoritative complete records; this map
|
|
317
|
+
// removes filename-search arithmetic for the common `backend:<id>` lookup.
|
|
318
|
+
const cropsByBackend: Record<string, string> = {};
|
|
314
319
|
let index = 0;
|
|
315
320
|
let elementsSkipped = 0;
|
|
316
321
|
let elementsReadFailed = 0;
|
|
@@ -361,6 +366,7 @@ export const collectPixels: Collector = async (ctx) => {
|
|
|
361
366
|
const cropFilename = `crops/${cropFileBase(index, entry)}.png`;
|
|
362
367
|
ctx.write.binary(cropFilename, encodePng(applyMaskTransparent(normalCrop, mask)));
|
|
363
368
|
|
|
369
|
+
const crop = `${ctx.snapId}/${cropFilename}`;
|
|
364
370
|
elements.push({
|
|
365
371
|
id: `px-${index}`,
|
|
366
372
|
backendNodeId: entry.backendNodeId,
|
|
@@ -372,7 +378,7 @@ export const collectPixels: Collector = async (ctx) => {
|
|
|
372
378
|
...(entry.clipInfo.unavailable
|
|
373
379
|
? { ancestorClipUnavailable: true as const, ancestorClipUnavailableReason: entry.clipInfo.unavailableReason }
|
|
374
380
|
: {}),
|
|
375
|
-
crop
|
|
381
|
+
crop,
|
|
376
382
|
maskedPixelFraction: round3(mask.count / (pixelRect.width * pixelRect.height)),
|
|
377
383
|
hash: averageHash(normalCrop, mask),
|
|
378
384
|
avgColor: averageColor(normalCrop, mask),
|
|
@@ -381,12 +387,14 @@ export const collectPixels: Collector = async (ctx) => {
|
|
|
381
387
|
alphaFraction: alphaFraction(transparentCrop, mask),
|
|
382
388
|
visiblePixelFraction: visiblePixelFraction(transparentCrop, mask),
|
|
383
389
|
});
|
|
390
|
+
if (entry.backendNodeId !== null) cropsByBackend[String(entry.backendNodeId)] = crop;
|
|
384
391
|
index += 1;
|
|
385
392
|
}
|
|
386
393
|
|
|
387
394
|
ctx.write.json('pixels.json', {
|
|
388
395
|
scope: PIXELS_SCOPE,
|
|
389
396
|
elements,
|
|
397
|
+
cropsByBackend,
|
|
390
398
|
backgroundOverrideRestored,
|
|
391
399
|
captureFailed: false,
|
|
392
400
|
elementsTotal,
|