@crouton-kit/capture 1.3.18 → 1.3.19

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.
@@ -4,6 +4,25 @@ import { nextStepPath } from '../session-context.js';
4
4
  import { writeBinaryPrivate } from '../session/artifacts.js';
5
5
  import { withScopeSerialization } from './scope-lock.js';
6
6
 
7
+ /**
8
+ * The CSS-pixel rectangle a captured PNG covers: its page-coordinate origin and
9
+ * its size. `image px / width` (and `/ height`) is the capture's true CSS-to-
10
+ * image scale, and `x`/`y` turn a page coordinate into an image coordinate.
11
+ */
12
+ export interface CapturedRegion {
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ }
18
+
19
+ /** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
20
+ function validRegion(region: CapturedRegion): CapturedRegion | undefined {
21
+ const finite = [region.x, region.y, region.width, region.height].every((n) => typeof n === 'number' && Number.isFinite(n));
22
+ if (!finite || region.width <= 0 || region.height <= 0) return undefined;
23
+ return region;
24
+ }
25
+
7
26
  /**
8
27
  * Downscales a PNG so its longest side fits within maxDim, using a box
9
28
  * filter. Used when a clipped/scaled CDP capture is unavailable and the raw
@@ -65,9 +84,27 @@ export async function captureScreenshot(
65
84
  viewport?: { width: number; height: number },
66
85
  options?: { fullPage?: boolean },
67
86
  ): Promise<Buffer> {
68
- return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
87
+ const result = await withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
69
88
  viewportScopedCapture(client, viewport, options),
70
89
  );
90
+ return result.png;
91
+ }
92
+
93
+ /**
94
+ * Captures a PNG and the CSS-pixel region the image actually covers — the clip
95
+ * sent to `Page.captureScreenshot` (or, for an unclipped capture, the visual
96
+ * viewport). Dividing the PNG's pixel dimensions by that region's size is the
97
+ * true CSS-to-image scale; the layout viewport (`window.innerWidth`) is a
98
+ * different coordinate space under page zoom and must not stand in for it.
99
+ */
100
+ export async function captureScreenshotWithCssViewport(
101
+ client: CDPClient,
102
+ viewport?: { width: number; height: number },
103
+ options?: { fullPage?: boolean },
104
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
105
+ return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
106
+ viewportScopedCapture(client, viewport, options, true),
107
+ );
71
108
  }
72
109
 
73
110
  /**
@@ -81,7 +118,8 @@ async function viewportScopedCapture(
81
118
  client: CDPClient,
82
119
  viewport?: { width: number; height: number },
83
120
  options?: { fullPage?: boolean },
84
- ): Promise<Buffer> {
121
+ includeCssViewport = false,
122
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
85
123
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
86
124
  let ownsDeviceMetricsOverride = false;
87
125
  let primaryFailed = false;
@@ -102,7 +140,7 @@ async function viewportScopedCapture(
102
140
  }
103
141
 
104
142
  if (options?.fullPage) {
105
- const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
143
+ const layoutMetrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
106
144
  contentSize?: { width: number; height: number };
107
145
  cssVisualViewport?: { clientWidth: number };
108
146
  };
@@ -123,8 +161,13 @@ async function viewportScopedCapture(
123
161
  captureBeyondViewport: false,
124
162
  };
125
163
 
164
+ // The CSS-pixel region the returned image covers, set from the clip that is
165
+ // actually sent. An unclipped capture clears it again and it is read from the
166
+ // live visual viewport below instead.
167
+ let capturedRegion: CapturedRegion | undefined;
168
+
126
169
  try {
127
- const metrics = (await client.send('Page.getLayoutMetrics')) as {
170
+ const metrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
128
171
  cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
129
172
  };
130
173
  // Snap the clip to integer CSS pixels: browser zoom (e.g. 110%) makes the
@@ -139,7 +182,7 @@ async function viewportScopedCapture(
139
182
  const dprResult = (await client.send('Runtime.evaluate', {
140
183
  expression: 'window.devicePixelRatio',
141
184
  returnByValue: true,
142
- })) as { result: { value: number } };
185
+ }, 5000)) as { result: { value: number } };
143
186
  const dpr = dprResult.result.value ?? 1;
144
187
 
145
188
  const actualMaxSide = Math.max(vw, vh) * dpr;
@@ -148,6 +191,9 @@ async function viewportScopedCapture(
148
191
  ...screenshotOpts,
149
192
  clip: { x: sx, y: sy, width: vw, height: vh, scale },
150
193
  };
194
+ // The clip is what the image is cut from, rounding included — not the
195
+ // fractional viewport it was derived from.
196
+ capturedRegion = validRegion({ x: sx, y: sy, width: vw, height: vh });
151
197
  } catch {
152
198
  // Capture without downscaling when the optional metrics probe is unavailable.
153
199
  }
@@ -155,6 +201,7 @@ async function viewportScopedCapture(
155
201
  const result = (await client.send(
156
202
  'Page.captureScreenshot',
157
203
  screenshotOpts,
204
+ 15000,
158
205
  )) as { data?: string };
159
206
  let png = Buffer.from(result.data ?? '', 'base64');
160
207
 
@@ -167,7 +214,7 @@ async function viewportScopedCapture(
167
214
  const retry = (await client.send('Page.captureScreenshot', {
168
215
  ...screenshotOpts,
169
216
  clip: { ...clip, scale: 1 },
170
- })) as { data?: string };
217
+ }, 15000)) as { data?: string };
171
218
  png = Buffer.from(retry.data ?? '', 'base64');
172
219
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
173
220
  }
@@ -178,9 +225,37 @@ async function viewportScopedCapture(
178
225
  const retry = (await client.send('Page.captureScreenshot', {
179
226
  format: 'png',
180
227
  captureBeyondViewport: false,
181
- })) as { data?: string };
228
+ }, 15000)) as { data?: string };
182
229
  png = Buffer.from(retry.data ?? '', 'base64');
183
230
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
231
+ // An unclipped capture covers the visual viewport itself, not the integer
232
+ // clip that was refused — and the two failed attempts above can have taken
233
+ // 30 seconds, so the viewport sampled before them is no longer evidence.
234
+ if (png.length > 0) capturedRegion = undefined;
235
+ }
236
+
237
+ // The image was captured unclipped (no clip was ever sent, or the clipped
238
+ // attempts were refused), so it covers the visual viewport. Read it as it
239
+ // stands now rather than reporting the layout viewport, which is a different
240
+ // space under page zoom, or a sample taken before a failed attempt.
241
+ if (includeCssViewport && !capturedRegion) {
242
+ try {
243
+ const response = (await client.send('Runtime.evaluate', {
244
+ expression: '(() => { const v = window.visualViewport; return v ? { x: v.pageLeft, y: v.pageTop, width: v.width, height: v.height } : null; })()',
245
+ returnByValue: true,
246
+ }, 5000)) as { result?: { value?: Record<string, unknown> | null } };
247
+ const value = response.result?.value;
248
+ if (value) {
249
+ capturedRegion = validRegion({
250
+ x: Number(value.x),
251
+ y: Number(value.y),
252
+ width: Number(value.width),
253
+ height: Number(value.height),
254
+ });
255
+ }
256
+ } catch {
257
+ // The image remains usable; only its CSS-coordinate mapping is unavailable.
258
+ }
184
259
  }
185
260
 
186
261
  if (png.length === 0) {
@@ -189,7 +264,7 @@ async function viewportScopedCapture(
189
264
  );
190
265
  }
191
266
 
192
- return png;
267
+ return { png, ...(includeCssViewport && capturedRegion ? { cssViewport: capturedRegion } : {}) };
193
268
  } catch (error) {
194
269
  primaryFailed = true;
195
270
  primaryError = error;
@@ -1,5 +1,6 @@
1
1
  import { CDPClient } from './client.js';
2
2
  import { detectCdpPortsAsync } from './detect.js';
3
+ import { touchOwnedBrowser } from './browser-process.js';
3
4
  import { type CDPTarget } from './types.js';
4
5
 
5
6
  /**
@@ -30,6 +31,10 @@ export async function getBrowserClient(
30
31
  }
31
32
  const client = new CDPClient(version.webSocketDebuggerUrl);
32
33
  await client.waitReady();
34
+ // Every browser-level connection funnels through here, which makes this the
35
+ // one place that can tell capture's idle sweep the browser is still in use.
36
+ // A port capture did not launch has no registry record and this is a no-op.
37
+ touchOwnedBrowser(port);
33
38
  return { client, browserWsUrl: version.webSocketDebuggerUrl };
34
39
  }
35
40
 
package/src/cdp/types.ts CHANGED
@@ -25,6 +25,8 @@ export interface ParsedArgs {
25
25
  * can auto-append recorded traffic to the session HAR. */
26
26
  har?: string;
27
27
  new?: boolean;
28
+ /** `tab launch --headed` — a visible window instead of headless. */
29
+ headed?: boolean;
28
30
  target?: string;
29
31
  /** Where `target` was assigned from: an actual `--target` flag, active-session
30
32
  * autofill, or the ambient `CDP_TARGET` env var. Recorded at the assignment
@@ -68,6 +70,8 @@ export interface ParsedArgs {
68
70
  settleTimeout?: number;
69
71
  /** `snap --capture-unsettled` — write full substrate despite non-settlement. */
70
72
  captureUnsettled?: boolean;
73
+ /** `snap --skip-collector <name>` — repeatable; one entry per occurrence. */
74
+ skipCollector?: string[];
71
75
  /** `snap|diff|mask --pixels` — include raster crop/diff facts. */
72
76
  pixels?: boolean;
73
77
  /** `snap --state <state[:selector]>` — repeatable; one entry per occurrence. */
@@ -552,8 +552,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
552
552
 
553
553
  // Response-state ⇄ status coupling.
554
554
  if (capResponse.state === 'received') {
555
- if (status < 100 || status > 599) {
556
- fail(source, `${path}.response.status`, 'a received response requires status 100..599');
555
+ if ((status < 100 || status > 599) && !(terminal.kind === 'redirect' && status === 0)) {
556
+ fail(source, `${path}.response.status`, 'a received response requires status 100..599 (or 0 for a redirect with no HTTP response)');
557
557
  }
558
558
  } else {
559
559
  // unavailable
@@ -566,8 +566,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
566
566
  fail(source, `${path}._capture.clocks.responseMonotonic`, 'must be null when no response was received');
567
567
  }
568
568
  }
569
- if (status === 0 && capResponse.state !== 'unavailable') {
570
- fail(source, `${path}.response.status`, 'status 0 is permitted only when _capture.response.state is "unavailable"');
569
+ if (status === 0 && capResponse.state !== 'unavailable' && terminal.kind !== 'redirect') {
570
+ fail(source, `${path}.response.status`, 'status 0 is permitted only for an unavailable response or a redirect with no HTTP response');
571
571
  }
572
572
 
573
573
  // Terminal ⇄ response/status/body coupling.
@@ -582,8 +582,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
582
582
  if (capResponse.state !== 'received') {
583
583
  fail(source, `${path}._capture.response.state`, 'a redirect requires a received response');
584
584
  }
585
- if (status < 300 || status > 399) {
586
- fail(source, `${path}.response.status`, 'a redirect requires status 300..399');
585
+ if (status !== 0 && (status < 300 || status > 399)) {
586
+ fail(source, `${path}.response.status`, 'a redirect requires status 0 (no HTTP response) or 300..399');
587
587
  }
588
588
  if (body.state !== 'not_applicable' || body.reason !== 'redirect') {
589
589
  fail(source, `${path}._capture.body`, 'a redirect requires body not_applicable/redirect');
@@ -666,17 +666,18 @@ function vIncompleteResponse(
666
666
  path: string,
667
667
  minResponseMonotonic: number,
668
668
  allowUnknownClock = false,
669
+ allowNoHttpStatus = false,
669
670
  ): IncompleteResponse {
670
671
  const o = vObject(v, source, path);
671
672
  exactKeys(o, ['status', 'headers', 'responseMonotonic'], source, path);
672
673
  const responseMonotonic = o.responseMonotonic === null
673
674
  ? allowUnknownClock ? null : fail(source, `${path}.responseMonotonic`, 'must be a finite number')
674
675
  : vFiniteMin(o.responseMonotonic, minResponseMonotonic, source, `${path}.responseMonotonic`);
675
- return {
676
- status: vIntInRange(o.status, 100, 599, source, `${path}.status`),
677
- headers: vHeaders(o.headers, source, `${path}.headers`),
678
- responseMonotonic,
679
- };
676
+ const status = vIntInRange(o.status, 0, 599, source, `${path}.status`);
677
+ if ((status < 100 && status !== 0) || (status === 0 && (!allowNoHttpStatus || responseMonotonic !== null))) {
678
+ fail(source, `${path}.status`, 'must be 100..599 (or 0 for a redirect with no HTTP response)');
679
+ }
680
+ return { status, headers: vHeaders(o.headers, source, `${path}.headers`), responseMonotonic };
680
681
  }
681
682
 
682
683
  function vStoppedBeforeTerminal(
@@ -754,9 +755,9 @@ function vInvalidClockOrder(
754
755
  const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
755
756
  const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
756
757
  const request = vRequest(o.request, source, `${path}.request`);
757
- const response =
758
- o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true);
759
758
  const terminal = vInvalidClockTerminal(o.terminal, source, `${path}.terminal`);
759
+ const response =
760
+ o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true, terminal.kind === 'redirect');
760
761
 
761
762
  const cap = vObject(o._capture, source, `${path}._capture`);
762
763
  exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic'], source, `${path}._capture`);
@@ -775,8 +776,8 @@ function vInvalidClockOrder(
775
776
  // CDP redirectResponse preserves redirect facts but has no independently observed response clock.
776
777
  if (terminal.kind === 'redirect') {
777
778
  if (response === null) fail(source, `${path}.response`, 'a redirect terminal requires an observed response');
778
- if (response.status < 300 || response.status > 399) {
779
- fail(source, `${path}.response.status`, 'a redirect terminal requires status 300..399');
779
+ if (response.status !== 0 && (response.status < 300 || response.status > 399)) {
780
+ fail(source, `${path}.response.status`, 'a redirect terminal requires status 0 (no HTTP response) or 300..399');
780
781
  }
781
782
  if (response.responseMonotonic !== null) {
782
783
  fail(source, `${path}.response.responseMonotonic`, 'must be null for a redirect terminal');
package/src/interact.ts CHANGED
@@ -89,6 +89,11 @@ export type ResolutionFailure =
89
89
  readonly input: string;
90
90
  readonly acceptedPrefixes: typeof ACCEPTED_LIVE_PREFIXES;
91
91
  }
92
+ | {
93
+ readonly ok: false;
94
+ readonly code: 'invalid-css-selector';
95
+ readonly input: string;
96
+ }
92
97
  | {
93
98
  readonly ok: false;
94
99
  readonly code: 'no-match' | 'ambiguous';
@@ -222,10 +227,21 @@ async function resolveLiveCss(
222
227
  ): Promise<ResolvedTarget | ResolutionFailure> {
223
228
  await client.send('DOM.enable');
224
229
  const { root } = (await client.send('DOM.getDocument', { depth: 0 })) as { root: { nodeId: number } };
225
- const { nodeIds } = (await client.send('DOM.querySelectorAll', {
226
- nodeId: root.nodeId,
227
- selector,
228
- })) as { nodeIds: number[] };
230
+ let nodeIds: number[];
231
+ try {
232
+ ({ nodeIds } = (await client.send('DOM.querySelectorAll', {
233
+ nodeId: root.nodeId,
234
+ selector,
235
+ })) as { nodeIds: number[] });
236
+ } catch (error) {
237
+ // Chrome reports a malformed CSS selector from this CDP method as the
238
+ // otherwise opaque "DOM Error while querying". The bare grammar is CSS;
239
+ // make the accessible-name retry explicit instead of leaking that error.
240
+ if (error instanceof Error && error.message.includes('DOM Error while querying')) {
241
+ return { ok: false, code: 'invalid-css-selector', input };
242
+ }
243
+ throw error;
244
+ }
229
245
 
230
246
  if (nodeIds.length === 1) {
231
247
  const backendNodeId = await backendNodeIdFor(client, nodeIds[0]);
@@ -56,6 +56,8 @@ export const RESULT_TAGS = [
56
56
  'tab-opened',
57
57
  'tab-closed',
58
58
  'tab-reset',
59
+ 'browser-launched',
60
+ 'browser-quit',
59
61
  'network',
60
62
  'cdp-result',
61
63
  'libs',
package/src/registry.ts CHANGED
@@ -87,7 +87,7 @@ const ref = 'css:<selector> | backend:<id> | axid:<id> | ax:<name> | text:<text>
87
87
 
88
88
  export const CAPTURE_REGISTRY: RegistryBranch = branch('', 'Capture — browser evidence and automation over CDP.', [
89
89
  branch('session', 'persistent browser-work lifecycle and artifact bundles.', [
90
- leaf('session start', 'start a fresh persistent capture session.', { flags: [requiredValue('--url', 'absolute-http(s)-url'), port()], effects: effects(true, true, true), constraints: 'Required singleton --url <absolute-http(s)-url>; optional singleton --port <1..65535>. No target, prefix, positional URL, or ambient CDP selection.', orderedOutput: 'Exact session ID; open lifecycle; full target ID; endpoint; observed URL; target_live=true; scope key; active generation.', artifactOwnership: 'Exclusively allocates the session directory and atomically publishes the canonical session record before its scoped pointer.' }),
90
+ leaf('session start', 'open or prepare a tab for a persistent capture session.', { flags: [value('--url', 'absolute-http(s)-url'), bool('--hold'), port()], effects: effects(true, true, true), constraints: 'Optional singleton --url <absolute-http(s)-url> opens a fresh tab through Target.createTarget; endpoints such as Electron that reject Target.createTarget cannot use --url. Optional singleton --hold retains a browser connection; optional singleton --port <1..65535> selects its endpoint. To work against an existing tab, omit --url, run `capture session start --hold --port <port>`, list it with `capture tab list --port <port>`, then run `capture page navigate <url> --target <target-id> --port <port>`; that navigate command adopts the target and later page commands auto-target it. No target, prefix, positional URL, or ambient CDP selection on session start.', orderedOutput: 'Exact session ID; open lifecycle; full target ID or null; endpoint or null; observed URL or null; target_live=true when a tab was opened; scope key; active generation.', artifactOwnership: 'Exclusively allocates the session directory and atomically publishes the canonical session record before its scoped pointer.' }),
91
91
  leaf('session stop', 'stop and bundle one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(true, true, true), constraints: 'One exact case-sensitive session ID; no prefixes. Only matching open/stopping/completed lifecycle records are accepted before bundle effects.', orderedOutput: 'Exact ID; stop-operation ID; stopped lifecycle; final bundle access; pointer outcome deleted|absent|superseded.', artifactOwnership: 'Resumes the recorded operation and owns bundle staging/publication; never rolls lifecycle back to open.', recovery: 'session_stop_state_invalid: inspect capture session view <exact-session-id> and preserve the session directory; never delete or allocate a new stop. session_stop_staging_cleanup_failed: correct access at the reported staging path and retry the identical capture session stop <exact-session-id>. session_stop_bundle_failed: correct the reported bundle/filesystem cause and retry that identical command. Both retries resume the recorded operation.' }),
92
92
  leaf('session list', 'list same-user host capture sessions.', { effects: effects(false, true), constraints: 'No target/session filters or ambient defaults.', orderedOutput: 'Rows sorted by exact session ID: ID, lifecycle, active_in_current_scope, full target ID or null, endpoint or null, target_live.' }),
93
93
  leaf('session view', 'view one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(false, true), constraints: 'One exact case-sensitive session ID; no prefix.' }),