@crouton-kit/capture 1.3.17 → 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.
Files changed (37) hide show
  1. package/bin/capture +4184 -3258
  2. package/package.json +1 -1
  3. package/src/capture.ts +4 -1
  4. package/src/cdp/args.ts +14 -2
  5. package/src/cdp/browser-process.ts +510 -0
  6. package/src/cdp/client.ts +10 -1
  7. package/src/cdp/commands/cdp.ts +5 -5
  8. package/src/cdp/commands/measure/snap.ts +37 -2
  9. package/src/cdp/commands/motion/mask.ts +8 -2
  10. package/src/cdp/commands/page/click.ts +14 -0
  11. package/src/cdp/commands/page/elements.ts +2 -2
  12. package/src/cdp/commands/page/shot.ts +26 -5
  13. package/src/cdp/commands/tab/index.ts +19 -8
  14. package/src/cdp/commands/tab/launch.ts +73 -0
  15. package/src/cdp/commands/tab/list.ts +14 -2
  16. package/src/cdp/commands/tab/quit.ts +78 -0
  17. package/src/cdp/har-recorder.ts +14 -20
  18. package/src/cdp/measure/collectors/ax.ts +3 -4
  19. package/src/cdp/measure/collectors/forms.ts +4 -6
  20. package/src/cdp/measure/collectors/media.ts +1 -2
  21. package/src/cdp/measure/collectors/states.ts +1 -6
  22. package/src/cdp/measure/collectors/style-provenance.ts +4 -16
  23. package/src/cdp/measure/collectors/styles.ts +21 -10
  24. package/src/cdp/measure/collectors/text.ts +3 -5
  25. package/src/cdp/measure/snapshot.ts +195 -29
  26. package/src/cdp/measure/types.ts +32 -2
  27. package/src/cdp/motion/jank.ts +8 -1
  28. package/src/cdp/record.ts +37 -5
  29. package/src/cdp/screenshot.ts +83 -8
  30. package/src/cdp/targets.ts +5 -0
  31. package/src/cdp/types.ts +4 -0
  32. package/src/har-manager.ts +16 -15
  33. package/src/interact.ts +20 -4
  34. package/src/output/render.ts +2 -0
  35. package/src/registry.ts +1 -1
  36. package/src/session/commands.ts +287 -70
  37. package/src/session/har-redact.ts +125 -0
@@ -6,7 +6,8 @@ import { type ParsedArgs } from '../../types.js';
6
6
  import { withConnection } from '../../connection.js';
7
7
  import { isRecorderHeldClient } from '../../recorder-client.js';
8
8
  import { parseViewport, type Viewport } from '../../viewport.js';
9
- import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
9
+ import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
10
+ import type { CollectorOutcome } from '../../measure/types.js';
10
11
  import { sanitizeString } from '../../measure/redaction.js';
11
12
  import { DEFAULT_SETTLE_TIMEOUT_MS } from '../../measure/settle.js';
12
13
  import { createOneshotSession } from '../../../session/commands.js';
@@ -31,6 +32,7 @@ input:
31
32
  --freeze-animations pause CSS/WAAPI animation before capture
32
33
  --settle-timeout <ms> override the default 5000ms settle wait
33
34
  --capture-unsettled write the full substrate despite non-settlement, marking unstable regions
35
+ --skip-collector <name> leave one collector out entirely (repeatable): ax, styles, queries, animation, layers, media, geometry, hittest, text, forms, focus, scroll, states, pixels. A collector that fails is recorded and skipped; every other collector's substrate is still written
34
36
  --pixels also write per-element raster crops
35
37
  --state <state[:selector]> force a pseudo-state or real control state (repeatable)
36
38
  --viewport <WxH> temporarily capture at a CSS-pixel viewport (repeatable); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
@@ -188,6 +190,17 @@ export async function withAppliedViewport<T>(client: ViewportClient, viewport: V
188
190
  }
189
191
  }
190
192
 
193
+ /** One line naming every collector whose substrate is absent or degraded, and what to do about it. */
194
+ function collectorNote(incomplete: readonly CollectorOutcome[]) {
195
+ const described = incomplete
196
+ .map((c) => `${sanitizeString(c.name)} (${c.status}${c.requestTimeouts ? `, ${c.requestTimeouts} timed-out request(s)` : ''})`)
197
+ .join(', ');
198
+ const failed = incomplete.some((c) => c.status === 'error' || c.status === 'degraded');
199
+ return fact`collectors incomplete: ${described}. Every other collector's substrate is valid and queryable.${
200
+ failed ? ` Re-run with \`--skip-collector <name>\` to omit one deliberately rather than have it fail.` : ''
201
+ }`;
202
+ }
203
+
191
204
  function settledNote(settled: boolean, captured: boolean, settleMs: number, settleTimeoutMs: number, unstableCount: number) {
192
205
  if (settled) {
193
206
  return fact`Settled after ${settleMs}ms (two consecutive identical captures; DOM quiet ≥300ms).`;
@@ -210,6 +223,11 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
210
223
  if (parsed.settleTimeout !== undefined && (!Number.isFinite(parsed.settleTimeout) || parsed.settleTimeout <= 0)) {
211
224
  throw invalidInput('--settle-timeout must be a positive number of milliseconds');
212
225
  }
226
+ for (const name of parsed.skipCollector ?? []) {
227
+ if (!COLLECTOR_NAMES.includes(name)) {
228
+ throw invalidInput(`--skip-collector must name a collector: ${COLLECTOR_NAMES.join(', ')} (got ${sanitizeString(name)})`);
229
+ }
230
+ }
213
231
  const viewport: ViewportSpec | undefined = viewportValue === undefined
214
232
  ? undefined
215
233
  : { label: viewportValue, ...parseViewport(viewportValue) };
@@ -254,6 +272,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
254
272
  pixels: parsed.pixels,
255
273
  state: parsed.state ?? [],
256
274
  viewport: viewport?.label ?? null,
275
+ skipCollectors: parsed.skipCollector ?? [],
257
276
  });
258
277
  artifacts = result.artifacts;
259
278
  });
@@ -262,7 +281,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
262
281
  if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
263
282
  return { id: snapId, dir: snapDir, artifacts, ...(base ? { base } : {}) };
264
283
  } catch (err) {
265
- if (allocatedRoot) removeArtifactTree(allocatedRoot);
284
+ if (allocatedRoot && !(err instanceof SnapshotCaptureTimeout)) removeArtifactTree(allocatedRoot);
266
285
  throw err;
267
286
  }
268
287
  }
@@ -276,7 +295,9 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
276
295
  settleTimeoutMs: number;
277
296
  unstableRegionCount: number;
278
297
  states: readonly string[];
298
+ collectors?: readonly CollectorOutcome[];
279
299
  }>({ kind: 'snap', id: captured.id, dir: captured.dir });
300
+ const incomplete = (meta.collectors ?? []).filter((c) => c.status !== 'ok');
280
301
  const elements = readArrayCount(path.join(captured.dir, 'geometry.json'), 'elements');
281
302
  const artifacts = artifactEntries(captured.dir, captured.artifacts);
282
303
  const capturedFullSubstrate = fs.existsSync(path.join(captured.dir, 'geometry.json'));
@@ -301,6 +322,11 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
301
322
  summary: meta.settled || capturedFullSubstrate
302
323
  ? text`Snapshot substrate captured.`
303
324
  : text`The evidence snapshot is not queryable because the settledness requirement was not met.`,
325
+ // A collector that did not complete cleanly must be visible in the block,
326
+ // not only in meta.json — its artifact is absent (timeout/error/skipped)
327
+ // or partly built from timed-out reads (degraded), and a caller who reads
328
+ // the remaining substrate as a whole page would be reading a hole.
329
+ ...(incomplete.length ? { sections: [collectorNote(incomplete)] } : {}),
304
330
  artifacts: formatArtifactList(artifacts),
305
331
  followUp: meta.settled || capturedFullSubstrate
306
332
  ? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.id}\`, \`capture measure census --snap ${captured.id} --axis color\`, or \`capture measure map focus ${captured.id}\`.`
@@ -312,6 +338,9 @@ interface SnapErrorDetails {
312
338
  readonly status: string;
313
339
  readonly message: string;
314
340
  readonly resolution?: ArtifactResolutionError;
341
+ readonly phase?: string;
342
+ readonly method?: string;
343
+ readonly partialPath?: string;
315
344
  }
316
345
 
317
346
  function safeErrorDetail(value: unknown): string {
@@ -319,6 +348,9 @@ function safeErrorDetail(value: unknown): string {
319
348
  }
320
349
 
321
350
  export function classifySnapError(err: unknown): SnapErrorDetails {
351
+ if (err instanceof SnapshotCaptureTimeout) {
352
+ return { status: 'snapshot_capture_timeout', message: safeErrorDetail(err), phase: err.phase, method: err.method, partialPath: err.partialPath };
353
+ }
322
354
  if (err instanceof ArtifactResolutionError) {
323
355
  return { status: 'snapshot_ref_unavailable', message: safeErrorDetail(err), resolution: err };
324
356
  }
@@ -399,6 +431,9 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
399
431
  attrs: {
400
432
  command: 'measure snap',
401
433
  status: classified.status,
434
+ ...(classified.phase ? { phase: classified.phase } : {}),
435
+ ...(classified.method ? { method: classified.method } : {}),
436
+ ...(classified.partialPath ? { 'partial-path': classified.partialPath } : {}),
402
437
  ...(classified.resolution ? {
403
438
  recovery: 'artifact-resolution-error',
404
439
  ref: sanitizeString(classified.resolution.ref),
@@ -24,9 +24,15 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
24
24
 
25
25
  try {
26
26
  const ref = resolveRecRef(parsed.positional[0]);
27
- const meta = readMeta<{ state?: unknown }>(ref);
27
+ const meta = readMeta<{ state?: unknown; reason?: unknown }>(ref);
28
28
  const state = typeof meta.state === 'string' ? meta.state : 'unknown';
29
- if (state !== 'finalized' && state !== 'orphaned-finalized') {
29
+ // A one-shot recording that captured zero screencast frames is a completed
30
+ // recording, not one awaiting `--stop` (which only finalizes an active
31
+ // composed lifecycle recording) — let it through and rely on
32
+ // createMotionMask's own accurate "needs at least two frames" rejection
33
+ // below, matching the same exception in response.ts's loadResponseTimeline.
34
+ const readablePartial = state === 'partial' && meta.reason === 'no_frames';
35
+ if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
30
36
  return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
31
37
  }
32
38
  const mask = createMotionMask(ref);
@@ -94,6 +94,20 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
94
94
  return;
95
95
  }
96
96
 
97
+ if (failure.code === 'invalid-css-selector') {
98
+ emitResult(
99
+ {
100
+ tag: 'error',
101
+ attrs: { command, code: 'invalid_css_selector' },
102
+ summary: fact`received: bare target \`${failure.input}\` was rejected by DOM.querySelectorAll as a CSS selector.`,
103
+ followUp: fact`Retry \`capture ${command} ax:${failure.input}\` to target that accessible name, or use \`backend:<id>\` from \`capture page elements\`.`,
104
+ },
105
+ { json: parsed.json },
106
+ );
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
97
111
  const code = failure.code === 'no-match' ? 'no_match' : 'ambiguous_target';
98
112
  const candidateRows: FactLine[] = failure.candidates.map((c) =>
99
113
  line(data(c.role ?? 'unknown'), text` "`, data(c.name ?? ''), text`" — backend:`, data(c.backendNodeId)),
@@ -42,7 +42,7 @@ Input:
42
42
  --json mirror the result as JSON
43
43
 
44
44
  Output:
45
- <elements scope="interactive|all" count=<total>> — one row per element: role "name" backend:<id>
45
+ <elements scope="interactive|all" count=<total>> — one row per element: role "name" target:ax:<name> backend:<id>; copy the target value into a driving verb
46
46
 
47
47
  Effects: read-only — the accessibility fetch is CDP-side, no page-observable writes; routes unmarked through an active composed recording.`;
48
48
 
@@ -106,7 +106,7 @@ export function buildElementsResult(
106
106
 
107
107
  const rows = shown.map((r) =>
108
108
  r.backendNodeId !== null
109
- ? fact`${r.role} "${r.name}" backend:${r.backendNodeId}`
109
+ ? fact`${r.role} "${r.name}" target:ax:${r.name} backend:${r.backendNodeId}`
110
110
  : fact`${r.role} "${r.name}"`,
111
111
  );
112
112
 
@@ -18,7 +18,7 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { type ParsedArgs } from '../../types.js';
20
20
  import { withConnection } from '../../connection.js';
21
- import { captureScreenshot } from '../../screenshot.js';
21
+ import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
22
22
  import { parseViewport, type Viewport } from '../../viewport.js';
23
23
  import { nextStepPath } from '../../../session-context.js';
24
24
  import { createOneshotSession } from '../../../session/commands.js';
@@ -40,7 +40,7 @@ input:
40
40
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
41
41
  --json mirror the result as JSON
42
42
  output:
43
- <screenshot path=… width=… height=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, byte size, and the emulation fact (whether a transient override was applied, or its absence)
43
+ <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> — 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 the emulation fact (whether a transient override was applied, or its absence)
44
44
  effects:
45
45
  no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
46
46
 
@@ -116,14 +116,29 @@ function writeScreenshot(outPath: string, png: Buffer): void {
116
116
 
117
117
  export type EmulationMode = 'none' | 'viewport' | 'full-page';
118
118
 
119
+ /** Renders a CSS-pixel measurement without inventing precision: an integer stays an integer, a fractional zoomed viewport keeps six decimals. */
120
+ function round6(value: number): number {
121
+ return Math.round(value * 1e6) / 1e6;
122
+ }
123
+
119
124
  export function buildScreenshotResult(f: {
120
125
  path: string;
121
126
  bytes: number;
122
127
  dimensions: { width: number; height: number } | null;
128
+ cssViewport?: CapturedRegion;
123
129
  emulation: EmulationMode;
124
130
  viewport?: { width: number; height: number };
125
131
  }): RenderableResult {
132
+ // The denominator is the CSS region the image was actually cut from, so the
133
+ // ratio stays true under page zoom, device pixel ratio, and the local
134
+ // downscale — the layout viewport is a different space and is never used here.
135
+ const scale = f.dimensions && f.cssViewport
136
+ ? { x: f.dimensions.width / f.cssViewport.width, y: f.dimensions.height / f.cssViewport.height }
137
+ : undefined;
126
138
  const sections: FactLine[] = [
139
+ scale && f.cssViewport
140
+ ? 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)}).`
141
+ : text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
127
142
  f.emulation === 'none'
128
143
  ? text`emulation: none — the browser's actual current viewport was captured; no Emulation call was made.`
129
144
  : f.emulation === 'viewport'
@@ -138,6 +153,12 @@ export function buildScreenshotResult(f: {
138
153
  path: f.path,
139
154
  width: f.dimensions?.width,
140
155
  height: f.dimensions?.height,
156
+ 'css-width': f.cssViewport === undefined ? undefined : round6(f.cssViewport.width),
157
+ 'css-height': f.cssViewport === undefined ? undefined : round6(f.cssViewport.height),
158
+ 'css-x': f.cssViewport === undefined ? undefined : round6(f.cssViewport.x),
159
+ 'css-y': f.cssViewport === undefined ? undefined : round6(f.cssViewport.y),
160
+ 'css-to-image-x': scale?.x.toFixed(6),
161
+ 'css-to-image-y': scale?.y.toFixed(6),
141
162
  emulation: f.emulation,
142
163
  },
143
164
  summary: f.dimensions
@@ -196,10 +217,10 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
196
217
  const result = await deps.withConnection(
197
218
  { ...parsed, command: 'shot' },
198
219
  async (client) => {
199
- const png = await captureScreenshot(client, viewport, { fullPage: parsed.fullPage });
220
+ const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
200
221
  const outPath = await resolveOutPath(parsed);
201
- writeScreenshot(outPath, png);
202
- return { path: outPath, bytes: png.length, dimensions: pngDimensions(png) };
222
+ writeScreenshot(outPath, screenshot.png);
223
+ return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
203
224
  },
204
225
  { settle: 0 },
205
226
  );
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import { invalidInput } from '../../../errors.js';
12
12
  import { type ParsedArgs } from '../../types.js';
13
+ import { cmdTabLaunch } from './launch.js';
14
+ import { cmdTabQuit } from './quit.js';
13
15
  import { cmdTabList } from './list.js';
14
16
  import { cmdTabOpen } from './open.js';
15
17
  import { cmdTabClose } from './close.js';
@@ -18,19 +20,24 @@ import { cmdTabNetwork } from './network.js';
18
20
 
19
21
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
20
22
  export const COMMAND_BLOCK = `<command name="tab">
21
- tab and connection plumbing — endpoint/tab discovery, lifecycle, connection-level network emulation
22
- use when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
23
- list · open · close · reset · network — \`capture tab -h\`
23
+ browser and tab plumbing — starting/stopping a browser capture owns, endpoint/tab discovery, tab lifecycle, connection-level network emulation
24
+ use when nothing is running yet (\`tab launch\` starts a browser capture owns and reaps), when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
25
+ launch · quit · list · open · close · reset · network — \`capture tab -h\`
24
26
  </command>`;
25
27
 
26
- export const TAB_USAGE = `capture tab — tab and connection plumbing: discovery, lifecycle, network emulation.
28
+ export const TAB_USAGE = `capture tab — browser and tab plumbing: browser lifecycle, discovery, tab lifecycle, network emulation.
27
29
 
28
30
  \`tab list\` with no --port performs full endpoint discovery and is the probe
29
- for whether a CDP-enabled browser is running at all. \`tab reset\` replaces a
30
- stuck tab with a fresh one; under an active session it refuses while a
31
- recording is active (stop it first), reaps dead recorder handles, and updates
32
- the session's {target, port} pair together.
31
+ for whether a CDP-enabled browser is running at all; when nothing is running,
32
+ \`tab launch\` starts one capture owns — never hand-roll a detached browser,
33
+ since nothing reaps that. Capture signals only browsers it launched itself
34
+ (verified by process-birth identity): an endpoint you reach with --port stays
35
+ untouched. \`tab reset\` replaces a stuck tab with a fresh one; under an active
36
+ session it refuses while a recording is active (stop it first), reaps dead
37
+ recorder handles, and updates the session's {target, port} pair together.
33
38
 
39
+ <subcommand name="launch" args="[--url <url>] [--port <port>] [--headed]" whenToUse="start a browser capture owns (and reaps) when none is running"/>
40
+ <subcommand name="quit" args="[<port>] [--all]" whenToUse="stop a browser capture launched; never touches a browser capture did not start"/>
34
41
  <subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
35
42
  <subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
36
43
  <subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
@@ -44,6 +51,10 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
44
51
  const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
45
52
 
46
53
  switch (leaf) {
54
+ case 'launch':
55
+ return cmdTabLaunch(rest, args);
56
+ case 'quit':
57
+ return cmdTabQuit(rest, args);
47
58
  case 'list':
48
59
  return cmdTabList(rest, args);
49
60
  case 'open':
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `capture tab launch [--url <url>] [--port <port>] [--headed]` — start a
3
+ * browser capture owns, and report the endpoint every other capture command can
4
+ * now be pointed at.
5
+ *
6
+ * This is the answer to "a CDP-enabled browser must be running": without it the
7
+ * only way to satisfy that precondition was a hand-rolled detached Chrome with
8
+ * an invented port and an invented scratch profile, which nothing ever reaped.
9
+ * Ownership, registry, and reaping all live in `../../browser-process.ts`; this
10
+ * leaf is the surface over them.
11
+ */
12
+ import { launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
13
+ import { type ParsedArgs } from '../../types.js';
14
+ import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
15
+
16
+ const USAGE = `capture tab launch — start a browser capture owns and report its CDP endpoint.
17
+
18
+ input:
19
+ --url <url> first page to open (default: about:blank)
20
+ --port <port> pin the debugging port (default: a free port chosen by the kernel)
21
+ --headed run with a visible window instead of headless
22
+
23
+ output: <browser-launched port=… pid=…> — the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
24
+ effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, stopped answering CDP, or gone idle.`;
25
+
26
+ /** Pure `<browser-launched>` result builder — exported for tests. */
27
+ export function buildBrowserLaunchedResult(
28
+ browser: { port: number; pid: number; executablePath: string; source: string; headless: boolean },
29
+ swept: number,
30
+ ): RenderableResult {
31
+ const sections = [
32
+ lineList([
33
+ line(text`executable `, data(browser.executablePath, 300), text` (`, data(browser.source), text`)`),
34
+ line(text`mode `, data(browser.headless ? 'headless' : 'headed')),
35
+ ]),
36
+ ];
37
+ if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, stopped answering CDP, or gone idle.`);
38
+ return {
39
+ tag: 'browser-launched',
40
+ attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
41
+ summary: line(
42
+ text`capture-owned browser on port `,
43
+ data(browser.port),
44
+ fact` (pid ${browser.pid}) is answering CDP.`,
45
+ ),
46
+ sections,
47
+ followUp: line(text`capture tab quit `, data(browser.port)),
48
+ };
49
+ }
50
+
51
+ export async function cmdTabLaunch(parsed: ParsedArgs, _args: string[]): Promise<void> {
52
+ if (parsed.help) {
53
+ console.log(USAGE);
54
+ return;
55
+ }
56
+
57
+ const swept = await sweepOwnedBrowsers();
58
+ const browser = await launchOwnedBrowser({
59
+ url: parsed.url,
60
+ port: parsed.port,
61
+ headless: !parsed.headed,
62
+ });
63
+
64
+ emitResult(
65
+ buildBrowserLaunchedResult(
66
+ // A launched browser always reports a port — `launchOwnedBrowser` only
67
+ // returns once its endpoint answered on one.
68
+ { ...browser, port: browser.port! },
69
+ swept.length,
70
+ ),
71
+ { json: parsed.json },
72
+ );
73
+ }
@@ -13,6 +13,7 @@ import {
13
13
  pickPreferredEndpoint,
14
14
  } from '../../detect.js';
15
15
  import { listTargets } from '../../targets.js';
16
+ import { ownedBrowsersByPort, sweepOwnedBrowsers } from '../../browser-process.js';
16
17
  import { type ParsedArgs } from '../../types.js';
17
18
  import {
18
19
  data,
@@ -30,8 +31,8 @@ const USAGE = `capture tab list — discover CDP endpoints and list the page tab
30
31
  input:
31
32
  --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
32
33
 
33
- output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
34
- effects: read-only — probes local CDP endpoints over HTTP/WebSocket; no page-observable writes.`;
34
+ output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
35
+ effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle — browsers capture did not start are never signalled.`;
35
36
 
36
37
  export interface TabRow {
37
38
  id: string;
@@ -45,6 +46,9 @@ export interface EndpointTabs {
45
46
  app?: string;
46
47
  /** Marked on the endpoint auto-discovery would pick for port-less commands. */
47
48
  preferred: boolean;
49
+ /** The pid of the capture-launched browser serving this endpoint, when this
50
+ * is one capture owns — absent for every foreign/user-owned endpoint. */
51
+ ownedPid?: number;
48
52
  pages: TabRow[];
49
53
  }
50
54
 
@@ -76,6 +80,7 @@ export function buildTabsResult(
76
80
  ...(ep.app !== undefined ? [line(text` — `, data(ep.app, 80))] : []),
77
81
  fact` — ${ep.pages.length} page tab(s)`,
78
82
  ...(ep.preferred ? [text` [preferred]`] : []),
83
+ ...(ep.ownedPid !== undefined ? [line(fact` [capture-owned pid=${ep.ownedPid}]`)] : []),
79
84
  );
80
85
  const rows = ep.pages.map((t) =>
81
86
  line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300)),
@@ -121,6 +126,11 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
121
126
  return;
122
127
  }
123
128
 
129
+ // The probe agents run constantly is also where abandoned owned browsers
130
+ // die: sweeping here is what keeps a launch from outliving the agent that
131
+ // made it, without any daemon in the picture.
132
+ await sweepOwnedBrowsers();
133
+ const owned = ownedBrowsersByPort();
124
134
  const reachable: EndpointTabs[] = [];
125
135
  const unreachable: UnreachableEndpoint[] = [];
126
136
 
@@ -130,6 +140,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
130
140
  reachable.push({
131
141
  port: parsed.port,
132
142
  preferred: false,
143
+ ...(owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port)!.pid } : {}),
133
144
  pages: targets
134
145
  .filter((t) => t.type === 'page')
135
146
  .map((t) => ({ id: t.id, title: t.title, url: t.url })),
@@ -149,6 +160,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
149
160
  port: ep.port,
150
161
  app: ep.app,
151
162
  preferred: ep.port === preferredPort,
163
+ ...(owned.has(ep.port) ? { ownedPid: owned.get(ep.port)!.pid } : {}),
152
164
  pages: targets
153
165
  .filter((t) => t.type === 'page')
154
166
  .map((t) => ({ id: t.id, title: t.title, url: t.url })),
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `capture tab quit [<port>] [--all]` — stop browsers capture started.
3
+ *
4
+ * Named `quit` rather than `close` deliberately: `capture tab close` closes one
5
+ * TAB inside a browser, this ends the browser process itself. It reaches only
6
+ * browsers in capture's own registry, verified by PID birth — a port capture
7
+ * never launched (the user's own browser, or one an agent started by hand)
8
+ * matches nothing here and is never signalled.
9
+ */
10
+ import { quitOwnedBrowsers, sweepOwnedBrowsers, type ReapedBrowser } from '../../browser-process.js';
11
+ import { invalidInput } from '../../../errors.js';
12
+ import { type ParsedArgs } from '../../types.js';
13
+ import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
14
+
15
+ const USAGE = `capture tab quit — stop a browser capture started.
16
+
17
+ input:
18
+ <port> the port of a capture-launched browser (as reported by tab launch / tab list)
19
+ --all stop every browser capture launched
20
+
21
+ output: <browser-quit stopped=…> — one line per stopped browser (port, pid) and the reason it ended.
22
+ effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, verified by process-birth identity, and removes their private profiles. A browser capture did not start is never signalled — a port with no capture record stops nothing.`;
23
+
24
+ /** Pure `<browser-quit>` result builder — exported for tests. */
25
+ export function buildBrowserQuitResult(stopped: readonly ReapedBrowser[], selector: string): RenderableResult {
26
+ return {
27
+ tag: 'browser-quit',
28
+ attrs: { stopped: stopped.length },
29
+ summary:
30
+ stopped.length === 0
31
+ ? line(text`no capture-launched browser matched `, data(selector), text`; nothing was signalled.`)
32
+ : fact`stopped ${stopped.length} capture-launched browser(s).`,
33
+ sections:
34
+ stopped.length === 0
35
+ ? []
36
+ : [
37
+ lineList(
38
+ stopped.map((browser) =>
39
+ line(
40
+ text`port `,
41
+ data(browser.port ?? 'unstarted'),
42
+ fact` pid ${browser.pid} `,
43
+ data(browser.reason),
44
+ ),
45
+ ),
46
+ ),
47
+ ],
48
+ };
49
+ }
50
+
51
+ export async function cmdTabQuit(parsed: ParsedArgs, _args: string[]): Promise<void> {
52
+ if (parsed.help) {
53
+ console.log(USAGE);
54
+ return;
55
+ }
56
+
57
+ const token = parsed.positional[0];
58
+ if (!parsed.all && token === undefined) {
59
+ throw invalidInput('received: no target; expected: capture tab quit <port> or capture tab quit --all.', 'missing_argument');
60
+ }
61
+ if (parsed.all && token !== undefined) {
62
+ throw invalidInput('received: both a port and --all; expected: one or the other.');
63
+ }
64
+ if (token !== undefined && !/^\d+$/.test(token)) {
65
+ throw invalidInput(`Invalid port: ${token}.`);
66
+ }
67
+ const port = token === undefined ? undefined : Number(token);
68
+
69
+ // The sweep runs first so a browser that already exited is reported as
70
+ // reaped rather than as "no match" for the port the caller remembered.
71
+ const swept = await sweepOwnedBrowsers();
72
+ const stopped = await quitOwnedBrowsers({ port, all: parsed.all });
73
+ const matchedSweep = swept.filter((browser) => parsed.all || browser.port === port);
74
+
75
+ emitResult(buildBrowserQuitResult([...stopped, ...matchedSweep], parsed.all ? '--all' : String(port)), {
76
+ json: parsed.json,
77
+ });
78
+ }
@@ -164,8 +164,6 @@ export class HARRecorder {
164
164
  private readonly recordedKeys = new Set<string>();
165
165
  /** Snapshot mode only — streaming mode retains no materialized values. */
166
166
  private readonly materializedByKey = new Map<string, Materialized>();
167
- /** Last terminal remains comparable until this CDP id opens its next generation. */
168
- private readonly closedTerminals = new Map<string, Terminal>();
169
167
  /** Sockets opened while this recorder is attached — CDP does not replay creation events for pre-existing sockets. */
170
168
  private readonly webSockets = new Map<string, WebSocketConnection>();
171
169
  private readonly bodyTasks = new Set<Promise<void>>();
@@ -245,11 +243,13 @@ export class HARRecorder {
245
243
  const hasRedirect = 'redirectResponse' in params && params.redirectResponse !== undefined;
246
244
  if (previous && !hasRedirect) fail('duplicate active requestId without redirectResponse');
247
245
  if (hasRedirect && previous) {
248
- const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse');
249
- if (redirect.status < 300 || redirect.status > 399) fail('redirectResponse.status');
246
+ // CDP uses status 0 when a redirect response is observable but never
247
+ // reached HTTP (for example, an extension redirect during navigation).
248
+ // Preserve that factual response rather than aborting the whole session.
249
+ const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse', true);
250
+ if (redirect.status !== 0 && (redirect.status < 300 || redirect.status > 399)) fail('redirectResponse.status');
250
251
  this.assignTerminal(previous, { kind: 'redirect', timestamp: monotonic }, redirect);
251
252
  }
252
- this.closedTerminals.delete(requestId);
253
253
  const generation = this.nextGenerationByRequestId.get(requestId) ?? 1;
254
254
  this.nextGenerationByRequestId.set(requestId, generation + 1);
255
255
  this.activeByRequestId.set(requestId, {
@@ -338,11 +338,11 @@ export class HARRecorder {
338
338
  });
339
339
  }
340
340
 
341
- private parseResponse(value: unknown, name: string): RedirectResponse {
341
+ private parseResponse(value: unknown, name: string, allowNoHttpStatus = false): RedirectResponse {
342
342
  if (!isPlainObject(value)) fail(name);
343
343
  nonempty(value.url, `${name}.url`);
344
344
  const status = finite(value.status, `${name}.status`);
345
- if (!Number.isInteger(status) || status < 100 || status > 599) fail(`${name}.status`);
345
+ if (!Number.isInteger(status) || status < 0 || status > 599 || (status < 100 && !(allowNoHttpStatus && status === 0))) fail(`${name}.status`);
346
346
  return { status, headers: headers(value.headers, `${name}.headers`) };
347
347
  }
348
348
 
@@ -366,11 +366,13 @@ export class HARRecorder {
366
366
  const requestId = this.eventRequestId(params);
367
367
  if (!requestId) return;
368
368
  const current = this.activeByRequestId.get(requestId);
369
- const closed = this.closedTerminals.has(requestId);
370
- if (!current && !closed) return;
369
+ // Chromium can follow a loadingFailed with loadingFinished for the same
370
+ // request (for example, a blocked iframe during navigation). Once the
371
+ // first terminal materializes its generation, a later terminal cannot be
372
+ // assigned safely, so the first factual terminal remains authoritative.
373
+ if (!current) return;
371
374
  const event = params as Record<string, unknown>;
372
375
  const terminal: Terminal = { kind: 'finished', timestamp: finite(event.timestamp, 'timestamp'), encodedDataLength: finite(event.encodedDataLength, 'encodedDataLength') };
373
- if (!current) return this.compareClosedTerminal(requestId, terminal);
374
376
  if (!current.response) fail('loadingFinished without responseReceived');
375
377
  this.assignTerminal(current, terminal);
376
378
  }
@@ -379,8 +381,7 @@ export class HARRecorder {
379
381
  const requestId = this.eventRequestId(params);
380
382
  if (!requestId) return;
381
383
  const current = this.activeByRequestId.get(requestId);
382
- const closed = this.closedTerminals.has(requestId);
383
- if (!current && !closed) return;
384
+ if (!current) return;
384
385
  const event = params as Record<string, unknown>;
385
386
  const terminal: Terminal = {
386
387
  kind: 'failed',
@@ -390,22 +391,15 @@ export class HARRecorder {
390
391
  blockedReason: event.blockedReason === undefined ? null : typeof event.blockedReason === 'string' ? event.blockedReason : fail('blockedReason'),
391
392
  resourceType: event.type === undefined ? null : typeof event.type === 'string' ? event.type : fail('type'),
392
393
  };
393
- if (!current) return this.compareClosedTerminal(requestId, terminal);
394
394
  this.assignTerminal(current, terminal);
395
395
  }
396
396
 
397
- private compareClosedTerminal(requestId: string, terminal: Terminal): void {
398
- const previous = this.closedTerminals.get(requestId);
399
- if (previous && !same(previous, terminal)) fail('conflicting terminal event');
400
- }
401
-
402
397
  private assignTerminal(current: Generation, terminal: Terminal, redirectResponse?: RedirectResponse): void {
403
398
  if (current.terminal) {
404
- if (!same(current.terminal, terminal)) fail('conflicting terminal event');
399
+ if (!same(current.terminal, terminal)) fail(`conflicting terminal event for requestId ${current.requestId} generation ${current.generation}`);
405
400
  return;
406
401
  }
407
402
  current.terminal = terminal;
408
- this.closedTerminals.set(current.requestId, terminal);
409
403
  if (redirectResponse) {
410
404
  current.redirectResponse = redirectResponse;
411
405
  this.materialize(current, { state: 'not_applicable', reason: 'redirect' });
@@ -7,10 +7,9 @@
7
7
  * shared with `styles.json`/`media.json` per the U04 contract — no
8
8
  * coordination with those files is needed.
9
9
  *
10
- * "geometry ids ... when possible": U07's `geometry.json` is a
11
- * concurrently-written sibling collector (`Promise.all` in `snapshot.ts`,
12
- * no cross-collector read ordering) that this file must not depend on or
13
- * read at capture time. Read here as "attach a `rect` fact directly per AX
10
+ * "geometry ids ... when possible": `geometry.json` is a separate
11
+ * collector artifact that this file does not depend on or read at capture
12
+ * time. Read here as "attach a `rect` fact directly per AX
14
13
  * node via a best-effort `DOM.getBoxModel` lookup" rather than a join
15
14
  * against `geometry.json` — a deliberate, bounded reading documented in
16
15
  * the U08 build-plan report, not a defect.