@crouton-kit/capture 1.4.2 → 1.4.11

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 (110) hide show
  1. package/README.md +187 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -8,6 +8,7 @@ import {
8
8
  validateHarFile,
9
9
  type HarFile,
10
10
  type HAREntry,
11
+ type HttpHAREntry,
11
12
  type Header,
12
13
  type IncompleteLifecycle,
13
14
  } from '../har-manager.js';
@@ -27,7 +28,7 @@ import {
27
28
  startSessionLogTailer,
28
29
  stopSessionLogTailers,
29
30
  } from './log-tailer.js';
30
- import { teardownAnyLiveRecorderAtSessionStop, isTerminalRecStopFailure } from '../cdp/motion/recorder.js';
31
+ import { stopAndReapCollectorHostAtSessionStop } from '../cdp/host/lifecycle.js';
31
32
  import {
32
33
  emitResult,
33
34
  fact,
@@ -45,13 +46,15 @@ import {
45
46
  rejectLogLabel,
46
47
  writeJsonPrivate,
47
48
  readPrivateFile,
49
+ registerArtifactRoot,
50
+ registeredArtifactRoots,
48
51
  removeArtifactTree,
49
52
  type SnapMeta,
50
53
  type RecMeta,
51
54
  } from './artifacts.js';
52
55
  import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
53
56
  import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
54
- import { redactUrlCredentials } from './har-redact.js';
57
+ import { redactHeaderCredential, redactUrlCredentials } from './har-redact.js';
55
58
 
56
59
  type Session = ActiveSessionState;
57
60
 
@@ -64,11 +67,14 @@ interface BundleManifest {
64
67
  shots: Array<{ name: string; path: string }>;
65
68
  har: { id: string; path: string; entryCount: number } | null;
66
69
  logs: Array<{ name: string; path: string; lines: number }>;
70
+ reports: Array<{ id: string; path: string; url: string; finalUrl: string; preset: 'mobile' | 'desktop'; categories: string[]; lighthouse: string | null; axe: string | null }>;
67
71
  other: Array<{ name: string; path: string }>;
68
72
  /** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
69
73
  snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
70
74
  /** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
71
75
  recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string; viewportRestored: boolean | null }>;
76
+ /** Finalized network mocks collected from `network/mocks/{id}/meta.json`. */
77
+ mocks?: Array<{ id: string; path: string; completion: string; rules: number; paused: number; matched: number; releasedUnmatched: number }>;
72
78
  /** Retry outcomes for viewport obligations retained by failed recorder starts. */
73
79
  pendingViewportRestorations: Array<{ recId: string; viewportRestored: boolean | null }>;
74
80
  }
@@ -99,9 +105,19 @@ function isBundleManifest(value: unknown): value is BundleManifest {
99
105
  && Array.isArray(record.shots) && record.shots.every(isNamedPathEntry)
100
106
  && validHar
101
107
  && Array.isArray(record.logs) && record.logs.every(item => isNamedPathEntry(item) && typeof (item as unknown as Record<string, unknown>).lines === 'number')
108
+ && Array.isArray(record.reports) && record.reports.every(item => !!item && typeof item === 'object'
109
+ && typeof (item as Record<string, unknown>).id === 'string'
110
+ && typeof (item as Record<string, unknown>).path === 'string'
111
+ && typeof (item as Record<string, unknown>).url === 'string'
112
+ && typeof (item as Record<string, unknown>).finalUrl === 'string'
113
+ && ((item as Record<string, unknown>).preset === 'mobile' || (item as Record<string, unknown>).preset === 'desktop')
114
+ && Array.isArray((item as Record<string, unknown>).categories) && ((item as Record<string, unknown>).categories as unknown[]).every(category => typeof category === 'string')
115
+ && ((item as Record<string, unknown>).lighthouse === null || typeof (item as Record<string, unknown>).lighthouse === 'string')
116
+ && ((item as Record<string, unknown>).axe === null || typeof (item as Record<string, unknown>).axe === 'string'))
102
117
  && Array.isArray(record.other) && record.other.every(isNamedPathEntry)
103
118
  && Array.isArray(record.snaps) && record.snaps.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
104
119
  && Array.isArray(record.recs) && record.recs.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
120
+ && (record.mocks === undefined || Array.isArray(record.mocks) && record.mocks.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string' && typeof (item as Record<string, unknown>).completion === 'string'))
105
121
  && Array.isArray(record.pendingViewportRestorations);
106
122
  }
107
123
 
@@ -125,7 +141,7 @@ function readBundleManifest(bundlePath: string): BundleManifest {
125
141
  }
126
142
 
127
143
  /** The bundle-manifest section keys `session view --filter` can address. */
128
- type SectionKey = 'shots' | 'har' | 'logs' | 'snaps' | 'recs' | 'other';
144
+ type SectionKey = 'shots' | 'har' | 'logs' | 'reports' | 'snaps' | 'recs' | 'mocks' | 'other';
129
145
 
130
146
  // The `--filter <name>` query names map onto manifest section keys. Most are
131
147
  // identical; `measure`/`motion` are the query-facing names for the `snaps`/
@@ -135,8 +151,10 @@ const VIEW_FILTERS: Record<string, SectionKey> = {
135
151
  shots: 'shots',
136
152
  har: 'har',
137
153
  logs: 'logs',
154
+ lighthouse: 'reports',
138
155
  measure: 'snaps',
139
156
  motion: 'recs',
157
+ mocks: 'mocks',
140
158
  other: 'other',
141
159
  };
142
160
 
@@ -145,13 +163,15 @@ const SECTION_LABELS: Record<SectionKey, string> = {
145
163
  shots: 'shots',
146
164
  har: 'har',
147
165
  logs: 'logs',
166
+ reports: 'lighthouse',
148
167
  snaps: 'measure',
149
168
  recs: 'motion',
169
+ mocks: 'mocks',
150
170
  other: 'other',
151
171
  };
152
172
 
153
- function sessionDir(id: string): string {
154
- return path.join(CAPTURE_ROOT, id);
173
+ function sessionDir(id: string, artifactRoot = CAPTURE_ROOT): string {
174
+ return path.join(artifactRoot, id);
155
175
  }
156
176
 
157
177
  function isSessionStopped(session: Session): boolean {
@@ -178,9 +198,9 @@ export interface OneshotSession {
178
198
  * bundled/torn down by `session stop`; it accumulates under `/tmp` the same as
179
199
  * any other session dir until the OS reaps `/tmp`.
180
200
  */
181
- export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
201
+ export function createOneshotSession(kind: 'measure' | 'motion' | 'page', artifactDir?: string): OneshotSession {
182
202
  const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
183
- const dir = sessionDir(id);
203
+ const dir = sessionDir(id, artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(artifactDir));
184
204
  const artifactsDir = kind === 'measure'
185
205
  ? path.join(dir, 'measure', 'snaps')
186
206
  : kind === 'motion'
@@ -235,8 +255,36 @@ function collectRecs(dir: string): BundleManifest['recs'] {
235
255
  .map((name) => readRecMeta(path.join(recsRoot, name), name));
236
256
  }
237
257
 
238
- function sessionMetaPath(id: string): string {
239
- return path.join(sessionDir(id), '.session.json');
258
+ function collectMocks(dir: string): NonNullable<BundleManifest['mocks']> {
259
+ const mocksRoot = path.join(dir, 'network', 'mocks');
260
+ if (!fs.existsSync(mocksRoot)) return [];
261
+ return fs.readdirSync(mocksRoot).flatMap((name) => {
262
+ const mockDir = path.join(mocksRoot, name);
263
+ const metaPath = path.join(mockDir, 'meta.json');
264
+ if (!fs.existsSync(metaPath)) return [];
265
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
266
+ return [{ id: typeof meta.id === 'string' ? meta.id : name, path: mockDir, completion: typeof meta.completion === 'string' ? meta.completion : 'partial', rules: typeof meta.rules === 'number' ? meta.rules : 0, paused: typeof meta.paused === 'number' ? meta.paused : 0, matched: typeof meta.matched === 'number' ? meta.matched : 0, releasedUnmatched: typeof meta.releasedUnmatched === 'number' ? meta.releasedUnmatched : 0 }];
267
+ });
268
+ }
269
+
270
+ function readLighthouseReportMeta(reportDir: string, fallbackId: string): BundleManifest['reports'][number] {
271
+ const meta = JSON.parse(fs.readFileSync(path.join(reportDir, 'meta.json'), 'utf-8')) as Partial<BundleManifest['reports'][number]>;
272
+ if (typeof meta.url !== 'string' || typeof meta.finalUrl !== 'string' || (meta.preset !== 'mobile' && meta.preset !== 'desktop') || !Array.isArray(meta.categories) || !meta.categories.every(category => typeof category === 'string') || (meta.lighthouse !== null && typeof meta.lighthouse !== 'string') || (meta.axe !== null && typeof meta.axe !== 'string')) {
273
+ throw captureError('artifact', 'invalid_lighthouse_report_metadata', `Lighthouse report metadata at ${reportDir} is invalid.`);
274
+ }
275
+ return { id: typeof meta.id === 'string' ? meta.id : fallbackId, path: reportDir, url: meta.url, finalUrl: meta.finalUrl, preset: meta.preset, categories: meta.categories, lighthouse: meta.lighthouse, axe: meta.axe };
276
+ }
277
+
278
+ function collectLighthouseReports(dir: string): BundleManifest['reports'] {
279
+ const reportsRoot = path.join(dir, 'lighthouse', 'reports');
280
+ if (!fs.existsSync(reportsRoot)) return [];
281
+ return fs.readdirSync(reportsRoot)
282
+ .filter((name) => fs.existsSync(path.join(reportsRoot, name, 'meta.json')))
283
+ .map((name) => readLighthouseReportMeta(path.join(reportsRoot, name), name));
284
+ }
285
+
286
+ function sessionMetaPath(id: string, artifactRoot = CAPTURE_ROOT): string {
287
+ return path.join(sessionDir(id, artifactRoot), '.session.json');
240
288
  }
241
289
 
242
290
  /** Outcome of reading one persisted record without failing the caller: either
@@ -253,8 +301,8 @@ type SessionRead =
253
301
  * A missing file is not a read outcome here; only `readSession` (targeted
254
302
  * commands) turns that into `unknown_session`.
255
303
  */
256
- function tryReadSession(id: string): SessionRead {
257
- const metaPath = sessionMetaPath(id);
304
+ function tryReadSession(id: string, artifactRoot = CAPTURE_ROOT): SessionRead {
305
+ const metaPath = sessionMetaPath(id, artifactRoot);
258
306
  const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
259
307
  let parsed: unknown;
260
308
  try {
@@ -277,16 +325,21 @@ function tryReadSession(id: string): SessionRead {
277
325
  * unbranded via `fact` interpolation of undefined fields).
278
326
  */
279
327
  function readSession(id: string): Session {
280
- const metaPath = sessionMetaPath(id);
281
- let read: SessionRead;
282
- try {
283
- read = tryReadSession(id);
284
- } catch (error) {
285
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
286
- throw error;
328
+ const active = getActiveSession();
329
+ if (active?.sessionId === id) return active;
330
+ for (const artifactRoot of registeredArtifactRoots()) {
331
+ const metaPath = sessionMetaPath(id, artifactRoot);
332
+ let read: SessionRead;
333
+ try {
334
+ read = tryReadSession(id, artifactRoot);
335
+ } catch (error) {
336
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
337
+ throw error;
338
+ }
339
+ if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
340
+ return read.session;
287
341
  }
288
- if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
289
- return read.session;
342
+ throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
290
343
  }
291
344
 
292
345
  function generateId(): string {
@@ -330,12 +383,11 @@ export async function waitForPageLoad(
330
383
 
331
384
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
332
385
  export const COMMAND_BLOCK = `<command name="session">
333
- the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab; once it has one, every command auto-targets it
334
- use when starting scoped work against a page: start with --url or --target, then every other capture command needs no --target/--port threading
335
- start · stop · list · view · har · log — \`capture session -h\`
386
+ the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab, and once it has one, every command auto-targets it
387
+ use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
336
388
  </command>`;
337
389
 
338
- const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] — open or adopt a tab, record HAR, and set the active capture context.
390
+ const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>] — open or adopt a tab, record HAR, and set the active capture context.
339
391
 
340
392
  input:
341
393
  --url <url> Absolute URL to open in a fresh tab through CDP's
@@ -352,6 +404,7 @@ input:
352
404
  lifetime, so browser-level state (permission grants,
353
405
  ServiceWorker enablement) survives across separate commands.
354
406
  --port <n> CDP port to attach to; default is auto-detected across localhost.
407
+ --artifact-dir <path> Absolute root for this session bundle and every later session-owned artifact.
355
408
 
356
409
  Output:
357
410
  One <session id=… path=… [url=…] [target=… port=…]> block: the session id,
@@ -359,7 +412,7 @@ Output:
359
412
  applicable. --json mirrors the same fields.
360
413
 
361
414
  effects:
362
- Creates a private session dir with shots/, starts a HAR recording, opens a
415
+ Creates a private session dir under --artifact-dir when supplied (otherwise the configured capture root), with shots/, starts a HAR recording, opens a
363
416
  fresh tab only when --url is supplied or adopts the resolved --target tab,
364
417
  optionally holds a CDP bridge, and registers the session as the active capture
365
418
  context so subsequent commands auto-target its tab.`;
@@ -371,7 +424,7 @@ input:
371
424
 
372
425
  Output:
373
426
  One <session-stopped id=… path=…> block: the bundle manifest path plus
374
- counts (shots, HAR entries, logs, measure snaps, motion recs, other). --json
427
+ counts (shots, HAR entries, logs, lighthouse reports, measure snaps, motion recs, mocks, other). --json
375
428
  mirrors the same fields.
376
429
 
377
430
  effects:
@@ -398,13 +451,10 @@ const VIEW_USAGE = `capture session view <session-id> [--filter <section>] — r
398
451
 
399
452
  input:
400
453
  <session-id> A stopped session (from \`capture session list\`).
401
- --filter <section> Show only one section: shots, har, logs, measure,
402
- motion, or other. measure -> manifest snaps, motion ->
403
- manifest recs.
454
+ --filter <section> Show only one section: shots, har, logs, lighthouse, measure, motion, mocks, or other. lighthouse -> manifest reports, measure -> manifest snaps, motion -> manifest recs.
404
455
 
405
456
  Output:
406
- One <session id=… path=… [filter=…]> block: the manifest sections (shots,
407
- har, logs, measure snaps, motion recs, other), or a single section under
457
+ One <session id=… path=… [filter=…]> block: the manifest sections (shots, har, logs, lighthouse reports, measure snaps, motion recs, mocks, other), or a single section under
408
458
  --filter. --json mirrors the same fields.
409
459
 
410
460
  effects:
@@ -414,6 +464,8 @@ effects:
414
464
  * thousands of records; an unbounded list buries the row the agent came for.
415
465
  * `--limit` overrides it in either direction. */
416
466
  const DEFAULT_HAR_ROWS = 100;
467
+ /** `--full` includes capped body detail, so its unfiltered selection has a lower bound. */
468
+ const DEFAULT_FULL_HAR_ROWS = 10;
417
469
 
418
470
  const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
419
471
 
@@ -423,33 +475,42 @@ input:
423
475
  stopped one reads the bundled har.json.
424
476
  --filter-url <pattern> substring or regex match on the request URL (matched
425
477
  against the URL as captured, before redaction)
478
+ --body <url> --out <path> write one exact, complete captured response body to
479
+ the caller-owned file. <url> matches exactly one
480
+ completed transaction by its captured URL; the result
481
+ reports its transaction id, byte count, and encoding.
426
482
  --filter-status <spec> exact status (100-599), one-digit class prefix
427
483
  (1-5, e.g. 4 = every 4xx), or ordered range
428
484
  (e.g. 400-499); any other token is invalid_filter.
429
485
  A record with no observed response never matches.
430
486
  --filter-method <method> HTTP method (GET, POST, …)
431
- --limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS}
432
- --full inline per-record detail (headers, post data,
433
- response body — escaped and capped); bodies are
434
- never inlined without it
487
+ --limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS} (${DEFAULT_FULL_HAR_ROWS} with --full)
488
+ --full inline full detail (headers, post data, response body — escaped and capped); filters resolve first, then structured responses sort before unstructured records so the bounded selection reaches API-shaped bodies before page assets; identical headers shared by every rendered record are listed once, while differing headers stay with their record; bodies are never inlined without it
435
489
 
436
490
  output:
437
491
  One <session-har id=… path=… source=live|bundle entries=… incomplete=…
438
492
  total=… total-incomplete=… [truncated=true]> block listing BOTH populations
439
- in one chronological selection: completed entries (method, status, URL, body
440
- size, start time) and the incomplete lifecycle records the recorder retained
441
- for requests that never completed — rendered as "incomplete: <kind>" (an
442
- out-of-order clock also names its violation, e.g.
443
- invalid_clock_order/terminal_before_response). The path attribute is the HAR
493
+ in chronological order by default (or structured responses first under \`--full\`): completed entries (method, status, URL, body
494
+ size, start time, HAR duration, response-end time) and the incomplete
495
+ lifecycle records the recorder retained for requests whose response never
496
+ completed — rendered as "incomplete: <kind>" plus their honest unavailable
497
+ timing state (an out-of-order request clock also names its violation, e.g.
498
+ invalid_clock_order/terminal_before_request). The path attribute is the HAR
444
499
  file's absolute path — the full-fidelity pointer, which holds every record
445
- including the ones this list bounds away. --json mirrors the same fields.
500
+ including the ones this list bounds away. \`--body\` writes one complete body to
501
+ \`--out\` instead of listing rows; it refuses an ambiguous, absent, or truncated
502
+ body rather than writing a partial result. --json mirrors the same fields.
446
503
  Credential-like query parameter values (key, token, secret, signature, auth,
447
- password, oauth code, …) render as REDACTED; the HAR file keeps the real
448
- value. WebSockets opened while a command was recording appear as entries with
504
+ password, oauth code, …) render as REDACTED; credential-valued headers render
505
+ as redacted · <N> chars. The HAR file keeps the real values. WebSockets opened
506
+ while a command was recording appear as entries with
449
507
  _resourceType "websocket" and their frames in _webSocketMessages (capped at
450
508
  200 frames/socket, 4KB/frame); sockets opened before recording started are
451
509
  not visible.
452
510
 
511
+ boundary:
512
+ Reports recorded transactions and does not change responses. For a counterfactual response, use \`capture tab mock start\`.
513
+
453
514
  effects:
454
515
  None — reads recorded HAR data only.`;
455
516
 
@@ -474,20 +535,16 @@ effects:
474
535
  the identity-bearing tailer record is registered in the session metadata.`;
475
536
 
476
537
  function printSessionHelp(): void {
477
- console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open or adopt a tab.
478
-
479
- An active session with a tab auto-targets it and auto-appends recorded traffic — no
480
- --target/--har threading. \`stop\` finalizes the session and writes its bundle
481
- manifest; \`view\` reads that manifest back.
482
-
483
- <subcommand name="start" args="[--url <url> | --target <tab-id>] [--hold] [--port <n>]" whenToUse="open or adopt a tab, start HAR, and set the active capture context"/>
484
- <subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
485
- <subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
486
- <subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
487
- <subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — completed entries and retained incomplete lifecycle records, from the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
488
- <subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
489
-
490
- capture session <leaf> -h Per-leaf usage`);
538
+ console.log(`<command name="session" description="the artifact container: records HTTP traffic and bundles artifacts for one page">
539
+ <model>An active session with a tab auto-targets it and appends recorded traffic; its finalized bundle remains available for readback.</model>
540
+ <subcommand name="start" description="open or adopt a tab and start a session" whenToUse="Use when beginning scoped work against one page and collecting its artifacts."/>
541
+ <subcommand name="stop" description="finalize a session bundle" whenToUse="Use when scoped capture is complete and its artifacts must become a bundle."/>
542
+ <subcommand name="list" description="active and stopped sessions" whenToUse="Use to find a session to inspect or stop."/>
543
+ <subcommand name="view" description="one stopped session's bundle" whenToUse="Use to inspect the artifacts collected by a finalized session."/>
544
+ <subcommand name="har" description="recorded HTTP traffic" whenToUse="Use to inspect requests from a running session or a finalized bundle."/>
545
+ <subcommand name="log" description="tail an external log into a session" whenToUse="Use to add external process output to a session's bundled artifacts."/>
546
+ <subcommand name="collectors" description="what is collecting right now" whenToUse="Use to see live traces, recordings, heap snapshots, and mocks before starting one that would conflict."/>
547
+ </command>`);
491
548
  }
492
549
 
493
550
  // ============================================================================
@@ -503,7 +560,7 @@ manifest; \`view\` reads that manifest back.
503
560
  export interface SessionStartWorld {
504
561
  createHar(dir: string): Promise<string>;
505
562
  deleteHar(harId: string): Promise<void>;
506
- detectCdpPort(): Promise<number>;
563
+ detectCdpPort(): Promise<number | { port: number; app: string; selectionReason: string }>;
507
564
  openTab(port: number, url: string): Promise<CDPTarget>;
508
565
  findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
509
566
  closeTarget(port: number, targetId: string): Promise<void>;
@@ -520,8 +577,8 @@ const productionStartWorld: SessionStartWorld = {
520
577
  },
521
578
  deleteHar: deleteHarRecording,
522
579
  async detectCdpPort() {
523
- const { detectCdpPort } = await import('../cdp.js');
524
- return detectCdpPort();
580
+ const { detectCdpEndpoint } = await import('../cdp/detect.js');
581
+ return detectCdpEndpoint();
525
582
  },
526
583
  async openTab(port, url) {
527
584
  const { openTab } = await import('../cdp.js');
@@ -604,7 +661,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
604
661
  return;
605
662
  }
606
663
 
607
- if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>]')) return;
664
+ if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>]')) return;
608
665
 
609
666
  return withSessionScopeLifecycle(async () => {
610
667
  const active = getActiveSession();
@@ -623,7 +680,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
623
680
  const requestedTarget = parsed.target ?? null;
624
681
  const hold = parsed.hold === true;
625
682
  const id = generateId();
626
- const dir = sessionDir(id);
683
+ const dir = sessionDir(id, parsed.artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(parsed.artifactDir));
627
684
 
628
685
  // Every effect below is owned the instant it succeeds and released in exact
629
686
  // reverse order if any later step throws. Nothing is committed outside this
@@ -635,6 +692,8 @@ async function start(parsed: ParsedArgs): Promise<void> {
635
692
  let bridgeSocket: string | null = null;
636
693
  let bridgePid: number | null = null;
637
694
  let cdpPort: number | null = null;
695
+ let selectedBrowser: string | null = null;
696
+ let endpointSelectionReason: string | null = null;
638
697
 
639
698
  try {
640
699
  ensurePrivateDir(path.join(dir, 'shots'));
@@ -644,8 +703,13 @@ async function start(parsed: ParsedArgs): Promise<void> {
644
703
  const acquiredHarId = harId;
645
704
  acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
646
705
 
647
- if (url || requestedTarget || hold) {
648
- cdpPort = parsed.port ?? await startWorld.detectCdpPort();
706
+ if (parsed.port !== undefined) {
707
+ cdpPort = parsed.port;
708
+ } else if (url || requestedTarget || hold) {
709
+ const detected = await startWorld.detectCdpPort();
710
+ cdpPort = typeof detected === 'number' ? detected : detected.port;
711
+ selectedBrowser = typeof detected === 'number' ? null : detected.app;
712
+ endpointSelectionReason = typeof detected === 'number' ? null : detected.selectionReason;
649
713
  }
650
714
 
651
715
  if (requestedTarget) {
@@ -715,6 +779,13 @@ async function start(parsed: ParsedArgs): Promise<void> {
715
779
 
716
780
  const rows: FactLine[] = [fact`bundle dir: ${dir}`];
717
781
  if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
782
+ if (cdpPort !== null) rows.push(
783
+ parsed.portSource === 'env'
784
+ ? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.`
785
+ : parsed.port !== undefined
786
+ ? fact`CDP endpoint on port ${cdpPort} selected by explicit --port; --port overrides endpoint selection and --target overrides tab selection.`
787
+ : fact`${selectedBrowser ?? 'CDP endpoint'} on port ${cdpPort} auto-selected because it ${endpointSelectionReason ?? 'was selected by automatic endpoint detection'}; capture-launched browser recency does not affect selection. --port overrides endpoint selection and --target overrides tab selection.`,
788
+ );
718
789
  if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
719
790
  if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
720
791
  if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
@@ -1022,8 +1093,44 @@ function harUrl(url: string): FactLine {
1022
1093
  return data(redactUrlCredentials(url), 300);
1023
1094
  }
1024
1095
 
1025
- /** One selection-list row: method, status, URL, body size, start time. Body
1026
- * content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
1096
+ function responseEndDateTime(
1097
+ requestWallTime: number,
1098
+ requestMonotonic: number,
1099
+ terminalMonotonic: number,
1100
+ ): string | null {
1101
+ const end = new Date((requestWallTime + terminalMonotonic - requestMonotonic) * 1000);
1102
+ return Number.isNaN(end.getTime()) ? null : end.toISOString();
1103
+ }
1104
+
1105
+ function completedTiming(e: HAREntry): FactLine {
1106
+ const { clocks, terminal } = e._capture;
1107
+ if (terminal.kind === 'failed') {
1108
+ return fact`duration ${e.time} ms — response incomplete: failed`;
1109
+ }
1110
+ if (terminal.kind === 'redirect' && e.response.status === 0) {
1111
+ return fact`duration ${e.time} ms — response incomplete: redirect without HTTP response`;
1112
+ }
1113
+ const ended = responseEndDateTime(clocks.requestWallTime, clocks.requestMonotonic, clocks.terminalMonotonic);
1114
+ return ended === null
1115
+ ? fact`duration ${e.time} ms — response end unavailable`
1116
+ : fact`duration ${e.time} ms — response ended ${ended}`;
1117
+ }
1118
+
1119
+ function incompleteTiming(record: IncompleteLifecycle): FactLine {
1120
+ if (record.kind === 'stopped_during_body') {
1121
+ const { requestWallTime, requestMonotonic, terminalMonotonic } = record._capture;
1122
+ const duration = (terminalMonotonic - requestMonotonic) * 1000;
1123
+ const ended = responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotonic);
1124
+ return ended === null
1125
+ ? fact`duration ${duration} ms — response end unavailable`
1126
+ : fact`duration ${duration} ms — response ended ${ended}`;
1127
+ }
1128
+ if (record.kind === 'invalid_clock_order') {
1129
+ return fact`timing unavailable: ${incompleteLabel(record)}`;
1130
+ }
1131
+ return text`response incomplete: terminal event never observed`;
1132
+ }
1133
+
1027
1134
  function harEntryRow(e: HAREntry): FactLine {
1028
1135
  const bodyText = e.response.content?.text;
1029
1136
  const sizePart = typeof bodyText === 'string'
@@ -1034,13 +1141,11 @@ function harEntryRow(e: HAREntry): FactLine {
1034
1141
  harUrl(e.request.url),
1035
1142
  text` — `,
1036
1143
  sizePart,
1037
- fact` — started ${e.startedDateTime}`,
1144
+ fact` — started ${e.startedDateTime} — `,
1145
+ completedTiming(e),
1038
1146
  );
1039
1147
  }
1040
1148
 
1041
- /** One selection-list row for an incomplete lifecycle record: it names the
1042
- * kind (and clock violation) in place of the body size a completed entry
1043
- * reports, so a row is never mistaken for finished traffic. */
1044
1149
  function harIncompleteRow(record: IncompleteLifecycle): FactLine {
1045
1150
  const response = incompleteResponse(record);
1046
1151
  return line(
@@ -1048,22 +1153,76 @@ function harIncompleteRow(record: IncompleteLifecycle): FactLine {
1048
1153
  harUrl(record.request.url),
1049
1154
  text` — `,
1050
1155
  fact`incomplete: ${incompleteLabel(record)}`,
1051
- fact` — started ${record.startedDateTime}`,
1156
+ fact` — started ${record.startedDateTime} — `,
1157
+ incompleteTiming(record),
1052
1158
  );
1053
1159
  }
1054
1160
 
1055
- /** `--full` inline detail for one entry: headers, post data, and response
1056
- * body — every value escaped and capped through data()/fact. */
1057
- function harEntryDetail(e: HAREntry, index: number): FactLine {
1161
+ function harHeader(prefix: 'req' | 'res', header: Header): FactLine {
1162
+ return fact` ${prefix} ${header.name}: ${redactHeaderCredential(header.name, header.value)}`;
1163
+ }
1164
+
1165
+ interface SharedHarHeaders {
1166
+ request?: readonly Header[];
1167
+ response?: readonly Header[];
1168
+ }
1169
+
1170
+ function headersEqual(left: readonly Header[], right: readonly Header[]): boolean {
1171
+ return left.length === right.length && left.every((header, index) => header.name === right[index]?.name && header.value === right[index]?.value);
1172
+ }
1173
+
1174
+ function requestHeaders(item: HarSelection): readonly Header[] {
1175
+ return item.kind === 'complete' ? item.entry.request.headers ?? [] : item.record.request.headers ?? [];
1176
+ }
1177
+
1178
+ function responseHeaders(item: HarSelection): readonly Header[] | undefined {
1179
+ if (item.kind === 'complete') return item.entry.response.headers ?? [];
1180
+ return incompleteResponse(item.record)?.headers;
1181
+ }
1182
+
1183
+ function isStructuredResponse(item: HarSelection): boolean {
1184
+ if (item.kind !== 'complete') return false;
1185
+ const contentType = responseHeaders(item)?.find((header) => header.name.toLowerCase() === 'content-type')?.value.toLowerCase().split(';', 1)[0]?.trim();
1186
+ return contentType === 'application/json' || contentType === 'text/json' || contentType === 'application/x-ndjson' || contentType === 'application/xml' || contentType === 'text/xml' || contentType?.endsWith('+json') === true || contentType?.endsWith('+xml') === true;
1187
+ }
1188
+
1189
+ /** `--full` resolves filters before this ordering, then selects its bounded record set. */
1190
+ function prioritizeFullHarSelection(items: readonly HarSelection[]): HarSelection[] {
1191
+ return [...items].sort((left, right) => Number(isStructuredResponse(right)) - Number(isStructuredResponse(left)));
1192
+ }
1193
+
1194
+ /** Hoist only complete identical header lists shared by every displayed row; every differing value remains next to its record. */
1195
+ function sharedHarHeaders(shown: readonly HarSelection[]): SharedHarHeaders {
1196
+ if (shown.length < 2) return {};
1197
+ const request = requestHeaders(shown[0]!);
1198
+ const response = responseHeaders(shown[0]!);
1199
+ return {
1200
+ ...(request.length > 0 && shown.every((item) => headersEqual(request, requestHeaders(item))) ? { request } : {}),
1201
+ ...(response && response.length > 0 && shown.every((item) => {
1202
+ const candidate = responseHeaders(item);
1203
+ return candidate !== undefined && headersEqual(response, candidate);
1204
+ }) ? { response } : {}),
1205
+ };
1206
+ }
1207
+
1208
+ function sharedHeaderDetail(prefix: 'req' | 'res', headers: readonly Header[], shown: number): FactLine {
1209
+ return lineList([
1210
+ fact`shared ${prefix === 'req' ? 'request' : 'response'} headers (all ${shown} rendered records):`,
1211
+ ...headers.map((header) => harHeader(prefix, header)),
1212
+ ]);
1213
+ }
1214
+
1215
+ /** `--full` inline detail for one entry: record-specific headers, post data, and response body — every value escaped and capped through data()/fact. */
1216
+ function harEntryDetail(e: HAREntry, index: number, shared: SharedHarHeaders): FactLine {
1058
1217
  const rows: FactLine[] = [line(fact`${index + 1}. `, harEntryRow(e))];
1059
- for (const h of e.request.headers ?? []) {
1060
- rows.push(fact` req ${h.name}: ${h.value}`);
1218
+ if (!shared.request) {
1219
+ for (const h of e.request.headers ?? []) rows.push(harHeader('req', h));
1061
1220
  }
1062
1221
  if (e.request.postData?.text !== undefined) {
1063
1222
  rows.push(fact` post data: ${capped(e.request.postData.text, 2000)}`);
1064
1223
  }
1065
- for (const h of e.response.headers ?? []) {
1066
- rows.push(fact` res ${h.name}: ${h.value}`);
1224
+ if (!shared.response) {
1225
+ for (const h of e.response.headers ?? []) rows.push(harHeader('res', h));
1067
1226
  }
1068
1227
  const bodyText = e.response.content?.text;
1069
1228
  rows.push(
@@ -1074,21 +1233,19 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
1074
1233
  return lineList(rows);
1075
1234
  }
1076
1235
 
1077
- /** `--full` inline detail for one incomplete lifecycle record: the same
1078
- * headers/post data a completed entry shows, plus the provenance that explains
1079
- * why the lifecycle never completed. */
1080
- function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLine {
1236
+ /** `--full` inline detail for one incomplete lifecycle record: record-specific headers/post data plus the lifecycle provenance. */
1237
+ function harIncompleteDetail(record: IncompleteLifecycle, index: number, shared: SharedHarHeaders): FactLine {
1081
1238
  const rows: FactLine[] = [line(fact`${index + 1}. `, harIncompleteRow(record))];
1082
1239
  rows.push(fact` request ${record.requestId} (generation ${record.generation})`);
1083
- for (const h of record.request.headers ?? []) {
1084
- rows.push(fact` req ${h.name}: ${h.value}`);
1240
+ if (!shared.request) {
1241
+ for (const h of record.request.headers ?? []) rows.push(harHeader('req', h));
1085
1242
  }
1086
1243
  if (record.request.postData?.text !== undefined) {
1087
1244
  rows.push(fact` post data: ${capped(record.request.postData.text, 2000)}`);
1088
1245
  }
1089
1246
  const response = incompleteResponse(record);
1090
- for (const h of response?.headers ?? []) {
1091
- rows.push(fact` res ${h.name}: ${h.value}`);
1247
+ if (!shared.response) {
1248
+ for (const h of response?.headers ?? []) rows.push(harHeader('res', h));
1092
1249
  }
1093
1250
  if (record.kind === 'invalid_clock_order') {
1094
1251
  rows.push(fact` terminal: ${record.terminal.kind}`);
@@ -1101,6 +1258,67 @@ function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLi
1101
1258
  return lineList(rows);
1102
1259
  }
1103
1260
 
1261
+ function completeBodyBytes(entry: HttpHAREntry): Buffer | { code: string; message: string } {
1262
+ const provenance = entry._capture.body;
1263
+ if (provenance.state !== 'captured') {
1264
+ return { code: 'body_unavailable', message: `The selected transaction has no captured response body (${provenance.state === 'fetch_failed' ? provenance.error : provenance.reason}).` };
1265
+ }
1266
+ if (provenance.truncated) {
1267
+ return { code: 'body_truncated', message: `The selected transaction retained ${provenance.capturedByteLength} of ${provenance.decodedByteLength} response bytes, so capture cannot export it as a complete body.` };
1268
+ }
1269
+ const content = entry.response.content.text;
1270
+ if (content === undefined) {
1271
+ return { code: 'body_unavailable', message: 'The selected transaction claims a captured response body but its HAR content is absent.' };
1272
+ }
1273
+ return provenance.sourceEncoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
1274
+ }
1275
+
1276
+ function exportHarBody(id: string, located: HarSource, parsed: ParsedArgs): boolean {
1277
+ if (parsed.body === undefined) return false;
1278
+ if (!parsed.out) {
1279
+ emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_out_required' }, summary: fact`--body requires --out <path>; response bytes are written only to a caller-named file.` }, { json: parsed.json });
1280
+ process.exitCode = 1;
1281
+ return true;
1282
+ }
1283
+ if (parsed.filterUrl !== undefined || parsed.filterStatus !== undefined || parsed.filterMethod !== undefined || parsed.limit !== undefined || parsed.full) {
1284
+ emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_selection_conflict' }, summary: fact`--body selects one exact captured URL and cannot be combined with --filter-url, --filter-status, --filter-method, --limit, or --full.` }, { json: parsed.json });
1285
+ process.exitCode = 1;
1286
+ return true;
1287
+ }
1288
+ const matches = located.har.log.entries.filter((entry): entry is HttpHAREntry => entry.request.url === parsed.body && '_capture' in entry);
1289
+ if (matches.length !== 1) {
1290
+ const code = matches.length === 0 ? 'body_not_found' : 'body_ambiguous';
1291
+ const message = matches.length === 0
1292
+ ? line(text`No completed captured transaction has the exact URL `, harUrl(parsed.body), text`.`)
1293
+ : line(fact`${matches.length} completed captured transactions have the exact URL `, harUrl(parsed.body), text`; --body requires one recorded transaction.`);
1294
+ emitResult({ tag: 'error', attrs: { command: 'session har', code }, summary: message }, { json: parsed.json });
1295
+ process.exitCode = 1;
1296
+ return true;
1297
+ }
1298
+ const entry = matches[0]!;
1299
+ const bytes = completeBodyBytes(entry);
1300
+ if (!(bytes instanceof Buffer)) {
1301
+ emitResult({ tag: 'error', attrs: { command: 'session har', code: bytes.code }, summary: fact`${bytes.message}` }, { json: parsed.json });
1302
+ process.exitCode = 1;
1303
+ return true;
1304
+ }
1305
+ try {
1306
+ fs.writeFileSync(parsed.out, bytes);
1307
+ } catch (cause) {
1308
+ emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_write_failed' }, summary: fact`The selected response body was not written to ${parsed.out}: ${cause instanceof Error ? cause.message : String(cause)}` }, { json: parsed.json });
1309
+ process.exitCode = 1;
1310
+ return true;
1311
+ }
1312
+ const provenance = entry._capture.body;
1313
+ emitResult({
1314
+ tag: 'session-har',
1315
+ attrs: { id, path: located.path, source: located.source, 'body-path': parsed.out, bytes: bytes.length, encoding: provenance.sourceEncoding, transaction: entry._capture.requestId, generation: entry._capture.generation, status: entry.response.status },
1316
+ summary: line(fact`Wrote the complete recorded response body for ${entry.request.method} `, harUrl(entry.request.url), fact` to ${parsed.out}.`),
1317
+ sections: [fact`Transaction ${entry._capture.requestId} (generation ${entry._capture.generation}) started ${entry.startedDateTime}; capture recorded ${provenance.capturedByteLength} of ${provenance.decodedByteLength} decoded bytes with ${provenance.sourceEncoding} encoding (truncated=${String(provenance.truncated)}).`],
1318
+ }, { json: parsed.json });
1319
+ return true;
1320
+ }
1321
+
1104
1322
  async function har(parsed: ParsedArgs): Promise<void> {
1105
1323
  if (parsed.help) {
1106
1324
  console.log(HAR_USAGE);
@@ -1169,6 +1387,13 @@ async function har(parsed: ParsedArgs): Promise<void> {
1169
1387
  return;
1170
1388
  }
1171
1389
 
1390
+ if (exportHarBody(id, located, parsed)) return;
1391
+ if (parsed.out) {
1392
+ emitResult({ tag: 'error', attrs: { command: 'session har', code: 'out_requires_body' }, summary: fact`--out is only available with --body <url>; it names the caller-owned response-body file.` }, { json: parsed.json });
1393
+ process.exitCode = 1;
1394
+ return;
1395
+ }
1396
+
1172
1397
  let selection = buildHarSelection(located.har);
1173
1398
  const total = located.har.log.entries.length;
1174
1399
  const totalIncomplete = located.har.incompleteLifecycles.length;
@@ -1200,7 +1425,8 @@ async function har(parsed: ParsedArgs): Promise<void> {
1200
1425
 
1201
1426
  const requestedLimit = typeof parsed.limit === 'number' && parsed.limit > 0 ? parsed.limit : null;
1202
1427
  if (requestedLimit !== null) filters.push(`limit=${requestedLimit}`);
1203
- const bound = requestedLimit ?? DEFAULT_HAR_ROWS;
1428
+ if (parsed.full) selection = prioritizeFullHarSelection(selection);
1429
+ const bound = requestedLimit ?? (parsed.full ? DEFAULT_FULL_HAR_ROWS : DEFAULT_HAR_ROWS);
1204
1430
  const matched = selection.length;
1205
1431
  const shown = selection.slice(0, bound);
1206
1432
  const truncated = matched > shown.length;
@@ -1215,7 +1441,14 @@ async function har(parsed: ParsedArgs): Promise<void> {
1215
1441
  const summary = fact`${shownEntries} of ${total} complete entries and ${shownIncomplete} of ${totalIncomplete} incomplete lifecycle records listed${filterNote}.${truncationNote}`;
1216
1442
 
1217
1443
  const sections = parsed.full
1218
- ? shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i) : harIncompleteDetail(item.record, i)))
1444
+ ? (() => {
1445
+ const shared = sharedHarHeaders(shown);
1446
+ return [
1447
+ ...(shared.request ? [sharedHeaderDetail('req', shared.request, shown.length)] : []),
1448
+ ...(shared.response ? [sharedHeaderDetail('res', shared.response, shown.length)] : []),
1449
+ ...shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i, shared) : harIncompleteDetail(item.record, i, shared))),
1450
+ ];
1451
+ })()
1219
1452
  : [lineList(shown.map((item, i) => line(
1220
1453
  fact`${i + 1}. `,
1221
1454
  item.kind === 'complete' ? harEntryRow(item.entry) : harIncompleteRow(item.record),
@@ -1234,7 +1467,10 @@ async function har(parsed: ParsedArgs): Promise<void> {
1234
1467
  truncated: truncated ? true : undefined,
1235
1468
  },
1236
1469
  summary,
1237
- sections,
1470
+ sections: [
1471
+ ...sections,
1472
+ fact`Recorded transactions only; this read does not change responses. \`capture tab mock start\` serves a counterfactual response.`,
1473
+ ],
1238
1474
  }, { json: parsed.json });
1239
1475
  }
1240
1476
 
@@ -1243,8 +1479,10 @@ function bundleRows(manifest: BundleManifest): FactLine[] {
1243
1479
  fact`shots: ${manifest.shots.length}`,
1244
1480
  manifest.har ? fact`har: ${manifest.har.entryCount} entries — ${manifest.har.path}` : fact`har: 0 entries`,
1245
1481
  fact`logs: ${manifest.logs.length}`,
1482
+ fact`lighthouse reports: ${manifest.reports.length}`,
1246
1483
  fact`measure snaps: ${manifest.snaps.length}`,
1247
1484
  fact`motion recs: ${manifest.recs.length}`,
1485
+ fact`mocks: ${manifest.mocks?.length ?? 0}`,
1248
1486
  fact`other: ${manifest.other.length}`,
1249
1487
  ];
1250
1488
  }
@@ -1332,19 +1570,9 @@ async function stop(parsed: ParsedArgs): Promise<void> {
1332
1570
  await stopSessionLogTailers(session.logPids ?? []);
1333
1571
  if (session.bridgePid || session.bridgeSocket) stopBridge(session.bridgePid, session.bridgeSocket);
1334
1572
 
1335
- let recorderTeardown: Awaited<ReturnType<typeof teardownAnyLiveRecorderAtSessionStop>> | null = null;
1336
- try {
1337
- recorderTeardown = await teardownAnyLiveRecorderAtSessionStop(session.dir);
1338
- } catch (error) {
1339
- // A terminal `rec-stop` failure (the bridge authenticated the request
1340
- // and explicitly refused — e.g. a fatal HAR drain) must NOT be
1341
- // swallowed into a warning: it escapes to the outer `stop_failed`
1342
- // lane below, so no bundle is committed and no live HAR/recorder
1343
- // handle is deleted while admitted append work may still be lost.
1344
- // Non-fatal teardown noise (dead handle, liveness-unknown, malformed
1345
- // handle) keeps the existing warn-and-continue policy.
1346
- if (isTerminalRecStopFailure(error)) throw error;
1347
- console.error(`Warning: could not finalize active recording: ${error instanceof Error ? error.message : error}`);
1573
+ const hostTeardown = await stopAndReapCollectorHostAtSessionStop(session.dir);
1574
+ if (hostTeardown.status === 'terminal') {
1575
+ throw new Error(`collector host teardown failed: ${hostTeardown.error}`);
1348
1576
  }
1349
1577
 
1350
1578
  const shotsDir = path.join(session.dir, 'shots');
@@ -1370,7 +1598,9 @@ async function stop(parsed: ParsedArgs): Promise<void> {
1370
1598
  : [];
1371
1599
  const snaps = collectSnaps(session.dir);
1372
1600
  const recs = collectRecs(session.dir);
1373
- const knownDirs = new Set(['shots', 'logs', 'measure', 'motion', '.har', '.stop.lock', '.operations.lock']);
1601
+ const mocks = collectMocks(session.dir);
1602
+ const reports = collectLighthouseReports(session.dir);
1603
+ const knownDirs = new Set(['shots', 'logs', 'lighthouse', 'measure', 'motion', 'network', '.har', '.stop.lock', '.operations.lock']);
1374
1604
  const knownFiles = new Set(['.session.json', '.operations.json', 'har.json', 'bundle.json', 'bridge.sock']);
1375
1605
  const other = fs.readdirSync(session.dir)
1376
1606
  .filter(name => !knownDirs.has(name) && !knownFiles.has(name))
@@ -1385,10 +1615,12 @@ async function stop(parsed: ParsedArgs): Promise<void> {
1385
1615
  shots,
1386
1616
  har,
1387
1617
  logs,
1618
+ reports,
1388
1619
  other,
1389
1620
  snaps,
1390
1621
  recs,
1391
- pendingViewportRestorations: recorderTeardown?.pendingViewportRestorations ?? [],
1622
+ mocks,
1623
+ pendingViewportRestorations: [],
1392
1624
  };
1393
1625
 
1394
1626
  // The immutable bundle is the commit point. Live HAR deletion, stopped
@@ -1400,8 +1632,6 @@ async function stop(parsed: ParsedArgs): Promise<void> {
1400
1632
  committed = true;
1401
1633
 
1402
1634
  const rows = bundleRows(manifest);
1403
- if (recorderTeardown && 'recId' in recorderTeardown) rows.push(fact`recorder ${recorderTeardown.recId} finalized — state ${recorderTeardown.state}, viewport restored ${String(recorderTeardown.viewportRestored)}`);
1404
- if (recorderTeardown?.pendingViewportRestorations.length) rows.push(fact`pending viewport restorations: ${recorderTeardown.pendingViewportRestorations.length}`);
1405
1635
  emitResult({
1406
1636
  tag: 'session-stopped',
1407
1637
  attrs: { id, path: bundlePath },
@@ -1431,14 +1661,14 @@ function list(parsed: ParsedArgs): void {
1431
1661
 
1432
1662
  if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
1433
1663
 
1434
- const reads = fs.existsSync(CAPTURE_ROOT)
1435
- ? fs.readdirSync(CAPTURE_ROOT)
1436
- .filter((d) => fs.existsSync(sessionMetaPath(d)))
1664
+ const reads = registeredArtifactRoots().flatMap((artifactRoot) => fs.existsSync(artifactRoot)
1665
+ ? fs.readdirSync(artifactRoot)
1666
+ .filter((d) => fs.existsSync(sessionMetaPath(d, artifactRoot)))
1437
1667
  .flatMap((d) => {
1438
1668
  // A record deleted between readdir and read is simply gone, not unreadable.
1439
- try { return [tryReadSession(d)]; } catch { return []; }
1669
+ try { return [tryReadSession(d, artifactRoot)]; } catch { return []; }
1440
1670
  })
1441
- : [];
1671
+ : []);
1442
1672
 
1443
1673
  const sessions = reads.flatMap((read) => {
1444
1674
  if (!read.ok) return [];
@@ -1488,12 +1718,17 @@ function sectionRows(manifest: BundleManifest, key: SectionKey): FactLine[] {
1488
1718
  : [];
1489
1719
  case 'logs':
1490
1720
  return manifest.logs.map((l) => fact`${l.name} — ${l.lines} lines — ${l.path}`);
1721
+ case 'reports':
1722
+ return manifest.reports.map((r) => fact`${r.id} — url ${r.url} — final URL ${r.finalUrl} — ${r.preset} — categories ${r.categories.join(',')} — ${r.path}`);
1491
1723
  case 'snaps':
1492
1724
  return manifest.snaps.map((s) =>
1493
1725
  fact`${s.id} — url ${s.url ?? '(none)'} — viewport ${s.viewport ?? '(none)'} — settled ${String(s.settled)} — ${s.path}`);
1494
1726
  case 'recs':
1495
1727
  return manifest.recs.map((r) =>
1496
1728
  fact`${r.id} — ${r.action ?? '(none)'} — ${r.frames} frames, ${r.durationMs}ms, ${r.state} — ${r.path}`);
1729
+ case 'mocks':
1730
+ return (manifest.mocks ?? []).map((m) =>
1731
+ fact`${m.id} — ${m.completion} — ${m.matched}/${m.paused} matched/paused, ${m.releasedUnmatched} released unmatched — ${m.path}`);
1497
1732
  case 'other':
1498
1733
  return manifest.other.map((o) => fact`${o.name} — ${o.path}`);
1499
1734
  }
@@ -1557,7 +1792,7 @@ function view(parsed: ParsedArgs): void {
1557
1792
  emitResult({
1558
1793
  tag: 'error',
1559
1794
  attrs: { command: 'session view', code: 'invalid_filter' },
1560
- summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, measure, motion, other.`,
1795
+ summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, lighthouse, measure, motion, mocks, other.`,
1561
1796
  followUp: text`Re-run \`capture session view <id> --filter <section>\` with a valid section.`,
1562
1797
  }, { json: parsed.json });
1563
1798
  process.exitCode = 1;
@@ -1574,7 +1809,7 @@ function view(parsed: ParsedArgs): void {
1574
1809
  return;
1575
1810
  }
1576
1811
 
1577
- const keys: SectionKey[] = ['shots', 'har', 'logs', 'snaps', 'recs', 'other'];
1812
+ const keys: SectionKey[] = ['shots', 'har', 'logs', 'reports', 'snaps', 'recs', 'mocks', 'other'];
1578
1813
  const sections = keys.map((key) => {
1579
1814
  const rows = sectionRows(manifest, key);
1580
1815
  return lineList([