@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.
Files changed (37) hide show
  1. package/bin/capture +2890 -1756
  2. package/package.json +1 -1
  3. package/src/capture.ts +3 -2
  4. package/src/cdp/args.ts +18 -3
  5. package/src/cdp/commands/measure/explain.ts +22 -7
  6. package/src/cdp/commands/measure/index.ts +8 -4
  7. package/src/cdp/commands/measure/map-paint.ts +273 -0
  8. package/src/cdp/commands/measure/snap.ts +34 -7
  9. package/src/cdp/commands/page/click.ts +32 -4
  10. package/src/cdp/commands/page/exec.ts +1 -1
  11. package/src/cdp/commands/page/index.ts +2 -2
  12. package/src/cdp/commands/page/scroll.ts +6 -4
  13. package/src/cdp/commands/page/shot.ts +118 -6
  14. package/src/cdp/commands/page/type.ts +6 -4
  15. package/src/cdp/connection.ts +19 -12
  16. package/src/cdp/measure/collectors/geometry.ts +66 -9
  17. package/src/cdp/measure/collectors/pixels.ts +9 -1
  18. package/src/cdp/measure/collectors/states.ts +243 -4
  19. package/src/cdp/measure/collectors/styles.ts +67 -43
  20. package/src/cdp/measure/explain.ts +123 -23
  21. package/src/cdp/measure/ink-source.ts +116 -0
  22. package/src/cdp/measure/ink.ts +180 -0
  23. package/src/cdp/measure/snapshot.ts +27 -13
  24. package/src/cdp/screenshot.ts +43 -11
  25. package/src/cdp/targets.ts +24 -0
  26. package/src/cdp/types.ts +15 -0
  27. package/src/contracts/index.ts +0 -2
  28. package/src/output/render.ts +1 -0
  29. package/src/output/selector.ts +238 -5
  30. package/src/session/commands.ts +47 -32
  31. package/src/command-help.ts +0 -29
  32. package/src/command-resolution.ts +0 -22
  33. package/src/command-walker.ts +0 -35
  34. package/src/contracts/results.ts +0 -208
  35. package/src/contracts/routes.ts +0 -140
  36. package/src/output/exact-raw.ts +0 -114
  37. package/src/registry.ts +0 -208
@@ -52,6 +52,33 @@ export function pageInputDeps(): PageInputDeps {
52
52
  return deps;
53
53
  }
54
54
 
55
+ export interface PageInputScreenshot {
56
+ readonly screenshot: string | null;
57
+ readonly screenshotWarning: string | null;
58
+ }
59
+
60
+ /** A dispatched page action stays successful when only its follow-up screenshot times out. */
61
+ export async function capturePageInputScreenshot(
62
+ client: Parameters<typeof autoScreenshot>[0],
63
+ action: string,
64
+ label: string,
65
+ noScreenshot?: boolean,
66
+ ): Promise<PageInputScreenshot> {
67
+ try {
68
+ return {
69
+ screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
70
+ screenshotWarning: null,
71
+ };
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ if (!/^CDP request timeout \(\d+ms\): Page\.captureScreenshot$/.test(message)) throw error;
75
+ return {
76
+ screenshot: null,
77
+ screenshotWarning: `automatic screenshot timed out after the page action completed; no screenshot was saved (${message})`,
78
+ };
79
+ }
80
+ }
81
+
55
82
  /**
56
83
  * The verb's settle window: `--settle <ms>` (including 0) wins outright;
57
84
  * otherwise the default is keyed off active-session presence (the in-session
@@ -147,7 +174,7 @@ input:
147
174
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
148
175
  --no-screenshot skip the auto-screenshot
149
176
  output:
150
- <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
177
+ <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
151
178
  effects:
152
179
  scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
153
180
 
@@ -177,8 +204,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
177
204
  const resolved = await resolveLiveTarget(live, target);
178
205
  if (!resolved.ok) return { failure: resolved } as const;
179
206
  const dispatch = await clickResolved(live, resolved);
180
- const screenshot = await deps.autoScreenshot(client, 'click', target, parsed.noScreenshot);
181
- return { dispatch, screenshot } as const;
207
+ const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
208
+ return { dispatch, ...screenshotResult } as const;
182
209
  },
183
210
  );
184
211
 
@@ -186,12 +213,13 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
186
213
  return emitResolutionError(parsed, 'page click', outcome.failure);
187
214
  }
188
215
 
189
- const { dispatch, screenshot } = outcome;
216
+ const { dispatch, screenshot, screenshotWarning } = outcome;
190
217
  const rows: FactLine[] = [
191
218
  fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
192
219
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
193
220
  ];
194
221
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
222
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
195
223
 
196
224
  emitResult(
197
225
  {
@@ -50,7 +50,7 @@ input:
50
50
  <code> JS source as one argument (exactly one of <code> / --file)
51
51
  --file <path> read the JS source from a file instead of inline
52
52
  --settle <ms> network-settle window after execution so an active session's HAR captures the requests the code triggers (default: ${DEFAULT_SETTLE_MS}; 0 disables); the block reports the measured requested/waited settle
53
- --target <id> target a tab explicitly (default: the active session tab; with no active session, --target or --url is required)
53
+ --target <id> target a tab explicitly (default: the active session tab, else exactly one available page tab; multiple page tabs require --target or --url)
54
54
  --url <pattern> target the first tab whose URL matches <pattern>
55
55
  output:
56
56
  <exec-result result-chars=…> — the JSON-serialized return value inline, escaped and capped at ${GENEROUS_RESULT_CAP} chars; a larger result is also written whole to a private artifact file (the active session's page/ dir, else a one-shot artifact dir) whose absolute path appears in the block. --json mirrors the same block with the value at full fidelity.
@@ -22,7 +22,7 @@ import { cmdPageElements } from './elements.js';
22
22
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
23
23
  export const COMMAND_BLOCK = `<command name="page">
24
24
  verbs against the live session tab — act (click, type, scroll, navigate, exec) and look (shot, elements)
25
- use when driving or inspecting the page a session opened; all verbs auto-target the active session tab, --target/--url override
25
+ use when driving or inspecting the tab a session opened or adopted; all verbs auto-target the active session tab, --target/--url override
26
26
  click · type · scroll · navigate · exec · shot · elements — \`capture page -h\`
27
27
  </command>`;
28
28
 
@@ -39,7 +39,7 @@ During a live composed recording every verb routes through the recorder.
39
39
  <subcommand name="scroll" args="<target> --to <top|bottom|px> [--no-screenshot]" whenToUse="scroll one resolved container to a position"/>
40
40
  <subcommand name="navigate" args="<url> [--settle <ms>]" whenToUse="navigate the tab and wait for load + settle"/>
41
41
  <subcommand name="exec" args="<code> | --file <path>" whenToUse="run arbitrary JS in the tab (expressions, return, await)"/>
42
- <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--out <path>]" whenToUse="look at the page right now without acting"/>
42
+ <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--crop <x,y,w,h> | --crop-selector <sel> [--pad <px>]] [--zoom <factor>] [--out <path>]" whenToUse="look at the page right now without acting"/>
43
43
  <subcommand name="elements" args="[--all] [--limit <n>]" whenToUse="list what can be acted on — role, name, backend:<id> per element"/>
44
44
 
45
45
  capture page <leaf> -h Per-leaf usage`;
@@ -13,6 +13,7 @@ import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../int
13
13
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
14
14
  import {
15
15
  pageInputDeps,
16
+ capturePageInputScreenshot,
16
17
  effectiveSettle,
17
18
  emitInvalidInput,
18
19
  emitResolutionError,
@@ -26,7 +27,7 @@ input:
26
27
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
27
28
  --no-screenshot skip the auto-screenshot
28
29
  output:
29
- <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
30
+ <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
30
31
  effects:
31
32
  assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
32
33
 
@@ -74,8 +75,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
74
75
  // Same landmark shape `motion rec --do scroll:` records; carried by
75
76
  // the one mutating call when the transport records landmarks.
76
77
  const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
77
- const screenshot = await deps.autoScreenshot(client, 'scroll', target, parsed.noScreenshot);
78
- return { dispatch, screenshot } as const;
78
+ const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
79
+ return { dispatch, ...screenshotResult } as const;
79
80
  },
80
81
  );
81
82
 
@@ -83,12 +84,13 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
83
84
  return emitResolutionError(parsed, 'page scroll', outcome.failure);
84
85
  }
85
86
 
86
- const { dispatch, screenshot } = outcome;
87
+ const { dispatch, screenshot, screenshotWarning } = outcome;
87
88
  const rows: FactLine[] = [
88
89
  fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
89
90
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
90
91
  ];
91
92
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
93
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
92
94
 
93
95
  emitResult(
94
96
  {
@@ -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
- const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
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
  }
@@ -18,6 +18,7 @@ import {
18
18
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
19
19
  import {
20
20
  pageInputDeps,
21
+ capturePageInputScreenshot,
21
22
  effectiveSettle,
22
23
  emitInvalidInput,
23
24
  emitResolutionError,
@@ -31,7 +32,7 @@ input:
31
32
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
32
33
  --no-screenshot skip the auto-screenshot
33
34
  output:
34
- <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
35
+ <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
35
36
  effects:
36
37
  dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
37
38
 
@@ -69,8 +70,8 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
69
70
  await typeText(live, textArg);
70
71
  }
71
72
  // Screenshot label identifies the field, never the typed content.
72
- const screenshot = await deps.autoScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
73
- return { dispatch, screenshot } as const;
73
+ const screenshotResult = await capturePageInputScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
74
+ return { dispatch, ...screenshotResult } as const;
74
75
  },
75
76
  );
76
77
 
@@ -78,7 +79,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
78
79
  return emitResolutionError(parsed, 'page type', outcome.failure);
79
80
  }
80
81
 
81
- const { dispatch, screenshot } = outcome;
82
+ const { dispatch, screenshot, screenshotWarning } = outcome;
82
83
  const rows: FactLine[] = [
83
84
  dispatch
84
85
  ? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
@@ -86,6 +87,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
86
87
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
87
88
  ];
88
89
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
90
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
89
91
 
90
92
  emitResult(
91
93
  {
@@ -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 { findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints } from './targets.js';
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, invalidInput } from '../errors.js';
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
- ? findTabByIdAcrossEndpoints(parsed.target, parsed.port)
57
- : findTabByUrlAcrossEndpoints(parsed.url!, parsed.port);
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
- if (!parsed.target && !parsed.url) {
214
- throw invalidInput('Use --target <tabId> or --url <pattern> to target a tab. Run "capture tab list" to see available tabs.', 'missing_target');
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
- `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture tab list" to see available tabs.`,
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
  }
@@ -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.body) {
356
+ if (innerDoc && innerDoc.documentElement) {
346
357
  frameCounter += 1;
347
358
  var childFrameId = 'frame-' + frameCounter;
348
359
  var childAncestors = ancestorFrameIds.concat([frameId]);
349
- var innerKids = innerDoc.body.children;
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
- var bodyKids = document.body ? document.body.children : [];
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