@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
@@ -8,42 +8,36 @@ import { spawn } from 'child_process';
8
8
  import * as crypto from 'crypto';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
- import { CAPTURE_ROOT, ensurePrivateDir } from '../../session/artifacts.js';
11
+ import { CAPTURE_ROOT } from '../../session/artifacts.js';
12
12
 
13
13
  export function bridgeSocketPath(sessionDir: string): string {
14
14
  return path.join(sessionDir, 'bridge.sock');
15
15
  }
16
16
 
17
17
  /**
18
- * Fixed, short, private (`0700`) directory every recorder socket lives in — flat, and
19
- * deliberately NOT nested under a recording's (long) artifact dir. macOS caps `AF_UNIX`
20
- * pathnames at ~104 bytes; a real recording dir (`{CAPTURE_ROOT}/{session}/motion/recs/{recId}`)
21
- * combined with `os.tmpdir()`'s own (often long, per-user) prefix can already exceed that before
22
- * adding a filename, so the socket can never live inside `recDir` — see `recorderSocketPath()`.
18
+ * The collector host socket is stored in a short private directory because
19
+ * macOS caps `AF_UNIX` paths at about 104 bytes. This cannot live beneath
20
+ * CAPTURE_ROOT: test and caller roots can already consume that entire budget.
23
21
  */
24
- function recorderSocketDir(): string {
25
- return ensurePrivateDir(path.join(CAPTURE_ROOT, 'sock'));
22
+ function collectorSocketDir(): string {
23
+ const dir = '/tmp/capture-sockets';
24
+ try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
26
+ }
27
+ const stat = fs.lstatSync(dir);
28
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`collector socket directory is not a real directory: ${dir}`);
29
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw new Error(`collector socket directory is not owned by this user: ${dir}`);
30
+ fs.chmodSync(dir, 0o700);
31
+ return dir;
26
32
  }
27
33
 
28
- /**
29
- * A short, fixed-length filename deterministically derived from `recDir` (not from `recDir`'s
30
- * own length/depth), so the resulting socket path stays bounded regardless of how deep the
31
- * recording's session/motion/recs nesting is.
32
- */
33
- function shortSocketName(recDir: string): string {
34
- return crypto.createHash('sha1').update(path.resolve(recDir)).digest('hex').slice(0, 16);
34
+ function shortSocketName(scopeDir: string): string {
35
+ return crypto.createHash('sha1').update(path.resolve(scopeDir)).digest('hex').slice(0, 16);
35
36
  }
36
37
 
37
- /**
38
- * The recorder bridge's own socket path. Lives in the short, flat `recorderSocketDir()` — NOT
39
- * inside `recDir` (the long, deep artifact directory `frames`/`events.jsonl`/etc. write into) —
40
- * so binding it never risks exceeding the platform's `AF_UNIX` pathname limit. `recDir` is still
41
- * required by every recorder-mode caller (spawn args, artifact writers); this function only
42
- * derives the socket's own location from it, deterministically, without embedding `recDir` in
43
- * the path itself.
44
- */
45
- export function recorderSocketPath(recDir: string): string {
46
- return path.join(recorderSocketDir(), `${shortSocketName(recDir)}.sock`);
38
+ /** The one collector host socket is keyed by its session, not an individual artifact. */
39
+ export function collectorHostSocketPath(sessionDir: string): string {
40
+ return path.join(collectorSocketDir(), `host-${shortSocketName(sessionDir)}.sock`);
47
41
  }
48
42
 
49
43
  export async function startBridge(
@@ -85,6 +79,27 @@ export async function startBridge(
85
79
  return { socketPath, pid };
86
80
  }
87
81
 
82
+ export async function startCollectorHost(
83
+ socketPath: string,
84
+ port: number,
85
+ targetId: string,
86
+ sessionDir: string,
87
+ timeoutMs = 5000,
88
+ ): Promise<{ socketPath: string; pid: number }> {
89
+ const child = spawn(process.execPath, [process.argv[1], '__bridge-serve', '--socket', socketPath, '--port', String(port), '--target', targetId, 'host', sessionDir], { detached: true, stdio: 'ignore' });
90
+ child.unref();
91
+ if (!child.pid) throw new Error('Failed to spawn collector host process.');
92
+ const started = Date.now();
93
+ while (!fs.existsSync(socketPath)) {
94
+ if (Date.now() - started > timeoutMs) {
95
+ stopBridge(child.pid, socketPath);
96
+ throw new Error(`Collector host (pid ${child.pid}) did not come up within ${timeoutMs}ms.`);
97
+ }
98
+ await new Promise(resolve => setTimeout(resolve, 50));
99
+ }
100
+ return { socketPath, pid: child.pid };
101
+ }
102
+
88
103
  export function stopBridge(pid: number | null | undefined, socketPath: string | null | undefined): void {
89
104
  if (pid) {
90
105
  try {
@@ -95,80 +110,9 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
95
110
  }
96
111
  if (socketPath) {
97
112
  try {
98
- fs.unlinkSync(socketPath);
113
+ if (fs.lstatSync(socketPath).isSocket()) fs.unlinkSync(socketPath);
99
114
  } catch {
100
115
  // Already gone.
101
116
  }
102
117
  }
103
118
  }
104
-
105
- /**
106
- * Spawns the same detached `__bridge-serve` entrypoint in recorder mode:
107
- * it connects to one tab (`targetId`) instead of the browser level, and
108
- * drives `capture motion rec`'s screencast/tracing/observers instead of
109
- * proxying arbitrary `--browser` CDP calls. Mirrors `startBridge()`'s
110
- * spawn + "wait for the socket file to appear" pattern; the caller
111
- * (U14's lifecycle routing) tears it down with the same `stopBridge()`
112
- * used for the plain held bridge.
113
- *
114
- * Recorder mode is selected via a positional (`recorder <recDir> <harId>`)
115
- * rather than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is
116
- * not owned by this unit, but `positional` is already generic passthrough.
117
- *
118
- * `harId` is the owning session's live HAR recording id (`.session.json`'s
119
- * `harId`) — the recorder bridge installs a streaming `HARRecorder` on its
120
- * held tab connection and appends network evidence to that store, so the id
121
- * must travel to the spawned process. Required: a session-backed recorder
122
- * always has one (the caller throws before spawning if it doesn't).
123
- *
124
- * The socket-file readiness poll below doubles as the nonce-boot-file
125
- * guarantee: the recorder writes `{recDir}/recorder-nonce.json` BEFORE it
126
- * binds the socket (see `../recorder-bridge.ts`), so once this resolves the
127
- * caller can read the nonce without racing the child.
128
- */
129
- export async function startRecorderBridge(
130
- socketPath: string,
131
- port: number,
132
- targetId: string,
133
- recDir: string,
134
- harId: string,
135
- timeoutMs = 5000,
136
- ): Promise<{ socketPath: string; pid: number }> {
137
- const scriptPath = process.argv[1];
138
- const child = spawn(
139
- process.execPath,
140
- [
141
- scriptPath,
142
- '__bridge-serve',
143
- '--socket',
144
- socketPath,
145
- '--port',
146
- String(port),
147
- '--target',
148
- targetId,
149
- 'recorder',
150
- recDir,
151
- harId,
152
- ],
153
- { detached: true, stdio: 'ignore' },
154
- );
155
- child.unref();
156
- const pid = child.pid;
157
- if (!pid) {
158
- throw new Error('Failed to spawn recorder bridge process.');
159
- }
160
-
161
- const start = Date.now();
162
- while (!fs.existsSync(socketPath)) {
163
- if (Date.now() - start > timeoutMs) {
164
- stopBridge(pid, socketPath);
165
- throw new Error(
166
- `Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
167
- `Check that target "${targetId}" is reachable on port ${port}.`,
168
- );
169
- }
170
- await new Promise((r) => setTimeout(r, 50));
171
- }
172
-
173
- return { socketPath, pid };
174
- }
@@ -1,18 +1,15 @@
1
1
  import { runBridgeServer } from '../bridge/server.js';
2
- import { runRecorderBridge } from '../recorder-bridge.js';
2
+ import { runCollectorHost } from '../host/server.js';
3
3
  import { invalidInput } from '../../errors.js';
4
4
  import { type ParsedArgs } from '../types.js';
5
5
 
6
6
  /**
7
7
  * Hidden entrypoint spawned detached by `capture session start --hold`
8
8
  * (see `bridge/spawn.ts`'s `startBridge()`) or by `capture motion rec
9
- * --start` in recorder mode (`startRecorderBridge()`). Not documented in
10
- * `capture --help` — nothing calls this directly.
11
- *
12
- * Recorder mode is selected by a `recorder <recDir> <harId>` positional
13
- * rather than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't
14
- * owned by this unit but `parsed.positional` is already generic passthrough:
15
- * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>
9
+ * --start` in collector-host mode. Not documented in `capture --help` —
10
+ * nothing calls this directly. Collector-host mode is selected by the
11
+ * `host <sessionDir>` positional:
12
+ * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> host <sessionDir>
16
13
  *
17
14
  * Usage errors throw the repo's structured `CaptureError` — the root
18
15
  * boundary in `src/capture.ts` renders it via `failureResult()` and exits
@@ -21,24 +18,17 @@ import { type ParsedArgs } from '../types.js';
21
18
  export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
22
19
  if (!parsed.socket) {
23
20
  throw invalidInput(
24
- 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir> <harId>]',
21
+ 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId>] [host <sessionDir>]',
25
22
  'bridge_serve_usage',
26
23
  );
27
24
  }
28
25
 
29
- if (parsed.positional[0] === 'recorder') {
30
- const recDir = parsed.positional[1];
31
- const harId = parsed.positional[2];
32
- if (!parsed.target || !recDir || !harId) {
33
- throw invalidInput(
34
- 'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir> <harId>',
35
- 'bridge_serve_usage',
36
- );
26
+ if (parsed.positional[0] === 'host') {
27
+ const sessionDir = parsed.positional[1];
28
+ if (!parsed.target || !sessionDir || parsed.port === undefined) {
29
+ throw invalidInput('Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> host <sessionDir>', 'bridge_serve_usage');
37
30
  }
38
- await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir, harId });
39
- // Deliberately does not exit: the open Unix socket server and the live
40
- // tab websocket keep this detached process alive until an authenticated
41
- // `rec-stop` completes (self-exit) or the caller escalates with SIGTERM.
31
+ await runCollectorHost({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, sessionDir });
42
32
  return;
43
33
  }
44
34
 
@@ -24,7 +24,7 @@ import { connectForCommand } from '../connection.js';
24
24
  import { getBrowserClient, findTabById } from '../targets.js';
25
25
  import { detectCdpPort } from '../detect.js';
26
26
  import { sendBridgeRequest } from '../bridge/client.js';
27
- import { isRecorderHeldClient } from '../recorder-client.js';
27
+ import { isMotionHeldClient } from '../host/held-client.js';
28
28
  import { type ParsedArgs } from '../types.js';
29
29
  import {
30
30
  capped,
@@ -45,8 +45,7 @@ const GENEROUS_RESULT_CAP = 4000;
45
45
  /** Root-help representation of this leaf, assembled by `src/capture.ts`. */
46
46
  export const COMMAND_BLOCK = `<command name="cdp">
47
47
  raw CDP escape hatch — send any protocol method capture doesn't wrap, or wait for a protocol event
48
- use when no other capture command covers the protocol surface you need (Browser.*, ServiceWorker.*, Target.*, ...)
49
- cdp [<Domain.method>] [--params <json>] [--browser] [--wait-event <name>] — \`capture cdp -h\`
48
+ use when sending a CDP method or waiting for an event Capture does not otherwise expose
50
49
  </command>`;
51
50
 
52
51
  const HELP = `capture cdp — send a raw CDP protocol method and/or wait for a protocol event: the escape hatch for domains no other capture command wraps (Browser.*, ServiceWorker.*, Target.*, ...).
@@ -60,6 +59,9 @@ Input:
60
59
  --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
61
60
  --timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
62
61
 
62
+ Connection lifetime:
63
+ Unless --browser routes through a held session, each invocation opens an isolated CDP connection and closes it when the command exits. Domain enables and remote object handles (objectIds) are connection-scoped, so a later one-shot invocation cannot use either. A multi-step protocol sequence needs connection state that survives between its calls.
64
+
63
65
  Output:
64
66
  <cdp-result method=… wait-event=… scope=…> — the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
65
67
 
@@ -68,7 +70,7 @@ Effects:
68
70
 
69
71
  /**
70
72
  * The minimal client surface both scopes drive. Satisfied structurally by
71
- * `CDPClient`, `RecorderHeldClient`, and test stubs \u2014 the injectable
73
+ * `CDPClient`, `MotionHeldClient`, and test stubs \u2014 the injectable
72
74
  * `connect` parameter on `runPageScope` exists so tests can exercise the
73
75
  * command wiring against a stub without a live browser.
74
76
  */
@@ -145,8 +147,15 @@ const TAB_SCOPE_RECOVERY =
145
147
  'Many CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are tab-scoped, not connection-scoped — re-run with --target <tabId> (`capture tab list` shows available tabs).';
146
148
 
147
149
  /** Appends the tab-scope recovery hint when the failed call was target-less. */
148
- function cdpFailed(message: string, flagTarget: string | undefined, cause?: unknown): CaptureError {
149
- return captureError('world', 'cdp_failed', flagTarget ? message : `${message} ${TAB_SCOPE_RECOVERY}`, cause);
150
+ function timeoutGuidance(message: string, timeoutMs: number): string {
151
+ return /\b(?:timed out|timeout)\b/i.test(message)
152
+ ? `${message} The request limit was ${timeoutMs}ms; re-run with \`--timeout <ms>\` to extend it.`
153
+ : message;
154
+ }
155
+
156
+ function cdpFailed(message: string, flagTarget: string | undefined, timeoutMs: number, cause?: unknown): CaptureError {
157
+ const detailed = timeoutGuidance(message, timeoutMs);
158
+ return captureError('world', 'cdp_failed', flagTarget ? detailed : `${detailed} ${TAB_SCOPE_RECOVERY}`, cause);
150
159
  }
151
160
 
152
161
  export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
@@ -193,7 +202,7 @@ export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void>
193
202
  throw captureError(
194
203
  'world',
195
204
  'cdp_failed',
196
- `\`${invocationLabel(method, parsed.waitEvent)}\` failed on the page connection: ${err instanceof Error ? err.message : String(err)}`,
205
+ timeoutGuidance(`\`${invocationLabel(method, parsed.waitEvent)}\` failed on the page connection: ${err instanceof Error ? err.message : String(err)}`, timeoutMs),
197
206
  err,
198
207
  );
199
208
  }
@@ -224,17 +233,29 @@ export async function runBrowserScope(
224
233
  const bridgeSocket = session?.bridgeSocket;
225
234
  const heldBridgeActive = Boolean(bridgeSocket && fs.existsSync(bridgeSocket));
226
235
  if (parsed.portSource !== 'flag' && bridgeSocket && heldBridgeActive) {
227
- const resp = await deps.sendBridgeRequest(bridgeSocket, {
228
- method,
229
- params,
230
- targetId: flagTarget,
231
- waitEvent: parsed.waitEvent,
232
- timeoutMs,
233
- });
236
+ let resp;
237
+ try {
238
+ resp = await deps.sendBridgeRequest(bridgeSocket, {
239
+ method,
240
+ params,
241
+ targetId: flagTarget,
242
+ waitEvent: parsed.waitEvent,
243
+ timeoutMs,
244
+ });
245
+ } catch (err) {
246
+ if (err instanceof CaptureError) throw err;
247
+ throw cdpFailed(
248
+ `\`${invocationLabel(method, parsed.waitEvent)}\` failed over the held connection: ${err instanceof Error ? err.message : String(err)}`,
249
+ flagTarget,
250
+ timeoutMs,
251
+ err,
252
+ );
253
+ }
234
254
  if (!resp.ok) {
235
255
  throw cdpFailed(
236
256
  `\`${invocationLabel(method, parsed.waitEvent)}\` failed over the held connection: ${resp.error ?? 'unknown bridge error'}`,
237
257
  flagTarget,
258
+ timeoutMs,
238
259
  );
239
260
  }
240
261
  emitCdpResult({
@@ -299,6 +320,7 @@ export async function runBrowserScope(
299
320
  throw cdpFailed(
300
321
  `\`${invocationLabel(method, parsed.waitEvent)}\` failed on the one-shot browser connection: ${err instanceof Error ? err.message : String(err)}`,
301
322
  flagTarget,
323
+ timeoutMs,
302
324
  err,
303
325
  );
304
326
  } finally {
@@ -309,7 +331,7 @@ export async function runBrowserScope(
309
331
  /**
310
332
  * Exported for testing (`test/recorder-navigate-waitevent.test.ts`,
311
333
  * `test/cdp-command.test.ts`): the recorder-held branch below
312
- * (`isRecorderHeldClient(client)`) is the actual command wiring
334
+ * (`isMotionHeldClient(client)`) is the actual command wiring
313
335
  * `capture cdp --wait-event` runs under an active recording, and the
314
336
  * injectable `connect` lets `test/cdp-command.test.ts` drive the non-held
315
337
  * branch against a stub client.
@@ -325,17 +347,17 @@ export async function runPageScope(
325
347
  try {
326
348
  let result: unknown;
327
349
  let event: unknown;
328
- if (isRecorderHeldClient(client)) {
329
- // The recorder-held adapter's `.on()` is a documented no-op (nothing
350
+ if (isMotionHeldClient(client)) {
351
+ // The motion-held adapter's `.on()` is a documented no-op (nothing
330
352
  // pushes unsolicited events back over its one-request-one-response
331
353
  // socket) — `waitForEventOnce` below would hang until its own timeout.
332
354
  // `.waitEvent()`/`.dispatch()` are the real event-wait surface for this
333
- // adapter, routing the wait through the recorder bridge's own event
334
- // broker. When both a method and a wait-event are requested,
335
- // `.dispatch()` carries them in ONE request so the bridge arms the wait
336
- // before dispatching the call — sending them as two separate requests
355
+ // adapter, routing the wait through the collector host's event broker.
356
+ // When both a method and a wait-event are requested, `.dispatch()`
357
+ // carries them in ONE request so the host arms the wait before
358
+ // dispatching the call — sending them as two separate requests
337
359
  // (`.send()` then `.waitEvent()`) risks the action firing the event
338
- // before the wait-only request even reaches the bridge. This command has
360
+ // before the wait-only request even reaches the host. This command has
339
361
  // no fragment-nav-style multi-call logic and already throws on an
340
362
  // event-wait timeout, so one combined request preserves the complete
341
363
  // result contract (contrast `page/navigate.ts`'s multi-call
@@ -0,0 +1,61 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { capped, emitResult, fact, formatArtifactList, lineList, text } from '../../../output/render.js';
3
+ import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
4
+ import { selectRecords } from '../../../output/selection.js';
5
+
6
+ const HELP = `capture heap census <snapshot> [--axis constructor|string] [--limit <N>] — what the heap is made of
7
+
8
+ input:
9
+ <snapshot> heap snapshot id in the active session or an absolute snapshot path (required)
10
+ --axis <axis> constructor (default) groups every node by constructor name with node count and retained bytes; string groups equal-content strings with their instance count and estimated duplicate bytes
11
+ --limit <N> return the top N groups by retained bytes; default 25, including under --json
12
+ output: <heap-census …> — one row per group with its counts and sizes, plus the size qualification for the chosen axis; --json mirrors
13
+ effects: read-only — reads the finalized snapshot artifact, never drives the browser`;
14
+
15
+ export function cmdHeapCensus(parsed: ParsedArgs): void {
16
+ if (parsed.help) {
17
+ console.log(HELP);
18
+ return;
19
+ }
20
+ const ref = resolveHeapRef(parsed.positional[0]);
21
+ const heap = loadHeap(ref);
22
+ const limit = parsed.limit ?? 25;
23
+ const axis = parsed.axis ?? 'constructor';
24
+ if (axis === 'string') {
25
+ const duplicate = heap.duplicateStrings();
26
+ const groups = [...duplicate.duplicates].sort((a, b) => b.wastedBytes - a.wastedBytes || b.totalSelfBytes - a.totalSelfBytes || a.value.localeCompare(b.value));
27
+ const displayed = selectRecords(groups, parsed, limit);
28
+ emitResult({
29
+ tag: 'heap-census',
30
+ attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), axis, groups: groups.length, displayed: displayed.length, nodes: heap.nodeCount, 'size-qualification': duplicate.sizeQualification },
31
+ summary: text`Equal-content string instances grouped from the Chrome heap snapshot.`,
32
+ artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
33
+ sections: [fact`${duplicate.sizeQualification}`, lineList(displayed.map((group, index) => fact`${index + 1}. ${capped(group.value, 120)} · instances=${group.count} self-bytes=${group.totalSelfBytes} wasted-bytes=${group.wastedBytes}`))],
34
+ jsonSections: displayed.map(group => ({ string: capped(group.value, Number.MAX_SAFE_INTEGER), instanceCount: group.count, selfBytes: group.totalSelfBytes, wastedBytes: group.wastedBytes, sizeQualification: duplicate.sizeQualification })),
35
+ followUp: fact`Use \`capture heap objects ${resultReference(ref)} --constructor <name>\` to inspect one constructor, or \`capture heap diff --before ${resultReference(ref)} --after <snapshot>\` to compare snapshots.`,
36
+ }, { json: parsed.json });
37
+ return;
38
+ }
39
+
40
+ const dominators = heap.computeDominators();
41
+ const grouped = new Map<string, { constructor: string; nodeCount: number; retainedBytes: number; selfBytes: number }>();
42
+ for (let index = 0; index < heap.nodeCount; index += 1) {
43
+ const node = heap.nodeAt(index);
44
+ const group = grouped.get(node.name) ?? { constructor: node.name, nodeCount: 0, retainedBytes: 0, selfBytes: 0 };
45
+ group.nodeCount += 1;
46
+ group.retainedBytes += dominators.retainedSizes[index];
47
+ group.selfBytes += node.selfSize;
48
+ grouped.set(node.name, group);
49
+ }
50
+ const groups = [...grouped.values()].sort((a, b) => b.retainedBytes - a.retainedBytes || b.selfBytes - a.selfBytes || a.constructor.localeCompare(b.constructor));
51
+ const displayed = selectRecords(groups, parsed, limit);
52
+ emitResult({
53
+ tag: 'heap-census',
54
+ attrs: { heap: ref.id, path: ref.dir, ...completionAttrs(ref.meta), axis, groups: groups.length, displayed: displayed.length, nodes: heap.nodeCount, 'size-qualification': dominators.sizeQualification },
55
+ summary: text`Constructor totals from the V8 heap snapshot.`,
56
+ artifacts: formatArtifactList([{ name: 'snapshot.heapsnapshot' }]),
57
+ sections: [fact`${dominators.sizeQualification}`, lineList(displayed.map((group, index) => fact`${index + 1}. ${capped(group.constructor, 120)} · nodes=${group.nodeCount} retained-bytes=${group.retainedBytes} self-bytes=${group.selfBytes}`))],
58
+ jsonSections: displayed.map(group => ({ constructor: capped(group.constructor, Number.MAX_SAFE_INTEGER), nodeCount: group.nodeCount, retainedBytes: group.retainedBytes, selfBytes: group.selfBytes, sizeQualification: dominators.sizeQualification })),
59
+ followUp: fact`Use \`capture heap objects ${resultReference(ref)} --constructor <name>\` for Chrome snapshot object ids and \`capture heap retainers ${resultReference(ref)} --node <object-id>\` for a retaining path.`,
60
+ }, { json: parsed.json });
61
+ }
@@ -0,0 +1,77 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { captureError } from '../../../errors.js';
4
+ import { HeapSnapshot } from '../../heap-snapshot.js';
5
+ import { assertUnderCaptureRoot, readPrivateFile } from '../../../session/artifacts.js';
6
+ import { getActiveSession } from '../../../session-context.js';
7
+ import type { ResolvedCompletion } from '../../host/collector.js';
8
+
9
+ export interface HeapMeta {
10
+ id: string;
11
+ completion: ResolvedCompletion;
12
+ reason?: string;
13
+ url?: string;
14
+ files: readonly { name: string; bytes: number }[];
15
+ }
16
+
17
+ export interface HeapRef {
18
+ readonly kind: 'heap';
19
+ readonly id: string;
20
+ readonly dir: string;
21
+ readonly meta: HeapMeta;
22
+ }
23
+
24
+ function readHeapMeta(dir: string, ref: string): HeapMeta {
25
+ const metaPath = path.join(dir, 'meta.json');
26
+ let raw: Record<string, unknown>;
27
+ try {
28
+ raw = JSON.parse(readPrivateFile(metaPath).toString('utf8')) as Record<string, unknown>;
29
+ } catch (error) {
30
+ throw captureError('artifact', 'heap_ref_unavailable', `Could not read finalized heap snapshot ${JSON.stringify(ref)} at ${dir}: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ if (raw.kind !== 'heap' || typeof raw.id !== 'string' || !['complete', 'partial', 'orphaned'].includes(String(raw.completion))) {
33
+ throw captureError('artifact', 'heap_ref_unavailable', `Artifact ${JSON.stringify(ref)} is not a finalized heap snapshot.`);
34
+ }
35
+ const files = Array.isArray(raw.files) && raw.files.every(file => file && typeof file === 'object' && typeof (file as { name?: unknown }).name === 'string' && typeof (file as { bytes?: unknown }).bytes === 'number')
36
+ ? raw.files as Array<{ name: string; bytes: number }>
37
+ : [];
38
+ const snapshot = files.find(file => file.name === 'snapshot.heapsnapshot');
39
+ let completion = raw.completion as ResolvedCompletion;
40
+ if (!snapshot || !fs.existsSync(path.join(dir, 'snapshot.heapsnapshot')) || fs.statSync(path.join(dir, 'snapshot.heapsnapshot')).size !== snapshot.bytes) completion = 'truncated';
41
+ return { id: raw.id, completion, ...(typeof raw.reason === 'string' ? { reason: raw.reason } : {}), ...(typeof raw.url === 'string' ? { url: raw.url } : {}), files };
42
+ }
43
+
44
+ export function resolveHeapRef(ref: string): HeapRef {
45
+ let dir: string;
46
+ if (path.isAbsolute(ref)) {
47
+ const resolved = assertUnderCaptureRoot(ref);
48
+ dir = path.basename(resolved) === 'snapshot.heapsnapshot' ? path.dirname(resolved) : resolved;
49
+ } else {
50
+ if (ref.includes('/') || ref.includes(path.sep)) throw captureError('invocation', 'heap_ref_invalid', 'Heap snapshot references must be a bare id or an absolute artifact path.');
51
+ const active = getActiveSession();
52
+ if (!active) throw captureError('precondition', 'heap_ref_unavailable', `A bare heap snapshot id (${ref}) requires an active capture session; pass the absolute path printed by capture heap snapshot.`);
53
+ dir = assertUnderCaptureRoot(path.join(active.dir, 'heap', 'snapshots', ref));
54
+ }
55
+ return { kind: 'heap', id: path.basename(dir), dir, meta: readHeapMeta(dir, ref) };
56
+ }
57
+
58
+ export function loadHeap(ref: HeapRef): HeapSnapshot {
59
+ const snapshotPath = path.join(ref.dir, 'snapshot.heapsnapshot');
60
+ if (ref.meta.completion === 'truncated' || !fs.existsSync(snapshotPath)) {
61
+ throw captureError('artifact', 'heap_snapshot_truncated', `Heap snapshot ${ref.id} is ${ref.meta.completion}; snapshot.heapsnapshot does not match its finalized inventory.`);
62
+ }
63
+ try {
64
+ return HeapSnapshot.parse(readPrivateFile(snapshotPath).toString('utf8'));
65
+ } catch (error) {
66
+ throw captureError('artifact', 'heap_snapshot_invalid', `Heap snapshot ${ref.id} could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
67
+ }
68
+ }
69
+
70
+ export function completionAttrs(meta: HeapMeta): { completion: ResolvedCompletion; 'completion-reason'?: string } {
71
+ return { completion: meta.completion, ...(meta.completion !== 'complete' && meta.reason ? { 'completion-reason': meta.reason } : {}) };
72
+ }
73
+
74
+ export function resultReference(ref: HeapRef): string {
75
+ const active = getActiveSession();
76
+ return active && path.join(active.dir, 'heap', 'snapshots', ref.id) === ref.dir ? ref.id : ref.dir;
77
+ }
@@ -0,0 +1,92 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { HeapSnapshot } from '../../heap-snapshot.js';
3
+ import { capped, emitResult, fact, formatArtifactList, lineList, text } from '../../../output/render.js';
4
+ import { completionAttrs, loadHeap, resolveHeapRef, resultReference } from './common.js';
5
+ import { selectRecords } from '../../../output/selection.js';
6
+
7
+ const HELP = `capture heap diff --before <snapshot> --after <snapshot> [--limit <N>] — what changed between two heap snapshots
8
+
9
+ input:
10
+ --before <snapshot> required. Heap snapshot id in the active session or an absolute snapshot path
11
+ --after <snapshot> required. Heap snapshot id in the active session or an absolute snapshot path
12
+ --limit <N> return the top N constructors by added retained bytes and top N after-snapshot nodes Chrome marks detached in prose and JSON; default 25
13
+ output: <heap-diff …> — before/after snapshot node and serialized-byte totals, then per constructor nodes added, removed, and grown with retained-byte and detached-node totals; after-snapshot nodes Chrome marks detached are listed with their object ids; --json mirrors
14
+ effects: read-only — reads both finalized snapshot artifacts, never drives the browser`;
15
+
16
+ export function cmdHeapDiff(parsed: ParsedArgs): void {
17
+ if (parsed.help) {
18
+ console.log(HELP);
19
+ return;
20
+ }
21
+ const before = resolveHeapRef(parsed.before!);
22
+ const after = resolveHeapRef(parsed.after!);
23
+ const beforeHeap = loadHeap(before);
24
+ const afterHeap = loadHeap(after);
25
+ const comparison = HeapSnapshot.compare(beforeHeap, afterHeap);
26
+ const snapshotBytes = (ref: typeof before) => ref.meta.files.find((file) => file.name === 'snapshot.heapsnapshot')?.bytes;
27
+ const totals = {
28
+ before: { nodes: beforeHeap.nodeCount, bytes: snapshotBytes(before) },
29
+ after: { nodes: afterHeap.nodeCount, bytes: snapshotBytes(after) },
30
+ };
31
+ const constructors = [...comparison.constructors].sort((a, b) => b.added.retainedSize - a.added.retainedSize || b.grown.retainedSize - a.grown.retainedSize || b.removed.retainedSize - a.removed.retainedSize || a.constructorName.localeCompare(b.constructorName));
32
+ const limit = parsed.limit ?? 25;
33
+ const displayed = selectRecords(constructors, parsed, limit);
34
+ const detached = selectRecords(comparison.afterDetachedNodes, parsed, limit);
35
+ const detachedSection = detached.length
36
+ ? lineList(detached.map((node, index) => fact`${index + 1}. object-id=${node.objectId} constructor=${capped(node.constructorName, 120)} type=${node.type} detachedness=${node.detachedness}`))
37
+ : text`No after-snapshot nodes are marked detached by Chrome.`;
38
+ const jsonSections = [
39
+ { before: totals.before, after: totals.after },
40
+ ...displayed.map(constructor => ({
41
+ constructor: capped(constructor.constructorName, Number.MAX_SAFE_INTEGER),
42
+ added: { nodeCount: constructor.added.nodeCount, detachedNodeCount: constructor.added.detachedNodeCount, retainedSize: constructor.added.retainedSize },
43
+ removed: { nodeCount: constructor.removed.nodeCount, detachedNodeCount: constructor.removed.detachedNodeCount, retainedSize: constructor.removed.retainedSize },
44
+ grown: { nodeCount: constructor.grown.nodeCount, detachedNodeCount: constructor.grown.detachedNodeCount, retainedSize: constructor.grown.retainedSize },
45
+ retainedSizeQualification: comparison.retainedSizeQualification,
46
+ matching: comparison.matching,
47
+ })),
48
+ ...(comparison.afterDetachedNodes.length ? [{
49
+ afterDetachedNodes: detached.map(node => ({
50
+ objectId: node.objectId,
51
+ constructor: capped(node.constructorName, Number.MAX_SAFE_INTEGER),
52
+ type: node.type,
53
+ detachedness: node.detachedness,
54
+ })),
55
+ total: comparison.afterDetachedNodes.length,
56
+ displayed: detached.length,
57
+ detachednessQualification: 'Chrome detachedness metadata value 2 marks these after-snapshot nodes detached.',
58
+ }] : []),
59
+ ];
60
+ emitResult({
61
+ tag: 'heap-diff',
62
+ attrs: {
63
+ before: before.id,
64
+ 'before-path': before.dir,
65
+ ...Object.fromEntries(Object.entries(completionAttrs(before.meta)).map(([key, value]) => [`before-${key}`, value])),
66
+ after: after.id,
67
+ 'after-path': after.dir,
68
+ ...Object.fromEntries(Object.entries(completionAttrs(after.meta)).map(([key, value]) => [`after-${key}`, value])),
69
+ matching: comparison.matching,
70
+ constructors: constructors.length,
71
+ displayed: displayed.length,
72
+ 'after-detached-nodes': comparison.afterDetachedNodes.length,
73
+ 'detached-displayed': detached.length,
74
+ 'retained-size-qualification': comparison.retainedSizeQualification,
75
+ 'before-nodes': totals.before.nodes,
76
+ 'before-bytes': totals.before.bytes,
77
+ 'after-nodes': totals.after.nodes,
78
+ 'after-bytes': totals.after.bytes,
79
+ },
80
+ summary: text`Nodes are matched by Chrome snapshot object id. “grown” reports the increase in retained size for id-matched nodes, not their current size.`,
81
+ artifacts: formatArtifactList([{ name: `${resultReference(before)}/snapshot.heapsnapshot`, note: 'before' }, { name: `${resultReference(after)}/snapshot.heapsnapshot`, note: 'after' }]),
82
+ sections: [
83
+ fact`Snapshot totals: before nodes=${totals.before.nodes} serialized-bytes=${totals.before.bytes ?? 'unavailable'}; after nodes=${totals.after.nodes} serialized-bytes=${totals.after.bytes ?? 'unavailable'}.`,
84
+ fact`${comparison.retainedSizeQualification}`,
85
+ lineList(displayed.map((constructor, index) => fact`${index + 1}. ${capped(constructor.constructorName, 120)} · added nodes=${constructor.added.nodeCount} detached-nodes=${constructor.added.detachedNodeCount} retained-bytes=${constructor.added.retainedSize}; removed nodes=${constructor.removed.nodeCount} detached-nodes=${constructor.removed.detachedNodeCount} retained-bytes=${constructor.removed.retainedSize}; grown nodes=${constructor.grown.nodeCount} detached-nodes=${constructor.grown.detachedNodeCount} retained-bytes=${constructor.grown.retainedSize}`)),
86
+ fact`After-snapshot nodes marked detached by Chrome (object-id is the heap retainers --node input):`,
87
+ detachedSection,
88
+ ],
89
+ jsonSections,
90
+ followUp: fact`The detached object ids above are inputs to \`capture heap retainers ${resultReference(after)} --node <object-id>\`.`,
91
+ }, { json: parsed.json });
92
+ }
@@ -0,0 +1,38 @@
1
+ import { invalidInput } from '../../../errors.js';
2
+ import { type ParsedArgs } from '../../types.js';
3
+ import { cmdHeapSnapshot } from './snapshot.js';
4
+ import { cmdHeapCensus } from './census.js';
5
+ import { cmdHeapObjects } from './objects.js';
6
+ import { cmdHeapRetainers } from './retainers.js';
7
+ import { cmdHeapDiff } from './diff.js';
8
+
9
+ export const COMMAND_BLOCK = `<command name="heap">
10
+ the tab's JavaScript heap — \`snapshot\` records the heap as a V8 snapshot, every other leaf is a read-only graph query over it
11
+ use for JavaScript-heap retention: constructor totals, retained sizes, retaining paths, duplicate strings, and before/after comparison
12
+ </command>`;
13
+
14
+ const HELP = `<command name="heap" description="the V8 heap-snapshot substrate">
15
+ <model>\`snapshot\` drives the browser; every other leaf is a read of the finalized snapshot artifact and never touches Chrome. Retained sizes are dominator-subtree sums of Chrome's own self_size. Findings exit 0; input/precondition errors exit 1. No leaf accepts --gate.</model>
16
+ <subcommand name="snapshot" description="take a V8 heap snapshot" whenToUse="Use to write the substrate every other heap leaf reads."/>
17
+ <subcommand name="census" description="what the heap is made of" whenToUse="Use for per-constructor node counts and retained bytes, or for duplicated strings."/>
18
+ <subcommand name="objects" description="the individual objects of one constructor" whenToUse="Use to get the snapshot object ids and sizes you need before asking what retains one."/>
19
+ <subcommand name="retainers" description="what is keeping one object alive" whenToUse="Use with an object id from \`objects\` to get the nearest application-owned retainers."/>
20
+ <subcommand name="diff" description="what changed between two snapshots" whenToUse="Use to see which constructors added, removed, or grew across an interaction."/>
21
+ </command>`;
22
+
23
+ export async function heapMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
24
+ const leaf = parsed.positional[0];
25
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
26
+ switch (leaf) {
27
+ case 'snapshot': return cmdHeapSnapshot(rest);
28
+ case 'census': return cmdHeapCensus(rest);
29
+ case 'objects': return cmdHeapObjects(rest);
30
+ case 'retainers': return cmdHeapRetainers(rest);
31
+ case 'diff': return cmdHeapDiff(rest);
32
+ case undefined:
33
+ console.log(HELP);
34
+ return;
35
+ default:
36
+ throw invalidInput(`Unknown heap leaf: ${leaf}.`, 'unknown_command');
37
+ }
38
+ }