@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
@@ -8,9 +8,12 @@ import {
8
8
  validateHarFile,
9
9
  type HarFile,
10
10
  type HAREntry,
11
+ type Header,
12
+ type IncompleteLifecycle,
11
13
  } from '../har-manager.js';
12
14
  import {
13
15
  getActiveSession,
16
+ activeSessionScopeKey,
14
17
  setActiveSession,
15
18
  clearActiveSessionIf,
16
19
  updateSessionState,
@@ -48,6 +51,7 @@ import {
48
51
  } from './artifacts.js';
49
52
  import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
50
53
  import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
54
+ import { redactUrlCredentials } from './har-redact.js';
51
55
 
52
56
  type Session = ActiveSessionState;
53
57
 
@@ -235,10 +239,38 @@ function sessionMetaPath(id: string): string {
235
239
  return path.join(sessionDir(id), '.session.json');
236
240
  }
237
241
 
242
+ /** Outcome of reading one persisted record without failing the caller: either
243
+ * the validated session or the short reason its `.session.json` is unreadable. */
244
+ type SessionRead =
245
+ | { ok: true; session: Session }
246
+ | { ok: false; id: string; metaPath: string; reason: string };
247
+
248
+ /**
249
+ * Reads one record without throwing. A listing is what an agent reaches for
250
+ * precisely when it is working out what state it is in, so one corrupt or
251
+ * legacy-schema record on a shared capture root must not make every other
252
+ * session invisible — `list` renders the reason as its own unreadable row.
253
+ * A missing file is not a read outcome here; only `readSession` (targeted
254
+ * commands) turns that into `unknown_session`.
255
+ */
256
+ function tryReadSession(id: string): SessionRead {
257
+ const metaPath = sessionMetaPath(id);
258
+ const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
259
+ let parsed: unknown;
260
+ try {
261
+ parsed = JSON.parse(readPrivateFile(metaPath).toString('utf-8'));
262
+ } catch (error) {
263
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw error;
264
+ return bad('not valid JSON; the record is corrupt');
265
+ }
266
+ if (!isActiveStateCandidate(parsed)) return bad('does not match the session record schema; written by an incompatible version or corrupted');
267
+ return { ok: true, session: parsed };
268
+ }
269
+
238
270
  /**
239
- * Reads and VALIDATES one persisted `.session.json`. A missing file throws a
240
- * typed `unknown_session` precondition error (leaf catch-alls emit it as
241
- * `unknown_session`); unparsable bytes or a record failing the shared
271
+ * Reads and VALIDATES one persisted `.session.json` for a command that named
272
+ * this session. A missing file throws a typed `unknown_session` precondition
273
+ * error; unparsable bytes or a record failing the shared
242
274
  * `isActiveStateCandidate` schema throw a typed `invalid_session_record`
243
275
  * artifact error instead of letting a structurally-trusted cast reach the
244
276
  * renderer (where a legacy-schema record used to crash `session list`
@@ -246,23 +278,15 @@ function sessionMetaPath(id: string): string {
246
278
  */
247
279
  function readSession(id: string): Session {
248
280
  const metaPath = sessionMetaPath(id);
249
- let raw: string;
281
+ let read: SessionRead;
250
282
  try {
251
- raw = readPrivateFile(metaPath).toString('utf-8');
283
+ read = tryReadSession(id);
252
284
  } catch (error) {
253
285
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
254
286
  throw error;
255
287
  }
256
- let parsed: unknown;
257
- try {
258
- parsed = JSON.parse(raw);
259
- } catch (cause) {
260
- throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
261
- }
262
- if (!isActiveStateCandidate(parsed)) {
263
- throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
264
- }
265
- return parsed;
288
+ if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
289
+ return read.session;
266
290
  }
267
291
 
268
292
  function generateId(): string {
@@ -306,32 +330,40 @@ export async function waitForPageLoad(
306
330
 
307
331
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
308
332
  export const COMMAND_BLOCK = `<command name="session">
309
- the artifact container — a session opens a tab, records HAR, and bundles every artifact; while active, every command auto-targets its tab
333
+ the artifact container — a session records HAR and bundles every artifact; it may open a tab or adopt one later; while active, every command auto-targets its tab
310
334
  use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
311
335
  start · stop · list · view · har · log — \`capture session -h\`
312
336
  </command>`;
313
337
 
314
- const START_USAGE = `capture session start [--url <url>] [--hold] — open a tab, record HAR, and set the active capture context.
338
+ const START_USAGE = `capture session start [--url <url>] [--hold] — open or prepare a tab, record HAR, and set the active capture context.
315
339
 
316
340
  input:
317
- --url <url> Absolute URL to open in a fresh tab. Omit to start a session
318
- with no tab (HAR-only until a later command targets one).
341
+ --url <url> Absolute URL to open in a fresh tab through CDP's
342
+ Target.createTarget. Some endpoints, including Electron,
343
+ expose existing tabs but reject Target.createTarget; on those
344
+ endpoints --url cannot be used.
319
345
  --hold Hold one CDP browser connection open for the session's
320
346
  lifetime, so browser-level state (permission grants,
321
347
  ServiceWorker enablement) survives across separate commands.
322
- With no --url, later --target <existing-id> commands adopt
323
- an already-open tab without creating a new one.
348
+ With no --url, start a held session and adopt an existing tab
349
+ with this supported workflow:
350
+ capture session start --hold --port <port>
351
+ capture tab list --port <port>
352
+ capture page navigate <url> --target <target-id> --port <port>
353
+ The navigate command adopts that target for the session; later
354
+ page commands then auto-target it.
324
355
  --port <n> CDP port to attach to; default is auto-detected across localhost.
325
356
 
326
357
  Output:
327
358
  One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
328
- tab, HAR recording id, and held-bridge pid when applicable. --json mirrors
329
- the same fields.
359
+ tab when --url succeeds, HAR recording id, and held-bridge pid when
360
+ applicable. --json mirrors the same fields.
330
361
 
331
362
  effects:
332
- Creates a private session dir with shots/, starts a HAR recording, opens the
333
- tab (with --url), optionally holds a CDP bridge, and registers the session as
334
- the active capture context so subsequent commands auto-target it.`;
363
+ Creates a private session dir with shots/, starts a HAR recording, opens a
364
+ fresh tab only when --url is supplied, optionally holds a CDP bridge, and
365
+ registers the session as the active capture context so subsequent commands
366
+ auto-target it.`;
335
367
 
336
368
  const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
337
369
 
@@ -354,8 +386,11 @@ input:
354
386
  (none)
355
387
 
356
388
  output:
357
- One <sessions count=…> block, one row per session: id, status
389
+ One <sessions count=… unreadable=…> block, one row per session: id, status
358
390
  (active|stopped), start time, and URL when set. --json mirrors the same rows.
391
+ A record that is corrupt or written by an incompatible version lists as its
392
+ own "<id> — unreadable — <path> — <reason>" row and is excluded from count;
393
+ one bad record never hides the others.
359
394
 
360
395
  effects:
361
396
  None — reads session metadata only.`;
@@ -376,30 +411,45 @@ Output:
376
411
  effects:
377
412
  None — reads bundle.json only.`;
378
413
 
414
+ /** Default row bound for one `session har` read. A busy page produces
415
+ * thousands of records; an unbounded list buries the row the agent came for.
416
+ * `--limit` overrides it in either direction. */
417
+ const DEFAULT_HAR_ROWS = 100;
418
+
379
419
  const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
380
420
 
381
421
  input:
382
422
  <session-id> session to read; defaults to the active session. A
383
423
  running session reads its live accumulating HAR; a
384
424
  stopped one reads the bundled har.json.
385
- --filter-url <pattern> substring or regex match on the request URL
425
+ --filter-url <pattern> substring or regex match on the request URL (matched
426
+ against the URL as captured, before redaction)
386
427
  --filter-status <spec> exact status (100-599), one-digit class prefix
387
428
  (1-5, e.g. 4 = every 4xx), or ordered range
388
- (e.g. 400-499); any other token is invalid_filter
429
+ (e.g. 400-499); any other token is invalid_filter.
430
+ A record with no observed response never matches.
389
431
  --filter-method <method> HTTP method (GET, POST, …)
390
- --limit <n> first n matching entries
391
- --full inline per-entry detail (headers, post data,
432
+ --limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS}
433
+ --full inline per-record detail (headers, post data,
392
434
  response body — escaped and capped); bodies are
393
435
  never inlined without it
394
436
 
395
437
  output:
396
- One <session-har id=… path=… source=live|bundle entries=… total=…> block,
397
- one row per entry: method, status, URL, body size, start time. The path
398
- attribute is the HAR file's absolute path — the full-fidelity pointer.
399
- --json mirrors the same fields. WebSockets opened while a command was
400
- recording appear as entries with _resourceType "websocket" and their frames
401
- in _webSocketMessages (capped at 200 frames/socket, 4KB/frame); sockets
402
- opened before recording started are not visible.
438
+ One <session-har id=… path=… source=live|bundle entries=… incomplete=…
439
+ total=… total-incomplete=… [truncated=true]> block listing BOTH populations
440
+ in one chronological selection: completed entries (method, status, URL, body
441
+ size, start time) and the incomplete lifecycle records the recorder retained
442
+ for requests that never completed — rendered as "incomplete: <kind>" (an
443
+ out-of-order clock also names its violation, e.g.
444
+ invalid_clock_order/terminal_before_response). The path attribute is the HAR
445
+ file's absolute path — the full-fidelity pointer, which holds every record
446
+ including the ones this list bounds away. --json mirrors the same fields.
447
+ Credential-like query parameter values (key, token, secret, signature, auth,
448
+ password, oauth code, …) render as REDACTED; the HAR file keeps the real
449
+ value. WebSockets opened while a command was recording appear as entries with
450
+ _resourceType "websocket" and their frames in _webSocketMessages (capped at
451
+ 200 frames/socket, 4KB/frame); sockets opened before recording started are
452
+ not visible.
403
453
 
404
454
  effects:
405
455
  None — reads recorded HAR data only.`;
@@ -425,17 +475,17 @@ effects:
425
475
  the identity-bearing tailer record is registered in the session metadata.`;
426
476
 
427
477
  function printSessionHelp(): void {
428
- console.log(`capture session — the artifact container: opens a tab, records HAR, bundles every artifact.
478
+ console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open a tab or adopt one later.
429
479
 
430
480
  An active session auto-targets its tab and auto-appends recorded traffic — no
431
481
  --target/--har threading. \`stop\` finalizes the session and writes its bundle
432
482
  manifest; \`view\` reads that manifest back.
433
483
 
434
- <subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open a tab, start HAR, and set the active capture context"/>
484
+ <subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open or prepare a tab, start HAR, and set the active capture context"/>
435
485
  <subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
436
486
  <subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
437
487
  <subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
438
- <subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
488
+ <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"/>
439
489
  <subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
440
490
 
441
491
  capture session <leaf> -h Per-leaf usage`);
@@ -557,9 +607,9 @@ async function start(parsed: ParsedArgs): Promise<void> {
557
607
  if (active && !isSessionStopped(active)) {
558
608
  emitResult({
559
609
  tag: 'error',
560
- attrs: { command: 'session start', code: 'start_failed' },
561
- summary: fact`A session is already active for this scope (${active.sessionId}); stop it first with \`session stop ${active.sessionId}\`.`,
562
- followUp: text`Only one live session is allowed per capture scope at a time.`,
610
+ attrs: { command: 'session start', code: 'session_already_active' },
611
+ summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
612
+ followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`,
563
613
  }, { json: parsed.json });
564
614
  process.exitCode = 1;
565
615
  return;
@@ -594,7 +644,18 @@ async function start(parsed: ParsedArgs): Promise<void> {
594
644
  }
595
645
 
596
646
  if (url) {
597
- target = await startWorld.openTab(cdpPort!, url);
647
+ try {
648
+ target = await startWorld.openTab(cdpPort!, url);
649
+ } catch (error) {
650
+ const message = error instanceof Error ? error.message : String(error);
651
+ if (message.toLowerCase().includes('not supported')) {
652
+ throw worldFailure(
653
+ `Target.createTarget is unsupported on port ${cdpPort}. Use existing tab: \`capture page navigate <url> --target <target-id> --port ${cdpPort}\` after \`capture session start --hold --port ${cdpPort}\` and \`capture tab list\`.`,
654
+ error,
655
+ );
656
+ }
657
+ throw error;
658
+ }
598
659
  const openedPort = cdpPort!;
599
660
  const openedTargetId = target.id;
600
661
  acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
@@ -863,6 +924,89 @@ async function locateSessionHar(session: Session): Promise<HarSource | { unavail
863
924
  }
864
925
  }
865
926
 
927
+ /**
928
+ * The `session har` selection population is BOTH of the HAR file's records:
929
+ * the completed entries under `log.entries` and the incomplete lifecycle
930
+ * records under `incompleteLifecycles`. A request whose lifecycle the recorder
931
+ * could not complete (the page was still in flight at session stop, or Chrome
932
+ * reported clocks out of order) is retained evidence, not lost traffic —
933
+ * rendering only `log.entries` hid it from every read, which is exactly when an
934
+ * agent is looking for the request that did not finish.
935
+ */
936
+ type HarSelection = {
937
+ /** Original captured URL — what every filter matches against. */
938
+ url: string;
939
+ method: string;
940
+ /** Response status when one was observed; null when the record has no response. */
941
+ status: number | null;
942
+ startedDateTime: string;
943
+ /** Capture order, the deterministic tie-breaker for equal start times. */
944
+ order: number;
945
+ } & (
946
+ | { kind: 'complete'; entry: HAREntry }
947
+ | { kind: 'incomplete'; record: IncompleteLifecycle }
948
+ );
949
+
950
+ /** The response an incomplete record observed before it was cut short, if any. */
951
+ function incompleteResponse(record: IncompleteLifecycle): { status: number; headers: Header[] } | null {
952
+ switch (record.kind) {
953
+ case 'stopped_before_terminal':
954
+ return record._capture.response;
955
+ case 'invalid_clock_order':
956
+ return record.response;
957
+ case 'stopped_during_body':
958
+ return record.response;
959
+ }
960
+ }
961
+
962
+ /** The incomplete record's kind, qualified by the clock violation when it has one. */
963
+ function incompleteLabel(record: IncompleteLifecycle): string {
964
+ return record.kind === 'invalid_clock_order' ? `${record.kind}/${record.violation}` : record.kind;
965
+ }
966
+
967
+ /**
968
+ * Merges completed entries and incomplete lifecycle records into one
969
+ * chronologically ordered population. Ordering is deterministic for a given
970
+ * HAR file: start time first, then capture order (entries before incomplete
971
+ * records at an identical timestamp).
972
+ */
973
+ function buildHarSelection(har: HarFile): HarSelection[] {
974
+ const items: HarSelection[] = [];
975
+ for (const entry of har.log.entries) {
976
+ items.push({
977
+ kind: 'complete',
978
+ entry,
979
+ url: entry.request.url,
980
+ method: entry.request.method,
981
+ status: entry.response.status,
982
+ startedDateTime: entry.startedDateTime,
983
+ order: items.length,
984
+ });
985
+ }
986
+ for (const record of har.incompleteLifecycles) {
987
+ items.push({
988
+ kind: 'incomplete',
989
+ record,
990
+ url: record.request.url,
991
+ method: record.request.method,
992
+ status: incompleteResponse(record)?.status ?? null,
993
+ startedDateTime: record.startedDateTime,
994
+ order: items.length,
995
+ });
996
+ }
997
+ return items.sort((a, b) =>
998
+ a.startedDateTime === b.startedDateTime
999
+ ? a.order - b.order
1000
+ : (a.startedDateTime < b.startedDateTime ? -1 : 1),
1001
+ );
1002
+ }
1003
+
1004
+ /** Every rendered URL passes through render-time credential redaction; the HAR
1005
+ * artifact and the `--filter-url` match target keep the original value. */
1006
+ function harUrl(url: string): FactLine {
1007
+ return data(redactUrlCredentials(url), 300);
1008
+ }
1009
+
866
1010
  /** One selection-list row: method, status, URL, body size, start time. Body
867
1011
  * content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
868
1012
  function harEntryRow(e: HAREntry): FactLine {
@@ -872,13 +1016,27 @@ function harEntryRow(e: HAREntry): FactLine {
872
1016
  : text`body not captured`;
873
1017
  return line(
874
1018
  fact`${e.request.method} ${e.response.status} `,
875
- data(e.request.url, 300),
1019
+ harUrl(e.request.url),
876
1020
  text` — `,
877
1021
  sizePart,
878
1022
  fact` — started ${e.startedDateTime}`,
879
1023
  );
880
1024
  }
881
1025
 
1026
+ /** One selection-list row for an incomplete lifecycle record: it names the
1027
+ * kind (and clock violation) in place of the body size a completed entry
1028
+ * reports, so a row is never mistaken for finished traffic. */
1029
+ function harIncompleteRow(record: IncompleteLifecycle): FactLine {
1030
+ const response = incompleteResponse(record);
1031
+ return line(
1032
+ fact`${record.request.method} ${response ? response.status : 'no-response'} `,
1033
+ harUrl(record.request.url),
1034
+ text` — `,
1035
+ fact`incomplete: ${incompleteLabel(record)}`,
1036
+ fact` — started ${record.startedDateTime}`,
1037
+ );
1038
+ }
1039
+
882
1040
  /** `--full` inline detail for one entry: headers, post data, and response
883
1041
  * body — every value escaped and capped through data()/fact. */
884
1042
  function harEntryDetail(e: HAREntry, index: number): FactLine {
@@ -901,6 +1059,33 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
901
1059
  return lineList(rows);
902
1060
  }
903
1061
 
1062
+ /** `--full` inline detail for one incomplete lifecycle record: the same
1063
+ * headers/post data a completed entry shows, plus the provenance that explains
1064
+ * why the lifecycle never completed. */
1065
+ function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLine {
1066
+ const rows: FactLine[] = [line(fact`${index + 1}. `, harIncompleteRow(record))];
1067
+ rows.push(fact` request ${record.requestId} (generation ${record.generation})`);
1068
+ for (const h of record.request.headers ?? []) {
1069
+ rows.push(fact` req ${h.name}: ${h.value}`);
1070
+ }
1071
+ if (record.request.postData?.text !== undefined) {
1072
+ rows.push(fact` post data: ${capped(record.request.postData.text, 2000)}`);
1073
+ }
1074
+ const response = incompleteResponse(record);
1075
+ for (const h of response?.headers ?? []) {
1076
+ rows.push(fact` res ${h.name}: ${h.value}`);
1077
+ }
1078
+ if (record.kind === 'invalid_clock_order') {
1079
+ rows.push(fact` terminal: ${record.terminal.kind}`);
1080
+ if (record.terminal.kind === 'failed') {
1081
+ rows.push(fact` error: ${record.terminal.errorText} (canceled=${String(record.terminal.canceled)})`);
1082
+ }
1083
+ } else {
1084
+ rows.push(text` terminal: never observed`);
1085
+ }
1086
+ return lineList(rows);
1087
+ }
1088
+
904
1089
  async function har(parsed: ParsedArgs): Promise<void> {
905
1090
  if (parsed.help) {
906
1091
  console.log(HAR_USAGE);
@@ -969,41 +1154,57 @@ async function har(parsed: ParsedArgs): Promise<void> {
969
1154
  return;
970
1155
  }
971
1156
 
972
- let entries = located.har.log.entries;
973
- const total = entries.length;
1157
+ let selection = buildHarSelection(located.har);
1158
+ const total = located.har.log.entries.length;
1159
+ const totalIncomplete = located.har.incompleteLifecycles.length;
974
1160
  const filters: string[] = [];
975
1161
 
1162
+ // Every filter matches the ORIGINAL captured URL/method/status — redaction is
1163
+ // applied at render time only, so a `--filter-url` pattern still selects on
1164
+ // what the browser actually requested.
976
1165
  if (parsed.filterUrl) {
977
1166
  const pattern = parsed.filterUrl;
978
1167
  let re: RegExp | null = null;
979
1168
  try { re = new RegExp(pattern, 'i'); } catch { re = null; }
980
- entries = entries.filter((e) =>
981
- re ? re.test(e.request.url) : e.request.url.toLowerCase().includes(pattern.toLowerCase()),
1169
+ selection = selection.filter((item) =>
1170
+ re ? re.test(item.url) : item.url.toLowerCase().includes(pattern.toLowerCase()),
982
1171
  );
983
1172
  filters.push(`url~${pattern}`);
984
1173
  }
985
1174
  if (statusMatch) {
986
1175
  const matches = statusMatch;
987
- entries = entries.filter((e) => matches(e.response.status));
1176
+ // A record with no observed response cannot satisfy a status filter.
1177
+ selection = selection.filter((item) => item.status !== null && matches(item.status));
988
1178
  filters.push(`status=${parsed.filterStatus}`);
989
1179
  }
990
1180
  if (parsed.filterMethod) {
991
1181
  const m = parsed.filterMethod.toUpperCase();
992
- entries = entries.filter((e) => e.request.method.toUpperCase() === m);
1182
+ selection = selection.filter((item) => item.method.toUpperCase() === m);
993
1183
  filters.push(`method=${m}`);
994
1184
  }
995
- if (typeof parsed.limit === 'number' && parsed.limit > 0) {
996
- entries = entries.slice(0, parsed.limit);
997
- filters.push(`limit=${parsed.limit}`);
998
- }
999
1185
 
1000
- const summary = filters.length > 0
1001
- ? fact`${entries.length} of ${total} entries match (${filters.join(', ')}).`
1002
- : fact`${total} entries.`;
1186
+ const requestedLimit = typeof parsed.limit === 'number' && parsed.limit > 0 ? parsed.limit : null;
1187
+ if (requestedLimit !== null) filters.push(`limit=${requestedLimit}`);
1188
+ const bound = requestedLimit ?? DEFAULT_HAR_ROWS;
1189
+ const matched = selection.length;
1190
+ const shown = selection.slice(0, bound);
1191
+ const truncated = matched > shown.length;
1192
+
1193
+ const shownEntries = shown.filter((item) => item.kind === 'complete').length;
1194
+ const shownIncomplete = shown.length - shownEntries;
1195
+
1196
+ const filterNote = filters.length > 0 ? ` (${filters.join(', ')})` : '';
1197
+ const truncationNote = truncated
1198
+ ? ` ${matched - shown.length} further matching rows are not listed — raise the bound with --limit.`
1199
+ : '';
1200
+ const summary = fact`${shownEntries} of ${total} complete entries and ${shownIncomplete} of ${totalIncomplete} incomplete lifecycle records listed${filterNote}.${truncationNote}`;
1003
1201
 
1004
1202
  const sections = parsed.full
1005
- ? entries.map((e, i) => harEntryDetail(e, i))
1006
- : [lineList(entries.map((e, i) => line(fact`${i + 1}. `, harEntryRow(e))))];
1203
+ ? shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i) : harIncompleteDetail(item.record, i)))
1204
+ : [lineList(shown.map((item, i) => line(
1205
+ fact`${i + 1}. `,
1206
+ item.kind === 'complete' ? harEntryRow(item.entry) : harIncompleteRow(item.record),
1207
+ )))];
1007
1208
 
1008
1209
  emitResult({
1009
1210
  tag: 'session-har',
@@ -1011,8 +1212,11 @@ async function har(parsed: ParsedArgs): Promise<void> {
1011
1212
  id,
1012
1213
  path: located.path,
1013
1214
  source: located.source,
1014
- entries: entries.length,
1215
+ entries: shownEntries,
1216
+ incomplete: shownIncomplete,
1015
1217
  total,
1218
+ 'total-incomplete': totalIncomplete,
1219
+ truncated: truncated ? true : undefined,
1016
1220
  },
1017
1221
  summary,
1018
1222
  sections,
@@ -1212,17 +1416,24 @@ function list(parsed: ParsedArgs): void {
1212
1416
 
1213
1417
  if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
1214
1418
 
1215
- const sessions = fs.existsSync(CAPTURE_ROOT)
1419
+ const reads = fs.existsSync(CAPTURE_ROOT)
1216
1420
  ? fs.readdirSync(CAPTURE_ROOT)
1217
1421
  .filter((d) => fs.existsSync(sessionMetaPath(d)))
1218
- .map((d) => {
1219
- const session = readSession(d);
1220
- const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
1221
- return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
1422
+ .flatMap((d) => {
1423
+ // A record deleted between readdir and read is simply gone, not unreadable.
1424
+ try { return [tryReadSession(d)]; } catch { return []; }
1222
1425
  })
1223
1426
  : [];
1224
1427
 
1225
- if (sessions.length === 0) {
1428
+ const sessions = reads.flatMap((read) => {
1429
+ if (!read.ok) return [];
1430
+ const session = read.session;
1431
+ const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
1432
+ return [{ id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' }];
1433
+ });
1434
+ const unreadable = reads.flatMap((read) => (read.ok ? [] : [read]));
1435
+
1436
+ if (reads.length === 0) {
1226
1437
  emitResult({
1227
1438
  tag: 'sessions',
1228
1439
  attrs: { count: 0 },
@@ -1237,11 +1448,17 @@ function list(parsed: ParsedArgs): void {
1237
1448
  s.url ? fact` — ${s.url}` : text``,
1238
1449
  ),
1239
1450
  );
1451
+ const unreadableRows = unreadable.map((u) =>
1452
+ line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`),
1453
+ );
1240
1454
 
1241
1455
  emitResult({
1242
1456
  tag: 'sessions',
1243
- attrs: { count: sessions.length },
1244
- sections: [lineList(rows)],
1457
+ attrs: { count: sessions.length, unreadable: unreadable.length },
1458
+ sections: [lineList([...rows, ...unreadableRows])],
1459
+ ...(unreadable.length > 0
1460
+ ? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` }
1461
+ : {}),
1245
1462
  }, { json: parsed.json });
1246
1463
  }
1247
1464