@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Recorder LIFECYCLE orchestration for the composed `motion rec --start` /
3
+ * `--stop` form — spawns/finalizes the recorder-mode held bridge
4
+ * (`../recorder-bridge.ts` via `../bridge/spawn.ts`), persists the
5
+ * live-recorder handle (`recorder.json`), and writes the finalized
6
+ * `markers.json`/`meta.json`. `../commands/motion/rec.ts` is the only
7
+ * caller for the command path; `../../session/commands.ts` calls
8
+ * `teardownAnyLiveRecorderAtSessionStop` from `session stop`.
9
+ *
10
+ * This module does NOT drive the browser directly and does NOT talk CDP —
11
+ * all of that lives in `../recorder-bridge.ts` (U13, already built/frozen);
12
+ * this module only spawns/addresses that process and shapes its
13
+ * request/response pairs into the artifact files the design calls for.
14
+ *
15
+ * One-shot (`rec --do`/`rec <url>`) recording is a separate, simpler path
16
+ * (an in-process `RecorderSession`, no detached bridge) that a later unit
17
+ * (U24) adds directly to `rec.ts` — out of scope here.
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as crypto from 'crypto';
23
+ import {
24
+ ensurePrivateDir,
25
+ writeJsonPrivate,
26
+ readPrivateFile,
27
+ unlinkPrivateFile,
28
+ removeArtifactTree,
29
+ processPidBirthProvider,
30
+ type PidBirth,
31
+ type PidBirthProvider,
32
+ type RecMeta,
33
+ } from '../../session/artifacts.js';
34
+ import {
35
+ withSessionLifecycle,
36
+ scanRecorderHandles,
37
+ recorderProcessAlive,
38
+ admitSessionOperation,
39
+ RECORDER_NONCE,
40
+ type LifecycleSeams,
41
+ type ScannedRecorderHandle,
42
+ type RecorderHandleRecord,
43
+ } from '../../session/coordinator.js';
44
+ import { captureError } from '../../errors.js';
45
+ import { startRecorderBridge, recorderSocketPath, stopBridge } from '../bridge/spawn.js';
46
+ import { requestRecStart, requestRecStop, RecStopBridgeFailure } from '../recorder-client.js';
47
+ import { RECORDER_NONCE_BOOT_FILE } from '../recorder-bridge.js';
48
+ import { detectCdpPort } from '../detect.js';
49
+ import { findTabByIdAcrossEndpoints } from '../targets.js';
50
+ import { CDPClient } from '../client.js';
51
+ import { getActiveRecId, setActiveRecId, clearActiveRecId, getActiveSession, updateSessionState, type ActiveSessionState } from '../../session-context.js';
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // recorder.json — the live-recorder handle. NOT part of the finalized
55
+ // recording inventory (removed on finalize/reap); this module is the only
56
+ // reader/writer of it.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export type RecorderLiveState = 'recording' | 'finalized' | 'orphaned-finalized' | 'partial';
60
+
61
+ /** Canonical persisted recorder-handle shape — owned by `session/coordinator.ts`
62
+ * (one authority for the shape and its liveness classification); aliased here
63
+ * for the motion callers and tests. `birth` is REQUIRED: recorder liveness is
64
+ * a pid-birth-identity match, never a bare numeric-pid check. */
65
+ export type RecorderJson = RecorderHandleRecord;
66
+
67
+ export function recDirFor(sessionDir: string, recId: string): string {
68
+ return path.join(sessionDir, 'motion', 'recs', recId);
69
+ }
70
+
71
+ function recsRootFor(sessionDir: string): string {
72
+ return path.join(sessionDir, 'motion', 'recs');
73
+ }
74
+
75
+ const SAFE_REC_ID = /^[A-Za-z0-9_-]+$/;
76
+
77
+ /** Validates an explicit (CLI `--rec-id`) recId as a safe path basename and
78
+ * asserts the directory it resolves to stays under this session's
79
+ * `motion/recs` root — defense against a recId containing `..` or an
80
+ * absolute-path escape. Throws a factual error; no lenient sanitizing. Only
81
+ * ever called on the external/explicit `opts.recId` — internally-generated
82
+ * ids (`crypto.randomBytes`, or `readdirSync` entries already confined to
83
+ * `recsRoot`) never need it. */
84
+ function validateRecId(sessionDir: string, recId: string): string {
85
+ if (!SAFE_REC_ID.test(recId)) {
86
+ throw new Error(`Invalid --rec-id "${recId}": must be a plain basename (letters, digits, "-", "_" only).`);
87
+ }
88
+ const recsRoot = path.resolve(recsRootFor(sessionDir));
89
+ const resolved = path.resolve(recDirFor(sessionDir, recId));
90
+ if (resolved !== recsRoot && !resolved.startsWith(recsRoot + path.sep)) {
91
+ throw new Error(`Invalid --rec-id "${recId}": resolves outside the session's recording directory.`);
92
+ }
93
+ return recId;
94
+ }
95
+
96
+ /** The active-session pointer (`session-context.ts`, scoped per-caller by
97
+ * `CRTR_NODE_ID`) is a SINGLE global pointer, not one per session id — so
98
+ * before trusting/clearing its `activeRecId`, every reap/teardown path here
99
+ * must first confirm that pointer actually names THIS session's dir, not
100
+ * some other concurrently-active session. Returns `null` if it doesn't. */
101
+ function activeSessionMatching(sessionDir: string): ActiveSessionState | null {
102
+ const active = getActiveSession();
103
+ return active && active.dir === sessionDir ? active : null;
104
+ }
105
+
106
+ function recorderJsonPath(recDir: string): string {
107
+ return path.join(recDir, 'recorder.json');
108
+ }
109
+
110
+ function markersPath(recDir: string): string {
111
+ return path.join(recDir, 'markers.json');
112
+ }
113
+
114
+ function metaPath(recDir: string): string {
115
+ return path.join(recDir, 'meta.json');
116
+ }
117
+
118
+ /** Reads `recorder.json`, or `null` if absent/unparseable (already reaped, or never existed). */
119
+ export function readRecorderJson(recDir: string): RecorderJson | null {
120
+ try {
121
+ return JSON.parse(fs.readFileSync(recorderJsonPath(recDir), 'utf-8')) as RecorderJson;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /** Best-effort session url, read directly from `.session.json` rather than
128
+ * threading `session/commands.ts`'s internal `Session` type through here —
129
+ * keeps this module decoupled from that file (which itself calls INTO this
130
+ * module at session-stop time; an import back the other way would cycle).
131
+ * This remains verbatim because session URLs are browser evidence. */
132
+ function readSessionUrl(sessionDir: string): string | null {
133
+ try {
134
+ const meta = JSON.parse(fs.readFileSync(path.join(sessionDir, '.session.json'), 'utf-8')) as { url?: string | null };
135
+ const url = meta.url ?? null;
136
+ return url === null ? null : url;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function round1(n: number): number {
143
+ return Math.round(n * 10) / 10;
144
+ }
145
+
146
+ function countFrames(recDir: string): number {
147
+ const framesDir = path.join(recDir, 'frames');
148
+ if (!fs.existsSync(framesDir)) return 0;
149
+ return fs.readdirSync(framesDir).filter((f) => f.endsWith('.png')).length;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Finalize — shared by a graceful `rec-stop`, an orphaned (dead-pid) reap,
154
+ // and session-stop teardown. Writes markers.json + meta.json, removes
155
+ // recorder.json, clears activeRecId. Never re-derives baselines itself.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ export interface FinalizedRecording {
159
+ recId: string;
160
+ recDir: string;
161
+ frames: number;
162
+ durationMs: number;
163
+ fps: number;
164
+ state: RecorderLiveState;
165
+ /** Whether a viewport override owned by this recording was restored. */
166
+ viewportRestored: boolean | null;
167
+ /** Total events.jsonl record count observed at graceful `rec-stop` time;
168
+ * `null` when the recorder was orphaned/best-effort finalized (no live socket to ask). */
169
+ eventCount: number | null;
170
+ }
171
+
172
+ /** Clears the (single, global, per-caller-scoped) `activeRecId` pointer
173
+ * ONLY if it currently names this exact `(sessionDir, recId)` — i.e. only
174
+ * when the pointer is actually OURS to clear. A teardown/reap running
175
+ * against session A must never blow away session B's pointer just because
176
+ * B happens to be the currently-active one for this caller scope. */
177
+ async function clearActiveRecIdIfOwned(sessionDir: string, recId: string): Promise<void> {
178
+ const active = getActiveSession();
179
+ if (active && active.dir === sessionDir && active.activeRecId === recId) {
180
+ await clearActiveRecId();
181
+ }
182
+ }
183
+
184
+ const VIEWPORT_STATE_FILE = 'viewport-override.json';
185
+
186
+ let viewportDeps = {
187
+ findTarget: findTabByIdAcrossEndpoints,
188
+ createClient: (url: string) => new CDPClient(url),
189
+ };
190
+
191
+ /** Focused lifecycle tests inject the target/CDP boundary; production always
192
+ * uses the normal target resolver and client. */
193
+ export function __setViewportLifecycleDepsForTest(overrides: Partial<typeof viewportDeps>): () => void {
194
+ const previous = viewportDeps;
195
+ viewportDeps = { ...viewportDeps, ...overrides };
196
+ return () => { viewportDeps = previous; };
197
+ }
198
+
199
+ /** Persist the lifecycle-owned restoration obligation only after the recorder
200
+ * is live, so every finalization route (stop, reap, session-stop) sees it. */
201
+ export function recordViewportOverride(recDir: string): void {
202
+ writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'applied' });
203
+ }
204
+
205
+ export interface RecordingViewport {
206
+ width: number;
207
+ height: number;
208
+ }
209
+
210
+ async function applyViewportOverride(recDir: string, targetId: string, viewport: RecordingViewport | undefined): Promise<boolean> {
211
+ if (!viewport) return false;
212
+ const tab = await viewportDeps.findTarget(targetId);
213
+ if (!tab?.tab.webSocketDebuggerUrl) throw new Error('recording target is unavailable');
214
+ const client = viewportDeps.createClient(tab.tab.webSocketDebuggerUrl);
215
+ try {
216
+ await client.waitReady();
217
+ // From this write onward the set request may reach Chrome even if its
218
+ // response is lost, so teardown owns a compensating clear.
219
+ writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'attempting', targetId });
220
+ await client.send('Emulation.setDeviceMetricsOverride', {
221
+ width: viewport.width,
222
+ height: viewport.height,
223
+ deviceScaleFactor: 1,
224
+ mobile: false,
225
+ });
226
+ writeJsonPrivate(path.join(recDir, VIEWPORT_STATE_FILE), { phase: 'applied', targetId });
227
+ return true;
228
+ } finally {
229
+ client.close();
230
+ }
231
+ }
232
+
233
+ interface ViewportOverrideState {
234
+ phase?: unknown;
235
+ applied?: unknown;
236
+ targetId?: unknown;
237
+ }
238
+
239
+ function readViewportOverrideState(statePath: string): ViewportOverrideState | null {
240
+ try {
241
+ return JSON.parse(fs.readFileSync(statePath, 'utf8')) as ViewportOverrideState;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ async function restoreViewportOverride(recDir: string, targetId: string): Promise<boolean | null> {
248
+ const statePath = path.join(recDir, VIEWPORT_STATE_FILE);
249
+ if (!fs.existsSync(statePath)) return null;
250
+ const state = readViewportOverrideState(statePath);
251
+ // Only an in-flight or acknowledged set can have mutated Chrome. A
252
+ // prepared obligation is discarded rather than clearing another owner.
253
+ const mayHaveApplied = state?.phase === 'attempting' || state?.phase === 'applied' || state?.applied === true;
254
+ if (!mayHaveApplied) {
255
+ removeArtifactTree(statePath);
256
+ return null;
257
+ }
258
+ try {
259
+ const tab = await viewportDeps.findTarget(targetId);
260
+ if (!tab?.tab.webSocketDebuggerUrl) throw new Error('recording target is unavailable');
261
+ const client = viewportDeps.createClient(tab.tab.webSocketDebuggerUrl);
262
+ try {
263
+ await client.waitReady();
264
+ await client.send('Emulation.clearDeviceMetricsOverride');
265
+ } finally {
266
+ client.close();
267
+ }
268
+ removeArtifactTree(statePath);
269
+ return true;
270
+ } catch {
271
+ // Preserve the state file as the factual failed-restoration evidence.
272
+ return false;
273
+ }
274
+ }
275
+
276
+ export class StartRecorderError extends Error {
277
+ constructor(message: string, readonly viewportRestored: boolean | null) {
278
+ super(message);
279
+ this.name = 'StartRecorderError';
280
+ }
281
+ }
282
+
283
+ async function writeFinalizedArtifacts(
284
+ sessionDir: string,
285
+ recDir: string,
286
+ recId: string,
287
+ url: string | null,
288
+ frames: number,
289
+ durationMs: number,
290
+ state: RecorderLiveState,
291
+ markers: unknown,
292
+ eventCount: number | null,
293
+ targetId: string,
294
+ ): Promise<FinalizedRecording> {
295
+ const fps = durationMs > 0 ? round1(frames / (durationMs / 1000)) : 0;
296
+ const viewportRestored = await restoreViewportOverride(recDir, targetId);
297
+ writeJsonPrivate(markersPath(recDir), markers);
298
+ const meta: RecMeta & { url: string | null; fps: number; eventCount: number | null; viewportRestored: boolean | null } = {
299
+ id: recId,
300
+ action: null,
301
+ frames,
302
+ durationMs,
303
+ state,
304
+ url,
305
+ fps,
306
+ eventCount,
307
+ viewportRestored,
308
+ };
309
+ writeJsonPrivate(metaPath(recDir), meta);
310
+ removeArtifactTree(recorderJsonPath(recDir));
311
+ await clearActiveRecIdIfOwned(sessionDir, recId);
312
+ return { recId, recDir, frames, durationMs, fps, state, eventCount, viewportRestored };
313
+ }
314
+
315
+ /** Every SIGTERM this module ever sends goes through here: re-reads the
316
+ * process's birth identity immediately before signaling and only signals when
317
+ * it still matches the recorded identity. A recycled/foreign pid gets NO
318
+ * signal — the socket file is cleaned up either way. */
319
+ function killIfIdentityMatches(record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>, provider: PidBirthProvider): void {
320
+ if (recorderProcessAlive(record, provider)) stopBridge(record.pid, record.socketPath);
321
+ else stopBridge(null, record.socketPath); // gone/foreign/mismatch: clean socket only, NEVER SIGTERM
322
+ }
323
+
324
+ /** After a successful authenticated `rec-stop`, the bridge process exits
325
+ * itself once its response has flushed. The graceful stop paths wait for that
326
+ * VERIFIED exit (birth-identity check — an absent or recycled/foreign pid
327
+ * counts as gone and is never signaled) instead of racing it with an
328
+ * immediate SIGTERM; only a timeout with a STILL-MATCHING identity escalates
329
+ * to the identity-checked kill. Socket-file hygiene happens either way. */
330
+ async function waitForRecorderExit(
331
+ record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>,
332
+ provider: PidBirthProvider,
333
+ deps: LifecycleSeams,
334
+ ): Promise<void> {
335
+ const timeoutMs = deps.stopExitTimeoutMs ?? 2000;
336
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
337
+ const deadline = Date.now() + timeoutMs;
338
+ for (;;) {
339
+ if (!recorderProcessAlive(record, provider)) {
340
+ stopBridge(null, record.socketPath); // gone or identity mismatch: clean socket only, NEVER signal
341
+ return;
342
+ }
343
+ if (Date.now() >= deadline) {
344
+ killIfIdentityMatches(record, provider); // still the same process after the grace window: escalate
345
+ return;
346
+ }
347
+ await sleep(25);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Shared tail for a TERMINAL `rec-stop` failure — the bridge authenticated
353
+ * the request and explicitly answered `ok:false` (`RecStopBridgeFailure`,
354
+ * e.g. a fatal HAR drain: sink/store rejection or assembly/validation
355
+ * failure), as opposed to a wedged/unreachable bridge. That fatal bridge
356
+ * self-exits non-zero once its response has flushed (see
357
+ * `../recorder-bridge.ts`'s terminal rec-stop latch), so waiting through the
358
+ * SAME identity-verified `waitForRecorderExit` the success path uses
359
+ * resolves rather than timing out — there is no wedged process to escalate
360
+ * against. This path takes NO identity-kill first resort and produces NO
361
+ * orphan-finalize success: it always re-throws the primary bridge failure,
362
+ * never a finalized recording. `waitForRecorderExit` itself does not throw
363
+ * in its ordinary outcomes (gone/mismatched identity resolves normally; a
364
+ * still-matching identity after timeout escalates to a kill and still
365
+ * resolves) — if it somehow does throw, that fact is folded in alongside the
366
+ * primary error via `AggregateError` (same precedent as
367
+ * `../../session/artifacts.ts`'s `combineFailure`) rather than masking the
368
+ * primary with a generic fallback.
369
+ */
370
+ async function propagateTerminalRecStopFailure(
371
+ record: Pick<RecorderHandleRecord, 'pid' | 'socketPath' | 'birth'>,
372
+ provider: PidBirthProvider,
373
+ deps: LifecycleSeams,
374
+ primary: RecStopBridgeFailure,
375
+ ): Promise<never> {
376
+ try {
377
+ await waitForRecorderExit(record, provider, deps);
378
+ } catch (cleanupErr) {
379
+ throw new AggregateError([primary, cleanupErr], primary.message);
380
+ }
381
+ throw primary;
382
+ }
383
+
384
+ /**
385
+ * True for the designated fatal teardown error `../../session/commands.ts`'s
386
+ * `session stop` must let escape its warn-and-continue recorder-teardown
387
+ * catch and route to the outer `stop_failed` lane instead — a terminal
388
+ * `rec-stop` failure thrown by `propagateTerminalRecStopFailure` above,
389
+ * whether that's the bare `RecStopBridgeFailure` or one wrapped alongside a
390
+ * cleanup fact in an `AggregateError`. Any OTHER teardown failure (dead-handle
391
+ * noise, liveness-unknown, a malformed handle) keeps the existing
392
+ * warn-and-continue policy — only an authenticated, explicit bridge refusal
393
+ * is fatal to the whole session stop.
394
+ */
395
+ export function isTerminalRecStopFailure(err: unknown): boolean {
396
+ if (err instanceof RecStopBridgeFailure) return true;
397
+ return err instanceof AggregateError && err.errors.some((e) => e instanceof RecStopBridgeFailure);
398
+ }
399
+
400
+ /** Reads, validates, and CONSUMES the nonce boot file the recorder bridge
401
+ * wrote into `recDir` before binding its socket (see `recorder-bridge.ts`'s
402
+ * `RECORDER_NONCE_BOOT_FILE`). The nonce is persisted into `recorder.json`
403
+ * by the caller — one persistent authority — so the boot file is deleted here
404
+ * as soon as it is read. Throws factually on a missing/malformed file: there
405
+ * is no unauthenticated fallback lane. */
406
+ function consumeRecorderNonceBootFile(recDir: string): string {
407
+ const bootPath = path.join(recDir, RECORDER_NONCE_BOOT_FILE);
408
+ let nonce: unknown;
409
+ try {
410
+ nonce = (JSON.parse(readPrivateFile(bootPath).toString('utf-8')) as { nonce?: unknown }).nonce;
411
+ } catch (err) {
412
+ throw new Error(`Recorder bridge did not hand back a control nonce (${err instanceof Error ? err.message : String(err)}).`);
413
+ }
414
+ if (typeof nonce !== 'string' || !RECORDER_NONCE.test(nonce)) {
415
+ throw new Error('Recorder bridge handed back a malformed control nonce.');
416
+ }
417
+ unlinkPrivateFile(bootPath);
418
+ return nonce;
419
+ }
420
+
421
+ /** Best-effort finalize when the recorder process is dead, or known-alive but
422
+ * unresponsive on its socket: counts whatever `frames/`/lack thereof already
423
+ * made it to disk, computes duration from `recorder.json`'s own `startedAt`,
424
+ * and reuses whichever baseline triple `rec-start` last persisted (it cannot
425
+ * get a fresher one — nothing is listening on the socket anymore).
426
+ *
427
+ * `attemptKill` lets a caller that classified the handle live (but whose
428
+ * socket round trip failed — a wedged bridge) still stop the process, via an
429
+ * identity-verified kill. Callers finalizing an already-dead handle pass
430
+ * `false`: the socket file is cleaned up, nothing is ever signaled. */
431
+ async function finalizeOrphaned(record: RecorderHandleRecord, recDir: string, sessionDir: string, provider: PidBirthProvider, attemptKill: boolean): Promise<FinalizedRecording> {
432
+ const frames = countFrames(recDir);
433
+ const durationMs = Math.max(0, Date.now() - Date.parse(record.startedAt));
434
+ if (attemptKill) killIfIdentityMatches(record, provider);
435
+ else stopBridge(null, record.socketPath); // dead handle: clean socket, never signal
436
+ return writeFinalizedArtifacts(sessionDir, recDir, record.recId, record.url, frames, durationMs, 'orphaned-finalized', record.markers, null, record.targetId);
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Dead-handle reap — a `recorder.json` whose birth-identity no longer matches
441
+ // a live process. Called (under the lifecycle lock) at the top of
442
+ // `startComposedRecorder` — never resume, always reap-then-proceed.
443
+ // ---------------------------------------------------------------------------
444
+
445
+ /** Orphan-finalizes every `dead`-classified handle in `scan` (motion
446
+ * consequence: meta/markers/viewport restore — reset does none of this; it
447
+ * only calls coordinator's neutral `clearDanglingRecorderPointer` and leaves
448
+ * dead handles intact for this finalizer to reap on the next start/stop).
449
+ * Clears a dangling `activeRecId` pointer owned by this session. Returns the
450
+ * handle the pointer named if it was reaped, else the first one reaped, else `null`. */
451
+ async function reapDeadHandles(sessionDir: string, scan: ScannedRecorderHandle[], provider: PidBirthProvider): Promise<FinalizedRecording | null> {
452
+ const active = activeSessionMatching(sessionDir);
453
+ let primary: FinalizedRecording | null = null;
454
+ for (const h of scan) {
455
+ if (h.classification !== 'dead') continue;
456
+ const finalized = await finalizeOrphaned(h.record!, h.recDir, sessionDir, provider, false);
457
+ if (h.recId === active?.activeRecId || primary === null) primary = finalized;
458
+ }
459
+ if (active?.activeRecId && !scan.some(h => h.recId === active.activeRecId)) await clearActiveRecId();
460
+ return primary;
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Start
465
+ // ---------------------------------------------------------------------------
466
+
467
+ export interface StartRecorderResult {
468
+ recId: string;
469
+ recDir: string;
470
+ state: 'recording';
471
+ /** Set when a stale prior recording was reaped before this one armed. */
472
+ reapedStale: FinalizedRecording | null;
473
+ }
474
+
475
+ /**
476
+ * Injectable seams for `startComposedRecorder`'s two external effects (CDP
477
+ * port detection and spawning the detached recorder-bridge process) —
478
+ * lets tests exercise the full start path (recorder.json shape, stale-reap,
479
+ * activeRecId bookkeeping) against a fake NDJSON socket server instead of a
480
+ * real spawned `__bridge-serve` child (which only works against the built
481
+ * bin, not a `tsx`-run test — see `../bridge/spawn.ts`'s own doc comment).
482
+ * Omit both in production; only tests pass `deps`.
483
+ */
484
+ export interface StartRecorderDeps extends LifecycleSeams {
485
+ detectPort?: () => Promise<number>;
486
+ spawnRecorderBridge?: typeof startRecorderBridge;
487
+ }
488
+
489
+ export async function startComposedRecorder(
490
+ opts: {
491
+ sessionDir: string;
492
+ viewport?: RecordingViewport;
493
+ },
494
+ deps: StartRecorderDeps = {},
495
+ ): Promise<StartRecorderResult> {
496
+ // Admission into the session's operation coordinator comes FIRST — before
497
+ // the lifecycle lock and before any session-bound effect. A `session stop`
498
+ // that has already marked `.operations.json` stopping rejects this start
499
+ // with zero side effects; a start admitted first holds its token until the
500
+ // recorder handle is atomically published (or the start fully rolled back),
501
+ // so a concurrent stop's drain barrier cannot finalize the bundle around a
502
+ // half-armed recorder. Deadlock-free ordering: stop drains tokens BEFORE
503
+ // taking `.lifecycle.lock`, while this start holds its token WHILE waiting
504
+ // on that lock — the two never wait on each other in a cycle.
505
+ const operation = await admitSessionOperation(opts.sessionDir);
506
+ try {
507
+ return await startComposedRecorderAdmitted(opts, deps);
508
+ } finally {
509
+ await operation.release();
510
+ }
511
+ }
512
+
513
+ async function startComposedRecorderAdmitted(
514
+ opts: {
515
+ sessionDir: string;
516
+ viewport?: RecordingViewport;
517
+ },
518
+ deps: StartRecorderDeps = {},
519
+ ): Promise<StartRecorderResult> {
520
+ // The lifecycle lock covers the scan, dead-handle reap, endpoint
521
+ // resolution, viewport mutation, and bridge startup. A rejected start
522
+ // therefore cannot touch a live recording's viewport, a stale reap
523
+ // completes before a new override, and the recording target is the one
524
+ // the session names UNDER the lock — never a stale pre-lock snapshot.
525
+ return withSessionLifecycle(opts.sessionDir, async () => {
526
+ const provider = deps.pidBirthProvider ?? processPidBirthProvider;
527
+ const scan = scanRecorderHandles(opts.sessionDir, deps);
528
+ if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to start.');
529
+ if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before starting.');
530
+ if (scan.some(h => h.classification === 'live')) throw new Error('A recording is already active on this session. Stop it first: `capture motion rec --stop`.');
531
+ const reapedStale = await reapDeadHandles(opts.sessionDir, scan, provider);
532
+ const session = getActiveSession();
533
+ if (!session || session.dir !== opts.sessionDir) throw new Error('The active capture session is no longer available.');
534
+ if (!session.targetId) throw new Error('The active session has no attached tab to record. Start it with a URL: `capture session start --url <url>`.');
535
+ const targetId = session.targetId;
536
+ // The owning session's live HAR store — REQUIRED: a session-backed
537
+ // recorder always has one (session start creates it), and the recorder
538
+ // bridge streams its network evidence there. No fallback lane.
539
+ const harId = session.harId;
540
+ if (typeof harId !== 'string' || harId.length === 0) {
541
+ throw new Error('The active session has no live HAR recording id (harId); cannot arm a recorder without its HAR evidence store.');
542
+ }
543
+ const port = session.port ?? await (deps.detectPort ?? detectCdpPort)();
544
+ await updateSessionState(opts.sessionDir, { targetId, port });
545
+
546
+ const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
547
+ const recDir = recDirFor(opts.sessionDir, recId);
548
+ ensurePrivateDir(recDir);
549
+ ensurePrivateDir(path.join(recDir, 'frames'));
550
+ let viewportAttempted = false;
551
+ let socketPath: string | null = null;
552
+ let established: { pid: number; socketPath: string; birth: PidBirth } | null = null;
553
+ try {
554
+ viewportAttempted = await applyViewportOverride(recDir, targetId, opts.viewport);
555
+ socketPath = recorderSocketPath(recDir);
556
+ const spawnRecorderBridge = deps.spawnRecorderBridge ?? startRecorderBridge;
557
+ const { pid } = await spawnRecorderBridge(socketPath, port, targetId, recDir, harId);
558
+ const born = provider.read(pid);
559
+ if (born.status !== 'found') {
560
+ // Identity unestablished — never signal a pid we cannot prove is ours.
561
+ stopBridge(null, socketPath);
562
+ throw new Error(`Could not establish recorder process identity (${born.status === 'unknown' ? born.reason : 'process absent'}).`);
563
+ }
564
+ established = { pid, socketPath, birth: born.identity };
565
+ // The bridge wrote its server-generated nonce into recDir before binding
566
+ // the socket; spawn's readiness poll guarantees it exists here. Read,
567
+ // validate, and consume it — recorder.json below becomes the one
568
+ // persistent authority for it.
569
+ const nonce = consumeRecorderNonceBootFile(recDir);
570
+ const startResp = await requestRecStart(socketPath, nonce);
571
+
572
+ const recorderJson: RecorderJson = {
573
+ recId,
574
+ pid,
575
+ socketPath,
576
+ targetId,
577
+ url: readSessionUrl(opts.sessionDir),
578
+ startedAt: new Date().toISOString(),
579
+ state: 'recording',
580
+ birth: born.identity,
581
+ markers: startResp.markers,
582
+ nonce,
583
+ };
584
+ writeJsonPrivate(recorderJsonPath(recDir), recorderJson);
585
+ await setActiveRecId(recId);
586
+ return { recId, recDir, state: 'recording' as const, reapedStale };
587
+ } catch (err) {
588
+ if (established) killIfIdentityMatches(established, provider);
589
+ else if (socketPath) stopBridge(null, socketPath);
590
+ const restored = viewportAttempted || fs.existsSync(path.join(recDir, VIEWPORT_STATE_FILE))
591
+ ? await restoreViewportOverride(recDir, targetId)
592
+ : null;
593
+ // A failed restoration is a live lifecycle obligation, not disposable
594
+ // partial output. Leave it for the next session teardown to retry.
595
+ if (restored !== false) removeArtifactTree(recDir);
596
+ throw err instanceof StartRecorderError ? err : new StartRecorderError(err instanceof Error ? err.message : String(err), restored);
597
+ }
598
+ }, deps);
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Stop
603
+ // ---------------------------------------------------------------------------
604
+
605
+ /** Finalizes the session's active composed recording (or an explicit `recId`).
606
+ * Gracefully stops a live recorder over its socket; best-effort finalizes an
607
+ * already-dead one instead of failing. Throws if there is nothing to stop. */
608
+ export async function stopComposedRecorder(opts: {
609
+ sessionDir: string;
610
+ recId?: string;
611
+ }, deps: LifecycleSeams = {}): Promise<FinalizedRecording> {
612
+ // validateRecId throws BEFORE the lock — a traversal-shaped --rec-id is an
613
+ // invocation defect, not a lifecycle transition; it never touches the lock.
614
+ const recId = opts.recId ? validateRecId(opts.sessionDir, opts.recId) : getActiveRecId();
615
+ if (!recId) {
616
+ throw new Error('No active recording on this session. Start one first: `capture motion rec --start`.');
617
+ }
618
+ return withSessionLifecycle(opts.sessionDir, async () => {
619
+ const provider = deps.pidBirthProvider ?? processPidBirthProvider;
620
+ const handle = scanRecorderHandles(opts.sessionDir, deps).find(h => h.recId === recId);
621
+ if (!handle) {
622
+ throw new Error(`No live-recorder state found for "${recId}" (already finalized, or never started).`);
623
+ }
624
+ if (handle.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to stop.');
625
+ if (handle.classification === 'malformed') throw captureError('precondition', 'recorder_unavailable', `The recorder handle for "${recId}" is malformed.`);
626
+ const record = handle.record!;
627
+ if (handle.classification === 'dead') {
628
+ return await finalizeOrphaned(record, handle.recDir, opts.sessionDir, provider, false);
629
+ }
630
+ try {
631
+ const stopResp = await requestRecStop(record.socketPath, record.nonce);
632
+ // The bridge self-exits once its authenticated rec-stop response has
633
+ // flushed — wait for that verified exit; escalate to an identity-checked
634
+ // SIGTERM only if the same process is still alive after the grace window.
635
+ await waitForRecorderExit(record, provider, deps);
636
+ return await writeFinalizedArtifacts(opts.sessionDir, handle.recDir, recId, record.url, stopResp.frameCount, stopResp.durationMs, 'finalized', stopResp.markers, stopResp.eventCount, record.targetId);
637
+ } catch (err) {
638
+ if (err instanceof RecStopBridgeFailure) {
639
+ // The bridge authenticated the request and explicitly refused — a
640
+ // terminal recorder failure, NOT a wedged bridge. No identity-kill,
641
+ // no orphan-finalize success: propagate the primary error.
642
+ await propagateTerminalRecStopFailure(record, provider, deps, err);
643
+ }
644
+ // Known-live handle, but the socket round trip failed (wedged/missing
645
+ // bridge) — identity-verified kill and best-effort finalize from
646
+ // whatever made it to disk, same fallback teardown uses.
647
+ return await finalizeOrphaned(record, handle.recDir, opts.sessionDir, provider, true);
648
+ }
649
+ }, deps);
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Session-stop teardown
654
+ // ---------------------------------------------------------------------------
655
+
656
+ /**
657
+ * Called by `session stop` before bundle collection: finalizes/tears down
658
+ * EVERY live recorder found on this session, directory-authoritatively —
659
+ * it scans `motion/recs/{recId}/recorder.json` rather than trusting the single
660
+ * global `activeRecId` pointer, because that pointer can be absent (a
661
+ * `recorder.json` left behind with no pointer naming it) or can currently
662
+ * name a *different* session entirely (this caller scope's one active
663
+ * pointer, pointing elsewhere while THIS session still has its own live
664
+ * recorder on disk). Each handle found gets a graceful `rec-stop` if its
665
+ * pid answers, otherwise a best-effort orphaned finalize; reap never
666
+ * resumes a stale one. The pointer is cleared only for handles this
667
+ * session actually owns (`clearActiveRecIdIfOwned`) — a pointer naming
668
+ * some other session is left completely untouched. Returns the finalized
669
+ * recording, if any, plus every pending failed-start viewport retry outcome. */
670
+ export interface PendingViewportRestoration {
671
+ recId: string;
672
+ viewportRestored: boolean | null;
673
+ }
674
+
675
+ async function restorePendingStartViewportOverrides(sessionDir: string): Promise<PendingViewportRestoration[]> {
676
+ const recsRoot = recsRootFor(sessionDir);
677
+ if (!fs.existsSync(recsRoot)) return [];
678
+ const outcomes: PendingViewportRestoration[] = [];
679
+ for (const entry of fs.readdirSync(recsRoot, { withFileTypes: true })) {
680
+ if (!entry.isDirectory()) continue;
681
+ const recDir = path.join(recsRoot, entry.name);
682
+ if (readRecorderJson(recDir)) continue;
683
+ const statePath = path.join(recDir, VIEWPORT_STATE_FILE);
684
+ if (!fs.existsSync(statePath)) continue;
685
+ const state = readViewportOverrideState(statePath);
686
+ const targetId = typeof state?.targetId === 'string' ? state.targetId : null;
687
+ const viewportRestored = targetId ? await restoreViewportOverride(recDir, targetId) : null;
688
+ outcomes.push({ recId: entry.name, viewportRestored });
689
+ }
690
+ return outcomes;
691
+ }
692
+
693
+ export type SessionRecorderTeardown = (FinalizedRecording & {
694
+ pendingViewportRestorations: PendingViewportRestoration[];
695
+ }) | {
696
+ recording: null;
697
+ pendingViewportRestorations: PendingViewportRestoration[];
698
+ } | null;
699
+
700
+ export async function teardownAnyLiveRecorderAtSessionStop(sessionDir: string, deps: LifecycleSeams = {}): Promise<SessionRecorderTeardown> {
701
+ return withSessionLifecycle(sessionDir, async () => {
702
+ const provider = deps.pidBirthProvider ?? processPidBirthProvider;
703
+ const scan = scanRecorderHandles(sessionDir, deps);
704
+ const activeHere = activeSessionMatching(sessionDir);
705
+
706
+ let primary: FinalizedRecording | null = null;
707
+ for (const h of scan) {
708
+ if (h.classification === 'unknown') throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness during session stop.');
709
+ if (h.classification === 'malformed') continue; // no readable handle to finalize; leave the dir as-is
710
+ const record = h.record!;
711
+ let finalized: FinalizedRecording;
712
+ if (h.classification === 'dead') {
713
+ finalized = await finalizeOrphaned(record, h.recDir, sessionDir, provider, false);
714
+ } else {
715
+ try {
716
+ const stopResp = await requestRecStop(record.socketPath, record.nonce);
717
+ // Same verified-exit wait as stopComposedRecorder's graceful path —
718
+ // never race the bridge's own post-response self-exit with a signal.
719
+ await waitForRecorderExit(record, provider, deps);
720
+ finalized = await writeFinalizedArtifacts(
721
+ sessionDir,
722
+ h.recDir,
723
+ record.recId,
724
+ record.url,
725
+ stopResp.frameCount,
726
+ stopResp.durationMs,
727
+ 'finalized',
728
+ stopResp.markers,
729
+ stopResp.eventCount,
730
+ record.targetId,
731
+ );
732
+ } catch (err) {
733
+ if (err instanceof RecStopBridgeFailure) {
734
+ // Terminal recorder-stop failure during whole-session teardown —
735
+ // the bridge authenticated the request and explicitly refused
736
+ // (e.g. fatal HAR drain). This must escape the caller's
737
+ // warn-and-continue teardown catch (`../../session/commands.ts`)
738
+ // and reach the outer `stop_failed` lane: no bundle commit, no
739
+ // live-HAR deletion, no handle deletion-as-finalized. Propagate
740
+ // rather than falling into the dead-handle fallback below.
741
+ await propagateTerminalRecStopFailure(record, provider, deps, err);
742
+ }
743
+ // The handle classified live but the socket round trip itself
744
+ // failed (e.g. mid-teardown race) — identity-verified kill plus the
745
+ // same best-effort path a dead handle takes, rather than leaving
746
+ // the session stuck with a live-looking recorder it can never stop.
747
+ finalized = await finalizeOrphaned(record, h.recDir, sessionDir, provider, true);
748
+ }
749
+ }
750
+ if (record.recId === activeHere?.activeRecId || primary === null) primary = finalized;
751
+ }
752
+
753
+ // A dangling pointer for THIS session with no matching on-disk handle left
754
+ // to tear down (already reaped, or the pointer never named a real one).
755
+ if (activeHere?.activeRecId && !scan.some((h) => h.recId === activeHere.activeRecId)) {
756
+ await clearActiveRecId();
757
+ }
758
+
759
+ const pendingViewportRestorations = await restorePendingStartViewportOverrides(sessionDir);
760
+ if (primary) return { ...primary, pendingViewportRestorations };
761
+ if (pendingViewportRestorations.length) return { recording: null, pendingViewportRestorations };
762
+ return null;
763
+ }, deps);
764
+ }