@crouton-kit/capture 1.3.18 → 1.3.20

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 (38) hide show
  1. package/bin/capture +4204 -3244
  2. package/package.json +1 -1
  3. package/src/capture.ts +4 -1
  4. package/src/cdp/args.ts +22 -3
  5. package/src/cdp/browser-process.ts +510 -0
  6. package/src/cdp/client.ts +10 -1
  7. package/src/cdp/color-scheme.ts +47 -0
  8. package/src/cdp/commands/measure/index.ts +1 -1
  9. package/src/cdp/commands/measure/snap.ts +50 -8
  10. package/src/cdp/commands/motion/mask.ts +8 -2
  11. package/src/cdp/commands/page/click.ts +14 -0
  12. package/src/cdp/commands/page/elements.ts +2 -2
  13. package/src/cdp/commands/page/index.ts +1 -1
  14. package/src/cdp/commands/page/shot.ts +58 -13
  15. package/src/cdp/commands/tab/index.ts +19 -8
  16. package/src/cdp/commands/tab/launch.ts +73 -0
  17. package/src/cdp/commands/tab/list.ts +14 -2
  18. package/src/cdp/commands/tab/quit.ts +78 -0
  19. package/src/cdp/har-recorder.ts +14 -20
  20. package/src/cdp/measure/collectors/ax.ts +3 -4
  21. package/src/cdp/measure/collectors/forms.ts +4 -6
  22. package/src/cdp/measure/collectors/media.ts +1 -2
  23. package/src/cdp/measure/collectors/states.ts +1 -6
  24. package/src/cdp/measure/collectors/style-provenance.ts +4 -16
  25. package/src/cdp/measure/collectors/styles.ts +21 -10
  26. package/src/cdp/measure/collectors/text.ts +3 -5
  27. package/src/cdp/measure/snapshot.ts +197 -29
  28. package/src/cdp/measure/types.ts +36 -2
  29. package/src/cdp/motion/jank.ts +8 -1
  30. package/src/cdp/screenshot.ts +83 -8
  31. package/src/cdp/targets.ts +5 -0
  32. package/src/cdp/types.ts +6 -0
  33. package/src/har-manager.ts +16 -15
  34. package/src/interact.ts +20 -4
  35. package/src/output/render.ts +2 -0
  36. package/src/registry.ts +1 -1
  37. package/src/session/commands.ts +223 -43
  38. package/src/session/har-redact.ts +125 -0
@@ -1,5 +1,6 @@
1
1
  import { CDPClient } from './client.js';
2
2
  import { detectCdpPortsAsync } from './detect.js';
3
+ import { touchOwnedBrowser } from './browser-process.js';
3
4
  import { type CDPTarget } from './types.js';
4
5
 
5
6
  /**
@@ -30,6 +31,10 @@ export async function getBrowserClient(
30
31
  }
31
32
  const client = new CDPClient(version.webSocketDebuggerUrl);
32
33
  await client.waitReady();
34
+ // Every browser-level connection funnels through here, which makes this the
35
+ // one place that can tell capture's idle sweep the browser is still in use.
36
+ // A port capture did not launch has no registry record and this is a no-op.
37
+ touchOwnedBrowser(port);
33
38
  return { client, browserWsUrl: version.webSocketDebuggerUrl };
34
39
  }
35
40
 
package/src/cdp/types.ts CHANGED
@@ -25,6 +25,8 @@ export interface ParsedArgs {
25
25
  * can auto-append recorded traffic to the session HAR. */
26
26
  har?: string;
27
27
  new?: boolean;
28
+ /** `tab launch --headed` — a visible window instead of headless. */
29
+ headed?: boolean;
28
30
  target?: string;
29
31
  /** Where `target` was assigned from: an actual `--target` flag, active-session
30
32
  * autofill, or the ambient `CDP_TARGET` env var. Recorded at the assignment
@@ -36,6 +38,8 @@ export interface ParsedArgs {
36
38
  into?: string;
37
39
  noScreenshot?: boolean;
38
40
  viewport?: string;
41
+ /** `page shot|measure snap --color-scheme dark|light` — transient media override. */
42
+ colorScheme?: string;
39
43
  fullPage?: boolean;
40
44
  help?: boolean;
41
45
  /** `--all` — boolean scope-widener (per-leaf semantics). */
@@ -68,6 +72,8 @@ export interface ParsedArgs {
68
72
  settleTimeout?: number;
69
73
  /** `snap --capture-unsettled` — write full substrate despite non-settlement. */
70
74
  captureUnsettled?: boolean;
75
+ /** `snap --skip-collector <name>` — repeatable; one entry per occurrence. */
76
+ skipCollector?: string[];
71
77
  /** `snap|diff|mask --pixels` — include raster crop/diff facts. */
72
78
  pixels?: boolean;
73
79
  /** `snap --state <state[:selector]>` — repeatable; one entry per occurrence. */
@@ -552,8 +552,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
552
552
 
553
553
  // Response-state ⇄ status coupling.
554
554
  if (capResponse.state === 'received') {
555
- if (status < 100 || status > 599) {
556
- fail(source, `${path}.response.status`, 'a received response requires status 100..599');
555
+ if ((status < 100 || status > 599) && !(terminal.kind === 'redirect' && status === 0)) {
556
+ fail(source, `${path}.response.status`, 'a received response requires status 100..599 (or 0 for a redirect with no HTTP response)');
557
557
  }
558
558
  } else {
559
559
  // unavailable
@@ -566,8 +566,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
566
566
  fail(source, `${path}._capture.clocks.responseMonotonic`, 'must be null when no response was received');
567
567
  }
568
568
  }
569
- if (status === 0 && capResponse.state !== 'unavailable') {
570
- fail(source, `${path}.response.status`, 'status 0 is permitted only when _capture.response.state is "unavailable"');
569
+ if (status === 0 && capResponse.state !== 'unavailable' && terminal.kind !== 'redirect') {
570
+ fail(source, `${path}.response.status`, 'status 0 is permitted only for an unavailable response or a redirect with no HTTP response');
571
571
  }
572
572
 
573
573
  // Terminal ⇄ response/status/body coupling.
@@ -582,8 +582,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
582
582
  if (capResponse.state !== 'received') {
583
583
  fail(source, `${path}._capture.response.state`, 'a redirect requires a received response');
584
584
  }
585
- if (status < 300 || status > 399) {
586
- fail(source, `${path}.response.status`, 'a redirect requires status 300..399');
585
+ if (status !== 0 && (status < 300 || status > 399)) {
586
+ fail(source, `${path}.response.status`, 'a redirect requires status 0 (no HTTP response) or 300..399');
587
587
  }
588
588
  if (body.state !== 'not_applicable' || body.reason !== 'redirect') {
589
589
  fail(source, `${path}._capture.body`, 'a redirect requires body not_applicable/redirect');
@@ -666,17 +666,18 @@ function vIncompleteResponse(
666
666
  path: string,
667
667
  minResponseMonotonic: number,
668
668
  allowUnknownClock = false,
669
+ allowNoHttpStatus = false,
669
670
  ): IncompleteResponse {
670
671
  const o = vObject(v, source, path);
671
672
  exactKeys(o, ['status', 'headers', 'responseMonotonic'], source, path);
672
673
  const responseMonotonic = o.responseMonotonic === null
673
674
  ? allowUnknownClock ? null : fail(source, `${path}.responseMonotonic`, 'must be a finite number')
674
675
  : vFiniteMin(o.responseMonotonic, minResponseMonotonic, source, `${path}.responseMonotonic`);
675
- return {
676
- status: vIntInRange(o.status, 100, 599, source, `${path}.status`),
677
- headers: vHeaders(o.headers, source, `${path}.headers`),
678
- responseMonotonic,
679
- };
676
+ const status = vIntInRange(o.status, 0, 599, source, `${path}.status`);
677
+ if ((status < 100 && status !== 0) || (status === 0 && (!allowNoHttpStatus || responseMonotonic !== null))) {
678
+ fail(source, `${path}.status`, 'must be 100..599 (or 0 for a redirect with no HTTP response)');
679
+ }
680
+ return { status, headers: vHeaders(o.headers, source, `${path}.headers`), responseMonotonic };
680
681
  }
681
682
 
682
683
  function vStoppedBeforeTerminal(
@@ -754,9 +755,9 @@ function vInvalidClockOrder(
754
755
  const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
755
756
  const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
756
757
  const request = vRequest(o.request, source, `${path}.request`);
757
- const response =
758
- o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true);
759
758
  const terminal = vInvalidClockTerminal(o.terminal, source, `${path}.terminal`);
759
+ const response =
760
+ o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true, terminal.kind === 'redirect');
760
761
 
761
762
  const cap = vObject(o._capture, source, `${path}._capture`);
762
763
  exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic'], source, `${path}._capture`);
@@ -775,8 +776,8 @@ function vInvalidClockOrder(
775
776
  // CDP redirectResponse preserves redirect facts but has no independently observed response clock.
776
777
  if (terminal.kind === 'redirect') {
777
778
  if (response === null) fail(source, `${path}.response`, 'a redirect terminal requires an observed response');
778
- if (response.status < 300 || response.status > 399) {
779
- fail(source, `${path}.response.status`, 'a redirect terminal requires status 300..399');
779
+ if (response.status !== 0 && (response.status < 300 || response.status > 399)) {
780
+ fail(source, `${path}.response.status`, 'a redirect terminal requires status 0 (no HTTP response) or 300..399');
780
781
  }
781
782
  if (response.responseMonotonic !== null) {
782
783
  fail(source, `${path}.response.responseMonotonic`, 'must be null for a redirect terminal');
package/src/interact.ts CHANGED
@@ -89,6 +89,11 @@ export type ResolutionFailure =
89
89
  readonly input: string;
90
90
  readonly acceptedPrefixes: typeof ACCEPTED_LIVE_PREFIXES;
91
91
  }
92
+ | {
93
+ readonly ok: false;
94
+ readonly code: 'invalid-css-selector';
95
+ readonly input: string;
96
+ }
92
97
  | {
93
98
  readonly ok: false;
94
99
  readonly code: 'no-match' | 'ambiguous';
@@ -222,10 +227,21 @@ async function resolveLiveCss(
222
227
  ): Promise<ResolvedTarget | ResolutionFailure> {
223
228
  await client.send('DOM.enable');
224
229
  const { root } = (await client.send('DOM.getDocument', { depth: 0 })) as { root: { nodeId: number } };
225
- const { nodeIds } = (await client.send('DOM.querySelectorAll', {
226
- nodeId: root.nodeId,
227
- selector,
228
- })) as { nodeIds: number[] };
230
+ let nodeIds: number[];
231
+ try {
232
+ ({ nodeIds } = (await client.send('DOM.querySelectorAll', {
233
+ nodeId: root.nodeId,
234
+ selector,
235
+ })) as { nodeIds: number[] });
236
+ } catch (error) {
237
+ // Chrome reports a malformed CSS selector from this CDP method as the
238
+ // otherwise opaque "DOM Error while querying". The bare grammar is CSS;
239
+ // make the accessible-name retry explicit instead of leaking that error.
240
+ if (error instanceof Error && error.message.includes('DOM Error while querying')) {
241
+ return { ok: false, code: 'invalid-css-selector', input };
242
+ }
243
+ throw error;
244
+ }
229
245
 
230
246
  if (nodeIds.length === 1) {
231
247
  const backendNodeId = await backendNodeIdFor(client, nodeIds[0]);
@@ -56,6 +56,8 @@ export const RESULT_TAGS = [
56
56
  'tab-opened',
57
57
  'tab-closed',
58
58
  'tab-reset',
59
+ 'browser-launched',
60
+ 'browser-quit',
59
61
  'network',
60
62
  'cdp-result',
61
63
  'libs',
package/src/registry.ts CHANGED
@@ -87,7 +87,7 @@ const ref = 'css:<selector> | backend:<id> | axid:<id> | ax:<name> | text:<text>
87
87
 
88
88
  export const CAPTURE_REGISTRY: RegistryBranch = branch('', 'Capture — browser evidence and automation over CDP.', [
89
89
  branch('session', 'persistent browser-work lifecycle and artifact bundles.', [
90
- leaf('session start', 'start a fresh persistent capture session.', { flags: [requiredValue('--url', 'absolute-http(s)-url'), port()], effects: effects(true, true, true), constraints: 'Required singleton --url <absolute-http(s)-url>; optional singleton --port <1..65535>. No target, prefix, positional URL, or ambient CDP selection.', orderedOutput: 'Exact session ID; open lifecycle; full target ID; endpoint; observed URL; target_live=true; scope key; active generation.', artifactOwnership: 'Exclusively allocates the session directory and atomically publishes the canonical session record before its scoped pointer.' }),
90
+ leaf('session start', 'open or prepare a tab for a persistent capture session.', { flags: [value('--url', 'absolute-http(s)-url'), bool('--hold'), port()], effects: effects(true, true, true), constraints: 'Optional singleton --url <absolute-http(s)-url> opens a fresh tab through Target.createTarget; endpoints such as Electron that reject Target.createTarget cannot use --url. Optional singleton --hold retains a browser connection; optional singleton --port <1..65535> selects its endpoint. To work against an existing tab, omit --url, run `capture session start --hold --port <port>`, list it with `capture tab list --port <port>`, then run `capture page navigate <url> --target <target-id> --port <port>`; that navigate command adopts the target and later page commands auto-target it. No target, prefix, positional URL, or ambient CDP selection on session start.', orderedOutput: 'Exact session ID; open lifecycle; full target ID or null; endpoint or null; observed URL or null; target_live=true when a tab was opened; scope key; active generation.', artifactOwnership: 'Exclusively allocates the session directory and atomically publishes the canonical session record before its scoped pointer.' }),
91
91
  leaf('session stop', 'stop and bundle one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(true, true, true), constraints: 'One exact case-sensitive session ID; no prefixes. Only matching open/stopping/completed lifecycle records are accepted before bundle effects.', orderedOutput: 'Exact ID; stop-operation ID; stopped lifecycle; final bundle access; pointer outcome deleted|absent|superseded.', artifactOwnership: 'Resumes the recorded operation and owns bundle staging/publication; never rolls lifecycle back to open.', recovery: 'session_stop_state_invalid: inspect capture session view <exact-session-id> and preserve the session directory; never delete or allocate a new stop. session_stop_staging_cleanup_failed: correct access at the reported staging path and retry the identical capture session stop <exact-session-id>. session_stop_bundle_failed: correct the reported bundle/filesystem cause and retry that identical command. Both retries resume the recorded operation.' }),
92
92
  leaf('session list', 'list same-user host capture sessions.', { effects: effects(false, true), constraints: 'No target/session filters or ambient defaults.', orderedOutput: 'Rows sorted by exact session ID: ID, lifecycle, active_in_current_scope, full target ID or null, endpoint or null, target_live.' }),
93
93
  leaf('session view', 'view one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(false, true), constraints: 'One exact case-sensitive session ID; no prefix.' }),
@@ -8,6 +8,8 @@ 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,
@@ -49,6 +51,7 @@ import {
49
51
  } from './artifacts.js';
50
52
  import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
51
53
  import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
54
+ import { redactUrlCredentials } from './har-redact.js';
52
55
 
53
56
  type Session = ActiveSessionState;
54
57
 
@@ -327,32 +330,40 @@ export async function waitForPageLoad(
327
330
 
328
331
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
329
332
  export const COMMAND_BLOCK = `<command name="session">
330
- 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
331
334
  use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
332
335
  start · stop · list · view · har · log — \`capture session -h\`
333
336
  </command>`;
334
337
 
335
- 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.
336
339
 
337
340
  input:
338
- --url <url> Absolute URL to open in a fresh tab. Omit to start a session
339
- 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.
340
345
  --hold Hold one CDP browser connection open for the session's
341
346
  lifetime, so browser-level state (permission grants,
342
347
  ServiceWorker enablement) survives across separate commands.
343
- With no --url, later --target <existing-id> commands adopt
344
- 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.
345
355
  --port <n> CDP port to attach to; default is auto-detected across localhost.
346
356
 
347
357
  Output:
348
358
  One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
349
- tab, HAR recording id, and held-bridge pid when applicable. --json mirrors
350
- the same fields.
359
+ tab when --url succeeds, HAR recording id, and held-bridge pid when
360
+ applicable. --json mirrors the same fields.
351
361
 
352
362
  effects:
353
- Creates a private session dir with shots/, starts a HAR recording, opens the
354
- tab (with --url), optionally holds a CDP bridge, and registers the session as
355
- 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.`;
356
367
 
357
368
  const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
358
369
 
@@ -400,30 +411,45 @@ Output:
400
411
  effects:
401
412
  None — reads bundle.json only.`;
402
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
+
403
419
  const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
404
420
 
405
421
  input:
406
422
  <session-id> session to read; defaults to the active session. A
407
423
  running session reads its live accumulating HAR; a
408
424
  stopped one reads the bundled har.json.
409
- --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)
410
427
  --filter-status <spec> exact status (100-599), one-digit class prefix
411
428
  (1-5, e.g. 4 = every 4xx), or ordered range
412
- (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.
413
431
  --filter-method <method> HTTP method (GET, POST, …)
414
- --limit <n> first n matching entries
415
- --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,
416
434
  response body — escaped and capped); bodies are
417
435
  never inlined without it
418
436
 
419
437
  output:
420
- One <session-har id=… path=… source=live|bundle entries=… total=…> block,
421
- one row per entry: method, status, URL, body size, start time. The path
422
- attribute is the HAR file's absolute path — the full-fidelity pointer.
423
- --json mirrors the same fields. WebSockets opened while a command was
424
- recording appear as entries with _resourceType "websocket" and their frames
425
- in _webSocketMessages (capped at 200 frames/socket, 4KB/frame); sockets
426
- 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.
427
453
 
428
454
  effects:
429
455
  None — reads recorded HAR data only.`;
@@ -449,17 +475,17 @@ effects:
449
475
  the identity-bearing tailer record is registered in the session metadata.`;
450
476
 
451
477
  function printSessionHelp(): void {
452
- 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.
453
479
 
454
480
  An active session auto-targets its tab and auto-appends recorded traffic — no
455
481
  --target/--har threading. \`stop\` finalizes the session and writes its bundle
456
482
  manifest; \`view\` reads that manifest back.
457
483
 
458
- <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"/>
459
485
  <subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
460
486
  <subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
461
487
  <subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
462
- <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"/>
463
489
  <subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
464
490
 
465
491
  capture session <leaf> -h Per-leaf usage`);
@@ -618,7 +644,18 @@ async function start(parsed: ParsedArgs): Promise<void> {
618
644
  }
619
645
 
620
646
  if (url) {
621
- 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
+ }
622
659
  const openedPort = cdpPort!;
623
660
  const openedTargetId = target.id;
624
661
  acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
@@ -887,6 +924,89 @@ async function locateSessionHar(session: Session): Promise<HarSource | { unavail
887
924
  }
888
925
  }
889
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
+
890
1010
  /** One selection-list row: method, status, URL, body size, start time. Body
891
1011
  * content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
892
1012
  function harEntryRow(e: HAREntry): FactLine {
@@ -896,13 +1016,27 @@ function harEntryRow(e: HAREntry): FactLine {
896
1016
  : text`body not captured`;
897
1017
  return line(
898
1018
  fact`${e.request.method} ${e.response.status} `,
899
- data(e.request.url, 300),
1019
+ harUrl(e.request.url),
900
1020
  text` — `,
901
1021
  sizePart,
902
1022
  fact` — started ${e.startedDateTime}`,
903
1023
  );
904
1024
  }
905
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
+
906
1040
  /** `--full` inline detail for one entry: headers, post data, and response
907
1041
  * body — every value escaped and capped through data()/fact. */
908
1042
  function harEntryDetail(e: HAREntry, index: number): FactLine {
@@ -925,6 +1059,33 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
925
1059
  return lineList(rows);
926
1060
  }
927
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
+
928
1089
  async function har(parsed: ParsedArgs): Promise<void> {
929
1090
  if (parsed.help) {
930
1091
  console.log(HAR_USAGE);
@@ -993,41 +1154,57 @@ async function har(parsed: ParsedArgs): Promise<void> {
993
1154
  return;
994
1155
  }
995
1156
 
996
- let entries = located.har.log.entries;
997
- 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;
998
1160
  const filters: string[] = [];
999
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.
1000
1165
  if (parsed.filterUrl) {
1001
1166
  const pattern = parsed.filterUrl;
1002
1167
  let re: RegExp | null = null;
1003
1168
  try { re = new RegExp(pattern, 'i'); } catch { re = null; }
1004
- entries = entries.filter((e) =>
1005
- 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()),
1006
1171
  );
1007
1172
  filters.push(`url~${pattern}`);
1008
1173
  }
1009
1174
  if (statusMatch) {
1010
1175
  const matches = statusMatch;
1011
- 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));
1012
1178
  filters.push(`status=${parsed.filterStatus}`);
1013
1179
  }
1014
1180
  if (parsed.filterMethod) {
1015
1181
  const m = parsed.filterMethod.toUpperCase();
1016
- entries = entries.filter((e) => e.request.method.toUpperCase() === m);
1182
+ selection = selection.filter((item) => item.method.toUpperCase() === m);
1017
1183
  filters.push(`method=${m}`);
1018
1184
  }
1019
- if (typeof parsed.limit === 'number' && parsed.limit > 0) {
1020
- entries = entries.slice(0, parsed.limit);
1021
- filters.push(`limit=${parsed.limit}`);
1022
- }
1023
1185
 
1024
- const summary = filters.length > 0
1025
- ? fact`${entries.length} of ${total} entries match (${filters.join(', ')}).`
1026
- : 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}`;
1027
1201
 
1028
1202
  const sections = parsed.full
1029
- ? entries.map((e, i) => harEntryDetail(e, i))
1030
- : [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
+ )))];
1031
1208
 
1032
1209
  emitResult({
1033
1210
  tag: 'session-har',
@@ -1035,8 +1212,11 @@ async function har(parsed: ParsedArgs): Promise<void> {
1035
1212
  id,
1036
1213
  path: located.path,
1037
1214
  source: located.source,
1038
- entries: entries.length,
1215
+ entries: shownEntries,
1216
+ incomplete: shownIncomplete,
1039
1217
  total,
1218
+ 'total-incomplete': totalIncomplete,
1219
+ truncated: truncated ? true : undefined,
1040
1220
  },
1041
1221
  summary,
1042
1222
  sections,