@crouton-kit/capture 1.4.2 → 1.4.10

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 +178 -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
@@ -0,0 +1,65 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { captureError } from '../../../../errors.js';
5
+ import { CAPTURE_ROOT, unlinkPrivateFile, writePrivateFile } from '../../../../session/artifacts.js';
6
+ import { emitResult, fact, formatArtifactList, text, type RenderableResult } from '../../../../output/render.js';
7
+ import { type ParsedArgs } from '../../../types.js';
8
+ import { parseMockRuleDocument } from '../../../host/collectors/intercept.js';
9
+ import { startMock } from './lifecycle.js';
10
+
11
+ const HELP = `capture tab mock start --rules <path> — intercept the tab's requests and answer them from a rule document
12
+
13
+ input:
14
+ --rules <path> required. Path to a JSON rule document (schema below). Requires an active session with a target; the rules live only as long as the mock, and \`capture tab mock stop\` removes them
15
+ output: <mock …> — the installed mock, its rule count, and the artifact directory holding the copy of exactly what was installed; --json mirrors
16
+ effects: changes what the tab's network does; spawns or joins the session's collector host, which holds the tab connection until the mock is stopped. Claims \`fetch-interception\`: refused while another mock is live, naming its holder. Composes with a live trace and with a live motion recording.
17
+
18
+ rule document:
19
+ { "rules": [{ "url": "https://api.example.com/users*", "methods": ["GET"], "resourceTypes": ["XHR", "Fetch"], "fulfill": { "status": 200, "headers": { "content-type": "application/json" }, "body": "{\\"users\\":[]}" } }, { "url": "https://cdn.example.com/*.png", "fail": { "reason": "Failed" } }, { "url": "https://api.example.com/health", "passthrough": {} }, { "url": "https://api.example.com/*", "modify": { "headers": { "x-test": "1" } } }] }
20
+
21
+ - An ordered list; first match wins; there is no priority field, because order is the priority.
22
+ - \`url\` is a \`*\`-glob over the full URL — the same syntax CDP's own \`Fetch.enable\` patterns use. No regex in v1.
23
+ - \`methods\` and \`resourceTypes\` are optional narrowing sets. Omitted means "any".
24
+ - Exactly one action per rule: \`fulfill\`, \`fail\`, \`modify\`, or \`passthrough\`. \`passthrough\` exists so a specific request can shadow a later broad rule.
25
+ - There is no default rule and no way to configure one. An unmatched request is continued unmodified.
26
+ - \`fulfill\` bodies are inline (\`body\` string, or \`bodyBase64\`). File-referenced bodies are deferred.
27
+ - Load is all-or-nothing: an unparseable pattern, unknown action, or oversized body refuses the whole document and nothing is installed. \`Fetch.enable\` is never sent for a document that failed validation.`;
28
+
29
+ function stagedRulesPath(): string { return path.join(CAPTURE_ROOT, '.mock-inputs', `rules-${crypto.randomBytes(12).toString('hex')}.json`); }
30
+
31
+ function sourceFor(pathname: string): { source: Buffer; rules: number } {
32
+ let source: Buffer;
33
+ try { source = fs.readFileSync(pathname); }
34
+ catch (error) { throw captureError('artifact', 'mock_rules_unreadable', `could not read mock rules at ${pathname}: ${error instanceof Error ? error.message : String(error)}`); }
35
+ try { return { source, rules: parseMockRuleDocument(source).length }; }
36
+ catch (error) { throw captureError('invocation', 'invalid_mock_rules', `received: ${pathname}\nexpected: a valid tab mock rule document\nfield: --rules\nreason: ${error instanceof Error ? error.message : String(error)}\nNext: Run \`capture tab mock start -h\` and read the rule-document schema before re-issuing.`); }
37
+ }
38
+
39
+ export function buildMockStartedResult(mock: { id: string; dir: string; rules: number; targetId: string }): RenderableResult {
40
+ return {
41
+ tag: 'mock',
42
+ attrs: { mock: mock.id, path: mock.dir, state: 'live', rules: mock.rules, target: mock.targetId },
43
+ summary: fact`Installed ${mock.rules} ordered mock rule(s) on the session tab. Requests are answered from the rule document copied to rules.json; first match wins.`,
44
+ artifacts: formatArtifactList([{ name: 'rules.json', note: 'the exact installed rule document' }]),
45
+ followUp: text`Stop and finalize this mock with \`capture tab mock stop\`.`,
46
+ };
47
+ }
48
+
49
+ export async function cmdTabMockStart(parsed: ParsedArgs): Promise<void> {
50
+ if (parsed.help) {
51
+ console.log(HELP);
52
+ return;
53
+ }
54
+ const rulesPath = parsed.rules;
55
+ if (!rulesPath) throw captureError('invocation', 'missing_rules', 'received: no rule document\nexpected: --rules <path>\nfield: --rules\nNext: Run `capture tab mock start -h` and read the schema before re-issuing.');
56
+ const loaded = sourceFor(rulesPath);
57
+ const staged = stagedRulesPath();
58
+ writePrivateFile(staged, loaded.source);
59
+ try {
60
+ const mock = await startMock(staged, loaded.rules);
61
+ emitResult(buildMockStartedResult(mock), { json: parsed.json });
62
+ } finally {
63
+ try { unlinkPrivateFile(staged); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
64
+ }
65
+ }
@@ -0,0 +1,39 @@
1
+ import { emitResult, fact, formatArtifactList, type RenderableResult } from '../../../../output/render.js';
2
+ import { type ParsedArgs } from '../../../types.js';
3
+ import { stopMock, type StoppedMock } from './lifecycle.js';
4
+
5
+ const HELP = `capture tab mock stop — remove the mock rules and finalize the record of what was mocked
6
+
7
+ input:
8
+ (none) the session's one live mock is selected without an id
9
+ output: <mock …> — how many requests paused, how many matched each rule, how many were released unmatched at teardown, and the mock's completion state; --json mirrors
10
+ effects: returns the tab to real network behavior; releases the collector host's \`fetch-interception\` claim and exits the host when nothing else is collecting`;
11
+
12
+ export function buildMockStoppedResult(mock: StoppedMock): RenderableResult {
13
+ const sections = mock.ruleMatches.map((matched, index) => fact`Rule ${index} matched ${matched} request(s).`);
14
+ const incomplete = mock.completion === 'complete' ? [] : [fact`Mock completion is ${mock.completion}; counts reflect the finalized collector record.`];
15
+ return {
16
+ tag: 'mock',
17
+ attrs: {
18
+ mock: mock.id,
19
+ path: mock.dir,
20
+ state: 'stopped',
21
+ completion: mock.completion,
22
+ rules: mock.rules,
23
+ paused: mock.paused,
24
+ matched: mock.matched,
25
+ 'released-unmatched': mock.releasedUnmatched,
26
+ },
27
+ summary: fact`Requests were answered from the rule document copied to rules.json; counts are per rule in document order, first match wins. ${mock.paused} request(s) paused, ${mock.matched} matched a rule, and ${mock.releasedUnmatched} paused request(s) were continued unmodified during teardown.`,
28
+ artifacts: formatArtifactList([{ name: 'rules.json', note: 'the exact installed rule document' }, { name: 'interceptions.jsonl', note: 'one record per paused request' }, { name: 'meta.json', note: 'completion and collector counts' }]),
29
+ sections: [...sections, ...incomplete],
30
+ };
31
+ }
32
+
33
+ export async function cmdTabMockStop(parsed: ParsedArgs): Promise<void> {
34
+ if (parsed.help) {
35
+ console.log(HELP);
36
+ return;
37
+ }
38
+ emitResult(buildMockStoppedResult(await stopMock()), { json: parsed.json });
39
+ }
@@ -15,6 +15,8 @@ import { captureError } from '../../../errors.js';
15
15
  import { getActiveSession, setActiveNetworkOffline } from '../../../session-context.js';
16
16
  import { admitSessionOperation } from '../../../session/coordinator.js';
17
17
  import { sendBridgeRequest } from '../../bridge/client.js';
18
+ import { sendHostRequest } from '../../host/client.js';
19
+ import { scanCollectorHost } from '../../host/handle.js';
18
20
  import { type ParsedArgs } from '../../types.js';
19
21
  import {
20
22
  emitResult,
@@ -133,16 +135,13 @@ export async function cmdTabNetwork(parsed: ParsedArgs, _args: string[]): Promis
133
135
  `Network.enable failed over the session's held bridge: ${enable.error || 'unknown bridge error'}`,
134
136
  );
135
137
  }
136
- const emulate = await sendBridgeRequest(socket, {
137
- method: 'Network.emulateNetworkConditions',
138
- params: {
139
- offline,
140
- latency: offline ? -1 : 0,
141
- downloadThroughput: offline ? 0 : -1,
142
- uploadThroughput: offline ? 0 : -1,
143
- },
144
- targetId,
145
- });
138
+ const params = {
139
+ offline,
140
+ latency: offline ? -1 : 0,
141
+ downloadThroughput: offline ? 0 : -1,
142
+ uploadThroughput: offline ? 0 : -1,
143
+ };
144
+ const emulate = await sendBridgeRequest(socket, { method: 'Network.emulateNetworkConditions', params, targetId });
146
145
  if (!emulate.ok) {
147
146
  throw captureError(
148
147
  'world',
@@ -150,9 +149,19 @@ export async function cmdTabNetwork(parsed: ParsedArgs, _args: string[]): Promis
150
149
  `Network.emulateNetworkConditions failed over the session's held bridge: ${emulate.error || 'unknown bridge error'}`,
151
150
  );
152
151
  }
153
- // Local-main session network retention: record the emulation on the
154
- // active session so later independent command connections re-apply it
155
- // (applyActiveSessionNetworkConditions in cdp/connection.ts).
152
+ const host = scanCollectorHost(session.dir);
153
+ if (host.classification === 'live' && host.handle) {
154
+ if (host.handle.targetId !== targetId) throw captureError('precondition', 'collector_host_target_mismatch', 'collector host is bound to a different session target; stop the session before changing tabs.');
155
+ const hostEnable = await sendHostRequest(host.handle.socketPath, { type: 'cdp', nonce: host.handle.nonce, method: 'Network.enable' });
156
+ if (!hostEnable.ok) throw captureError('world', 'collector_host_error', `Network.enable failed over the collector host: ${hostEnable.error || 'unknown bridge error'}`);
157
+ const hostEmulate = await sendHostRequest(host.handle.socketPath, { type: 'cdp', nonce: host.handle.nonce, method: 'Network.emulateNetworkConditions', params });
158
+ if (!hostEmulate.ok) throw captureError('world', 'collector_host_error', `Network.emulateNetworkConditions failed over the collector host: ${hostEmulate.error || 'unknown bridge error'}`);
159
+ } else if (host.classification !== 'absent') {
160
+ throw captureError('precondition', 'collector_host_unavailable', `session collector host is ${host.classification}.`);
161
+ }
162
+ // Local-main session network retention: record the emulation on the active
163
+ // session only after every live connection carrying the session condition
164
+ // has accepted it.
156
165
  await setActiveNetworkOffline(offline);
157
166
  } finally {
158
167
  await operation.release();
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * Under an active session this is a session-lifecycle transition: it admits
7
7
  * as a session operation, runs under the session's `.lifecycle.lock`, and
8
- * REFUSES while a recording is live (a recorder is bound to the old target;
9
- * reset never rebinds a live recorder). Reset only clears a dangling recorder
10
- * pointer; dead recorders are left for the next stop/start to finalize (their
11
- * frames are preserved). The session's `{targetId, port}` pair is published
8
+ * REFUSES while a recording is live (the collector host is bound to the old
9
+ * target; reset never rebinds it). Reset only clears the session's dangling
10
+ * recording pointer; a dead collector host is left for the next stop/start to
11
+ * finalize its artifacts. The session's `{targetId, port}` pair is published
12
12
  * atomically under the lock.
13
13
  *
14
14
  * Emits a `<tab-reset>` block; page-derived strings flow through `data()`
@@ -23,11 +23,10 @@ import {
23
23
  admitSessionOperation,
24
24
  withSessionLifecycle,
25
25
  withSessionScopeLifecycle,
26
- scanRecorderHandles,
27
- clearDanglingRecorderPointer,
28
26
  type LifecycleSeams,
29
27
  } from '../../../session/coordinator.js';
30
28
  import { captureError, invalidInput } from '../../../errors.js';
29
+ import { scanCollectorHost } from '../../host/handle.js';
31
30
  import { type CDPTarget, type ParsedArgs } from '../../types.js';
32
31
  import {
33
32
  data,
@@ -44,7 +43,7 @@ input:
44
43
  --port <port> CDP endpoint (default: the active session's port, else the auto-discovered preferred endpoint)
45
44
 
46
45
  output: <tab-reset port=… target=…> — the fresh tab's target id and url, plus whether the active session's target was repointed at it.
47
- effects: opens a new background browser tab (the old tab is left behind, not closed) and waits up to 10s for its load event. Under an active session it refuses while a recording is live (stop it first: \`capture motion rec --stop\`), clears a dangling recorder pointer (dead recorders are left for the next stop/start to finalize; their frames are preserved), and updates the session's {target, port} pair together.`;
46
+ effects: opens a new background browser tab (the old tab is left behind, not closed) and waits up to 10s for its load event. Under an active session it refuses while a recording is live (stop it first: \`capture motion rec --stop\`), clears the session's dangling recording pointer (a dead collector host remains for the next stop/start to finalize its artifacts), and updates the session's {target, port} pair together.`;
48
47
 
49
48
  /** Pure `<tab-reset>` result builder — exported for tests. */
50
49
  export function buildTabResetResult(
@@ -144,11 +143,11 @@ export async function cmdTabReset(parsed: ParsedArgs, _args: string[]): Promise<
144
143
  const result = await withSessionLifecycle(session.dir, async () => {
145
144
  const fresh = getActiveSession();
146
145
  if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
147
- const scan = scanRecorderHandles(session.dir, deps.lifecycle);
148
- if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
149
- if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
150
- if (scan.some(h => h.classification === 'live')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
151
- await clearDanglingRecorderPointer(session.dir, scan);
146
+ const host = scanCollectorHost(session.dir);
147
+ if (host.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine collector-host liveness; refusing to reset.');
148
+ if (host.classification === 'malformed') throw captureError('precondition', 'recorder_unavailable', 'A malformed collector-host handle exists on this session; resolve it before resetting.');
149
+ if (host.classification === 'live' && host.handle?.collectors.some(collector => collector.kind === 'motion')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
150
+ if (fresh.activeRecId) await updateSessionState(session.dir, { activeRecId: null });
152
151
  const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
153
152
  const tab = await openTabOrThrow(port, url);
154
153
  await boundedLoadWait(tab);
@@ -12,8 +12,8 @@ import {
12
12
  } from '../har-manager.js';
13
13
  import { getActiveSession, updateActiveSession, type ActiveSessionState } from '../session-context.js';
14
14
  import { withSessionScopeLifecycle } from '../session/coordinator.js';
15
- import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
16
- import { recDirFor, readRecorderJson } from './motion/recorder.js';
15
+ import { MotionHeldClient, isMotionHeldClient } from './host/held-client.js';
16
+ import { scanCollectorHost } from './host/handle.js';
17
17
  import { captureError } from '../errors.js';
18
18
 
19
19
  function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
@@ -41,12 +41,12 @@ export interface ConnectionSeams {
41
41
  resolveTab: (parsed: ParsedArgs) => Promise<{ port: number; tab: CDPTarget } | null>;
42
42
  createClient: (wsUrl: string) => CDPClient;
43
43
  appendHar: typeof appendToHar;
44
- /** Recorder-routed actions only: the recorder bridge's `har-flush` health
45
- * barrier (`RecorderHeldClient.flushHar()`) — resolves once every HAR
44
+ /** Motion-routed actions only: the collector host's `har-flush` health
45
+ * barrier (`MotionHeldClient.flushHar()`) — resolves once every HAR
46
46
  * entry/body/append the recorder's streaming collector had completed at
47
47
  * call time is durably in the owning session's live HAR; throws on a
48
48
  * latched fatal store failure. Never invoked for a non-recorder action. */
49
- flushRecorderHar: (client: RecorderHeldClient) => Promise<void>;
49
+ flushRecorderHar: (client: MotionHeldClient) => Promise<void>;
50
50
  /** Monotonic clock (ms) — measured settle facts derive from it, never from wall time. */
51
51
  now: () => number;
52
52
  sleep: (ms: number) => Promise<void>;
@@ -150,51 +150,31 @@ function connectToActiveRecorder(
150
150
  parsed: ParsedArgs,
151
151
  ): { client: CDPClient; tab: CDPTarget } {
152
152
  const recId = session.activeRecId!;
153
- const recDir = recDirFor(session.dir, recId);
154
- const rj = readRecorderJson(recDir);
155
-
156
- if (!rj) {
157
- throw captureError(
158
- 'precondition',
159
- 'recorder_unavailable',
160
- `The active session claims recording "${recId}" but its live-recorder handle (recorder.json) is missing — it was already finalized or reaped. Recover with: capture motion rec --stop.`,
161
- );
162
- }
163
- if (
164
- typeof rj.socketPath !== 'string' || rj.socketPath.length === 0
165
- || typeof rj.targetId !== 'string' || rj.targetId.length === 0
166
- || typeof rj.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(rj.nonce)
167
- ) {
168
- throw captureError(
169
- 'precondition',
170
- 'recorder_unavailable',
171
- `The active session's recorder handle for "${recId}" is malformed (missing or invalid socketPath/targetId/nonce). Recover with: capture motion rec --stop.`,
172
- );
173
- }
174
- if (rj.state !== 'recording') {
153
+ const scanned = scanCollectorHost(session.dir);
154
+ const collector = scanned.handle?.collectors.find(value => value.id === recId && value.kind === 'motion');
155
+ if (scanned.classification !== 'live' || !scanned.handle || !collector) {
175
156
  throw captureError(
176
157
  'precondition',
177
158
  'recorder_unavailable',
178
- `The active session's recorder "${recId}" is not currently recording (state: ${rj.state}) — it is finalized or mid-teardown. Recover with: capture motion rec --stop.`,
159
+ `The active session claims recording "${recId}" but its live collector-host handle is unavailable. Recover with: capture motion rec --stop.`,
179
160
  );
180
161
  }
181
162
 
182
163
  const actionLabel = deriveActionLabel(parsed);
183
- const client = new RecorderHeldClient({ socketPath: rj.socketPath, nonce: rj.nonce, actionLabel });
164
+ const client = new MotionHeldClient({ socketPath: scanned.handle.socketPath, nonce: scanned.handle.nonce, actionLabel });
184
165
  const tab: CDPTarget = {
185
- id: rj.targetId,
166
+ id: scanned.handle.targetId,
186
167
  title: '',
187
- url: rj.url ?? '',
168
+ url: session.url ?? '',
188
169
  type: 'page',
189
170
  webSocketDebuggerUrl: undefined,
190
171
  };
191
172
 
192
- console.error(`Routing via active recorder ${recId} (recorder-held tab connection, action "${actionLabel}")`);
173
+ console.error(`Routing via active motion collector ${recId} (collector-host tab connection, action "${actionLabel}")`);
193
174
 
194
- // Documented cast — RecorderHeldClient only ever needs to satisfy the
175
+ // Documented cast — MotionHeldClient only ever needs to satisfy the
195
176
  // structural send/on/onDisconnect/close surface every command leaf calls;
196
- // see recorder-client.ts's own header for why this mirrors
197
- // recorder-bridge.ts's `asCDPClient()`.
177
+ // see held-client.ts and host/collectors/motion.ts's `asCDPClient()`.
198
178
  return { client: client as unknown as CDPClient, tab };
199
179
  }
200
180
 
@@ -204,8 +184,8 @@ export async function connectForCommand(
204
184
  const activeSession = seams.getActiveSession();
205
185
  if (activeSession && shouldRouteToRecorder(activeSession, parsed)) {
206
186
  const routed = connectToActiveRecorder(activeSession, parsed);
207
- // The recorder bridge owns its own persistent target connection, so
208
- // the persisted offline/online state must be reissued here too — the
187
+ // The collector host owns its own persistent target connection, so the
188
+ // persisted offline/online state must be reissued here too — the
209
189
  // ephemeral connection that ran `network offline` has since closed.
210
190
  await applyActiveSessionNetworkConditions(routed.client, activeSession, routed.tab.id);
211
191
  return routed;
@@ -285,7 +265,7 @@ interface Collectors {
285
265
  }
286
266
 
287
267
  async function startCollectors(parsed: ParsedArgs, client: CDPClient): Promise<Collectors> {
288
- const routed = isRecorderHeldClient(client);
268
+ const routed = isMotionHeldClient(client);
289
269
 
290
270
  let consoleRecorder: ConsoleRecorder | undefined;
291
271
  if (!routed) {
@@ -433,7 +413,7 @@ export async function withPageAction<T>(
433
413
  // this request.
434
414
  if (collectors.routed) {
435
415
  try {
436
- await seams.flushRecorderHar(client as unknown as RecorderHeldClient);
416
+ await seams.flushRecorderHar(client as unknown as MotionHeldClient);
437
417
  } catch (err) {
438
418
  throw captureError(
439
419
  'artifact',
package/src/cdp/detect.ts CHANGED
@@ -226,22 +226,30 @@ export async function detectCdpPortsAsync(): Promise<CdpEndpoint[]> {
226
226
  // browser out of everything discovered on localhost. Shared so `capture
227
227
  // detect`'s printed default always matches what auto-discovery actually
228
228
  // picks for session start / navigate / etc.
229
- export function pickPreferredEndpoint(
229
+ export interface PreferredEndpointSelection {
230
+ endpoint: CdpEndpoint;
231
+ reason: string;
232
+ }
233
+
234
+ export function pickPreferredEndpointWithReason(
230
235
  endpoints: CdpEndpoint[],
231
236
  defaultBrowser: string | null,
232
- ): CdpEndpoint {
237
+ ): PreferredEndpointSelection {
233
238
  // Endpoints that already host a real page/tab are real browsers. CDP-
234
239
  // speaking non-browser processes (Node/workerd inspectors, etc.) never do,
235
240
  // so prefer real browsers whenever at least one is present — this is what
236
241
  // keeps an unrelated CDP listener from being picked over the intended one.
237
242
  const withPages = endpoints.filter((e) => e.hasPageTarget);
238
243
  const candidates = withPages.length > 0 ? withPages : endpoints;
244
+ const candidateScope = withPages.length > 0
245
+ ? 'among endpoints with a live page target'
246
+ : 'because no discovered endpoint has a live page target';
239
247
 
240
248
  if (defaultBrowser) {
241
249
  const match = candidates.find(
242
250
  (p) => p.bundleId === defaultBrowser && !p.isElectron,
243
251
  );
244
- if (match) return match;
252
+ if (match) return { endpoint: match, reason: `matched the configured default browser ${candidateScope}` };
245
253
  }
246
254
 
247
255
  // Prefer a recognized browser over other Chromium hosts (Spotify, app
@@ -249,12 +257,20 @@ export function pickPreferredEndpoint(
249
257
  const knownBrowser = candidates.find(
250
258
  (endpoint) => endpoint.bundleId !== 'unknown' && !endpoint.isElectron,
251
259
  );
252
- if (knownBrowser) return knownBrowser;
260
+ if (knownBrowser) return { endpoint: knownBrowser, reason: `is a recognized non-Electron browser ${candidateScope}` };
253
261
  const nonElectron = candidates.find((endpoint) => !endpoint.isElectron);
254
- return nonElectron ?? candidates[0];
262
+ if (nonElectron) return { endpoint: nonElectron, reason: `is the first non-Electron endpoint ${candidateScope}` };
263
+ return { endpoint: candidates[0], reason: `is the first discovered endpoint ${candidateScope}` };
255
264
  }
256
265
 
257
- export async function detectCdpPort(): Promise<number> {
266
+ export function pickPreferredEndpoint(
267
+ endpoints: CdpEndpoint[],
268
+ defaultBrowser: string | null,
269
+ ): CdpEndpoint {
270
+ return pickPreferredEndpointWithReason(endpoints, defaultBrowser).endpoint;
271
+ }
272
+
273
+ export async function detectCdpEndpoint(): Promise<CdpEndpoint & { selectionReason: string }> {
258
274
  const endpoints = await detectCdpPortsAsync();
259
275
  if (endpoints.length === 0) {
260
276
  throw new Error(
@@ -264,6 +280,10 @@ export async function detectCdpPort(): Promise<number> {
264
280
  ' Electron apps expose CDP automatically',
265
281
  );
266
282
  }
283
+ const { endpoint, reason } = pickPreferredEndpointWithReason(endpoints, getDefaultBrowserId());
284
+ return { ...endpoint, selectionReason: reason };
285
+ }
267
286
 
268
- return pickPreferredEndpoint(endpoints, getDefaultBrowserId()).port;
287
+ export async function detectCdpPort(): Promise<number> {
288
+ return (await detectCdpEndpoint()).port;
269
289
  }
@@ -2,7 +2,7 @@
2
2
  * Shared dispatch for every routed command. Two jobs live here:
3
3
  *
4
4
  * 1. Route each root (`session`, `page`, `tab`, `measure`, `motion`,
5
- * `cdp`, `lib`, hidden `__bridge-serve`) to its branch main.
5
+ * `perf`, `heap`, `lighthouse`, `cdp`, `lib`, hidden `__bridge-serve`) to its branch main.
6
6
  * 2. Invoke the `--gate` guard ONCE for the whole surface: every command
7
7
  * that is not `measure check|diff` rejects the flag structurally here,
8
8
  * before any branch main runs.
@@ -20,6 +20,10 @@ import { cmdCdp } from './commands/cdp.js';
20
20
  import { cmdBridgeServe } from './commands/bridge-serve.js';
21
21
  import { measureMain } from './commands/measure/index.js';
22
22
  import { motionMain } from './commands/motion/index.js';
23
+ import { perfMain } from './commands/perf/index.js';
24
+ import { heapMain } from './commands/heap/index.js';
25
+ import { cmdLighthouse } from './commands/lighthouse.js';
26
+ import { cmdSessionCollectors } from './commands/session/collectors.js';
23
27
 
24
28
  /** The dotted leaf name the gate rejection reports, derived from the parsed
25
29
  * command plus its first positional (the branch-leaf token). */
@@ -50,10 +54,20 @@ async function withActiveSessionAdmission(parsed: ParsedArgs, run: () => Promise
50
54
  const active = getActiveSession();
51
55
  if (!active) return run();
52
56
  const operation = await admitSessionOperation(active.dir);
57
+ let completed = false;
53
58
  try {
54
- return await run();
59
+ await run();
60
+ completed = true;
55
61
  } finally {
56
- await operation.release();
62
+ try {
63
+ await operation.release();
64
+ } catch (error) {
65
+ if (!completed) throw error;
66
+ // The command's browser effect and rendered measurement already
67
+ // completed. A failed auxiliary lock-owner lookup cannot recast that
68
+ // completed observation as a failed navigation/page command.
69
+ process.stderr.write(`capture: session-operation release unavailable after completed command: ${error instanceof Error ? error.message : String(error)}\n`);
70
+ }
57
71
  }
58
72
  }
59
73
 
@@ -70,13 +84,20 @@ export async function cdpMain(): Promise<void> {
70
84
  const resolved = resolveCliContext(parsed);
71
85
 
72
86
  switch (resolved.command) {
73
- case 'session': return sessionMain(resolved, args);
87
+ case 'session':
88
+ if (resolved.positional[0] === 'collectors') {
89
+ return cmdSessionCollectors({ ...resolved, positional: resolved.positional.slice(1) });
90
+ }
91
+ return sessionMain(resolved, args);
74
92
  case 'page': return withActiveSessionAdmission(resolved, () => pageMain(resolved, args));
75
93
  case 'tab': return tabMain(resolved, args);
76
94
  case 'lib': return cmdLib(resolved, args);
77
95
  case 'cdp': return cmdCdp(resolved, args);
78
96
  case 'measure': return withActiveSessionAdmission(resolved, () => measureMain(resolved, args));
79
97
  case 'motion': return withActiveSessionAdmission(resolved, () => motionMain(resolved, args));
98
+ case 'perf': return withActiveSessionAdmission(resolved, () => perfMain(resolved, args));
99
+ case 'heap': return withActiveSessionAdmission(resolved, () => heapMain(resolved, args));
100
+ case 'lighthouse': return withActiveSessionAdmission(resolved, () => Promise.resolve(cmdLighthouse(resolved, args)));
80
101
  case '__bridge-serve': return cmdBridgeServe(resolved, args);
81
102
  default:
82
103
  // capture.ts routes only the known roots into cdpMain(); an unknown
@@ -29,11 +29,11 @@ export async function enableDomainsForSnap(client: CDPClient): Promise<void> {
29
29
  }
30
30
 
31
31
  /**
32
- * Enables the CDP domains the motion recorder bridge needs: frame/navigation metadata and
32
+ * Enables the CDP domains the motion collector needs: frame/navigation metadata and
33
33
  * `Page.startScreencast` (Page), element geometry for rect/selector resolution (DOM), script
34
34
  * execution for the injected Mutation/Resize/Performance observers (Runtime), request/response
35
35
  * timing for `motion response` (Network), and playback-rate control for the animation inventory
36
- * (Animation). `Tracing.start` is configured with its own category set by the recorder bridge
36
+ * (Animation). `Tracing.start` is configured with its own category set by the motion collector
37
37
  * (U13) and is not a plain `.enable` call, so it is not included here.
38
38
  */
39
39
  export async function enableDomainsForMotionRec(client: CDPClient): Promise<void> {
@@ -514,7 +514,6 @@ export class HARRecorder {
514
514
  const terminal = current.terminal!;
515
515
  if (responseTimestamp !== null && responseTimestamp < current.requestMonotonic) return 'response_before_request';
516
516
  if (terminal.timestamp < current.requestMonotonic) return 'terminal_before_request';
517
- if (responseTimestamp !== null && terminal.timestamp < responseTimestamp) return 'terminal_before_response';
518
517
  return null;
519
518
  }
520
519