@crouton-kit/capture 1.3.8 → 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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,33 +1,534 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as crypto from 'crypto';
4
+ import { spawnSync } from 'child_process';
1
5
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
6
+ import { CDPClient } from '../../client.js';
7
+ import { findTabById, openTab } from '../../targets.js';
8
+ import { detectCdpPort } from '../../detect.js';
9
+ import { RecorderSession } from '../../recorder-bridge.js';
10
+ import { parseViewport, type Viewport } from '../../viewport.js';
11
+ import {
12
+ emitResult,
13
+ fact,
14
+ formatArtifactList,
15
+ type ArtifactEntry,
16
+ type RenderableResult,
17
+ } from '../../../output/render.js';
18
+ import { getActiveSession } from '../../../session-context.js';
19
+ import { createOneshotSession } from '../../../session/commands.js';
20
+ import { parseDoAction } from '../../leaf-grammar.js';
21
+ import { ensurePrivateDir, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, type RecMeta } from '../../../session/artifacts.js';
22
+ import {
23
+ startComposedRecorder,
24
+ stopComposedRecorder,
25
+ StartRecorderError,
26
+ type StartRecorderResult,
27
+ type FinalizedRecording,
28
+ } from '../../motion/recorder.js';
29
+ import {
30
+ clickResolved,
31
+ resolveLiveTarget,
32
+ scrollResolved,
33
+ type LiveClient,
34
+ type ResolutionFailure,
35
+ } from '../../../interact.js';
36
+ import { CaptureError } from '../../../errors.js';
3
37
 
4
- const USAGE = `Usage: capture motion rec [url] --do <action> [--duration <ms>]
5
- capture motion rec --start
6
- capture motion rec --stop [--rec-id <id>]
38
+ interface RecCommandDeps {
39
+ detectCdpPort: typeof detectCdpPort;
40
+ openTab: typeof openTab;
41
+ findTabById: typeof findTabById;
42
+ createClient: (webSocketDebuggerUrl: string) => CDPClient;
43
+ createRecorderSession: (opts: { client: CDPClient; recDir: string }) => RecorderSession;
44
+ createOneshotSession: typeof createOneshotSession;
45
+ getActiveSession: typeof getActiveSession;
46
+ startComposedRecorder: typeof startComposedRecorder;
47
+ stopComposedRecorder: typeof stopComposedRecorder;
48
+ encodeVideo: typeof encodeVideoIfAvailable;
49
+ }
50
+
51
+ let deps: RecCommandDeps = {
52
+ detectCdpPort,
53
+ openTab,
54
+ findTabById,
55
+ createClient: (webSocketDebuggerUrl) => new CDPClient(webSocketDebuggerUrl),
56
+ createRecorderSession: (opts) => new RecorderSession(opts),
57
+ createOneshotSession,
58
+ getActiveSession,
59
+ startComposedRecorder,
60
+ stopComposedRecorder,
61
+ encodeVideo: encodeVideoIfAvailable,
62
+ };
63
+
64
+ export function __setMotionRecDepsForTest(overrides: Partial<RecCommandDeps>): () => void {
65
+ const previous = deps;
66
+ deps = { ...deps, ...overrides };
67
+ return () => { deps = previous; };
68
+ }
69
+
70
+ const USAGE = `capture motion rec — record the page over time: composed (whatever the active session does between --start and --stop) or one-shot (one scripted action on a URL or the active session tab).
7
71
 
8
- One-shot (\`--do\`): drives one scripted action and records it end to end.
9
- Composed (\`--start\` ... intervening commands ... \`--stop\`): records
10
- whatever the active session does across multiple independent commands.
72
+ input:
73
+ [url] --do <action> one-shot: open <url> (or record the active session tab when <url> is omitted), record one action, finalize
74
+ action click:<target> | scroll:<target>,to=<top|bottom|px>
75
+ target css selector (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
76
+ must resolve to exactly one live element; text: is not accepted by driving actions
77
+ --duration <seconds> keep recording after the action (default: 0)
78
+ --viewport <WxH> emulate a viewport for the recording window (restored after); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
79
+ --start arm the composed recorder on the active session tab (requires \`capture session start\`)
80
+ --viewport <WxH> as above; restored on --stop
81
+ --stop finalize the composed recording
82
+ --rec-id <id> explicit recording id (default: the session's active recording)
11
83
 
12
- Options:
13
- --do <action> One-shot action to drive and record (e.g. click:SEL)
14
- --duration <ms> Recording duration override
15
- --start Arm the composed recorder (requires an active session)
16
- --stop Finalize the composed recorder
17
- --rec-id <id> Explicit recording id (default: the session's active recording)
84
+ output:
85
+ <recording> block — frames, fps, duration, state, event-records, video status, artifact list; --json mirrors.
18
86
 
19
- Not yet implemented.`;
87
+ effects:
88
+ One-shot on a URL opens a new tab and writes a private one-shot artifact dir; with <url> omitted it records the active session tab and writes under the session. Composed writes under the active session. Scripted actions dispatch real input, marked as labeled landmarks in events.jsonl. Video encodes via ffmpeg when available.`;
20
89
 
21
90
  export async function cmdMotionRec(parsed: ParsedArgs, _args: string[]): Promise<void> {
22
91
  if (parsed.help) {
23
92
  console.log(USAGE);
24
- process.exit(0);
93
+ return;
94
+ }
95
+
96
+ const lifecycleError = validateLifecycleInputs(parsed);
97
+ if (lifecycleError) return emitCommandError(parsed, lifecycleError.status, lifecycleError.message);
98
+
99
+ let viewport: Viewport | undefined;
100
+ if (parsed.viewport !== undefined) {
101
+ try {
102
+ viewport = parseViewport(parsed.viewport);
103
+ } catch (error) {
104
+ return emitCommandError(parsed, 'invalid_viewport', error instanceof Error ? error.message : String(error));
105
+ }
106
+ }
107
+
108
+ if (parsed.start) return handleStart(parsed, viewport);
109
+ if (parsed.stop) return handleStop(parsed);
110
+ return handleOneShot(parsed, viewport);
111
+ }
112
+
113
+ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined): Promise<void> {
114
+ const url = parsed.positional[0];
115
+ const active = deps.getActiveSession();
116
+ if (!parsed.do || parsed.positional.length > 1 || (!url && !active)) {
117
+ return emitCommandError(parsed, 'invalid_oneshot', 'One-shot recording requires a URL (or an active session tab) and `--do <action>`.');
118
+ }
119
+ if (!active?.targetId && !url) {
120
+ return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to record.');
121
+ }
122
+ if (!Number.isFinite(parsed.duration ?? 0) || (parsed.duration ?? 0) < 0) {
123
+ return emitCommandError(parsed, 'invalid_duration', '`--duration` must be a non-negative number of seconds.');
124
+ }
125
+
126
+ let parsedUrl: URL | undefined;
127
+ if (url) {
128
+ try {
129
+ parsedUrl = new URL(url);
130
+ } catch {
131
+ return emitCommandError(parsed, 'invalid_url', `Invalid recording URL: ${url}`);
132
+ }
133
+ }
134
+
135
+ const oneshot = active ? undefined : deps.createOneshotSession('motion');
136
+ const destination = active ? path.join(active.dir, 'motion', 'recs') : oneshot!.artifactsDir;
137
+ const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
138
+ const recDir = path.join(destination, recId);
139
+ ensurePrivateDir(recDir);
140
+ ensurePrivateDir(path.join(recDir, 'frames'));
141
+
142
+ let client: CDPClient | undefined;
143
+ let viewportMayHaveApplied = false;
144
+ let failure: string | null = null;
145
+ let failureStatus = 'oneshot_failed';
146
+ try {
147
+ const port = parsed.port ?? await deps.detectCdpPort();
148
+ // The shared parser fills the active session's targetId into parsed.target,
149
+ // but this leaf needs the target's live websocket and URL rather than a
150
+ // URL-pattern lookup. An explicit positional URL retains one-shot's
151
+ // existing new-tab behavior; omission means "the active session tab".
152
+ const tab = parsedUrl
153
+ ? await deps.openTab(port, parsedUrl.toString())
154
+ : await deps.findTabById(port, active!.targetId!);
155
+ if (!tab) throw new Error(`Active session target ${active!.targetId} is no longer available.`);
156
+ if (!tab.webSocketDebuggerUrl) throw new Error('Recording target has no WebSocket debugger URL.');
157
+ client = deps.createClient(tab.webSocketDebuggerUrl);
158
+ await client.waitReady();
159
+ await client.send('Page.enable');
160
+ viewportMayHaveApplied = viewport !== undefined;
161
+ await applyViewportOverride(client, viewport);
162
+ await waitForPageReady(client);
163
+
164
+ const recorder = deps.createRecorderSession({ client, recDir });
165
+ await recorder.start();
166
+ await driveOneShotAction(recorder, parsed.do);
167
+ if (parsed.duration) await sleep(parsed.duration);
168
+ const stopped = await recorder.stop();
169
+ let viewportRestored: boolean | null = null;
170
+ if (viewportMayHaveApplied) {
171
+ viewportRestored = await restoreSessionViewportForClient(client);
172
+ viewportMayHaveApplied = false;
173
+ }
174
+ const finalized = finalizeOneShotRecording(recDir, recId, parsedUrl?.toString() ?? tab.url, parsed.do, stopped, deps.encodeVideo, viewportRestored);
175
+ emitFinalizedResult(parsed, finalized);
176
+ } catch (err) {
177
+ failure = err instanceof Error ? err.message : String(err);
178
+ if (err instanceof DoActionError) failureStatus = err.status;
179
+ } finally {
180
+ let restored: boolean | null = null;
181
+ if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client);
182
+ client?.close();
183
+ if (failure !== null) {
184
+ // A failed action is not a completed measurement. Keep the private partial
185
+ // artifact for inspection, but never invent a finalized meta.json.
186
+ emitCommandError(parsed, failureStatus, failure, restored === null ? undefined : { 'viewport-restored': restored });
187
+ }
188
+ }
189
+ }
190
+
191
+ /** A one-shot `--do` failure carrying a precise status for the `<error>` block. */
192
+ export class DoActionError extends Error {
193
+ constructor(message: string, readonly status: string) {
194
+ super(message);
195
+ }
196
+ }
197
+
198
+ /** Adapts `RecorderSession.handleCdp` onto interact.ts's `LiveClient` so
199
+ * live target resolution and scroll dispatch route through the recorder —
200
+ * `sendMarked` carries the labeled input landmark into `events.jsonl`. */
201
+ function recorderLiveClient(recorder: Pick<RecorderSession, 'handleCdp'>): LiveClient {
202
+ return {
203
+ async send(method, params) {
204
+ return (await recorder.handleCdp({ method, params: params ?? {} })).result;
205
+ },
206
+ async sendMarked(method, params, mark) {
207
+ return (await recorder.handleCdp({ method, params, mark })).result;
208
+ },
209
+ };
210
+ }
211
+
212
+ function resolutionError(failure: ResolutionFailure): DoActionError {
213
+ if (failure.code === 'unsupported-prefix') {
214
+ return new DoActionError(
215
+ `Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector, ax:<name>, axid:<id>, backend:<id>.`,
216
+ 'unsupported_target_prefix',
217
+ );
218
+ }
219
+ if (failure.code === 'no-match') {
220
+ return new DoActionError(`--do target matched no live element: ${failure.input}.`, 'target_resolution_failed');
221
+ }
222
+ const candidates = failure.candidates
223
+ .map((c) => `${c.role ?? 'unknown'} "${c.name ?? ''}" backend:${c.backendNodeId}`)
224
+ .join('; ');
225
+ return new DoActionError(
226
+ `--do target is ambiguous: ${failure.input} matched ${failure.matchCount} live elements. Candidates: ${candidates}. Retry with backend:<id>.`,
227
+ 'target_resolution_failed',
228
+ );
229
+ }
230
+
231
+ /** Drives the one-shot action: resolves the target via the unified live
232
+ * grammar (exactly one match), then dispatches through the shared
233
+ * interact.ts helpers — the adapter's `sendMarked` carries the labeled
234
+ * landmark (the initiating press for click, the one mutating call for
235
+ * scroll) into `events.jsonl`, identical to the `page click`/`page scroll`
236
+ * mechanics. */
237
+ export async function driveOneShotAction(recorder: Pick<RecorderSession, 'handleCdp'>, action: string): Promise<void> {
238
+ const parsedAction = parseDoAction(action);
239
+ const live = recorderLiveClient(recorder);
240
+ const resolved = await resolveLiveTarget(live, parsedAction.target);
241
+ if (!resolved.ok) throw resolutionError(resolved);
242
+ if (parsedAction.verb === 'click') {
243
+ try {
244
+ await clickResolved(live, resolved, { mark: action });
245
+ } catch (err) {
246
+ // interact.ts owns the quad validation; this leaf only classifies its
247
+ // typed outcome into the one-shot's precise `<error>` status.
248
+ if (err instanceof CaptureError && err.descriptor.code === 'target_not_clickable') {
249
+ throw new DoActionError(err.message, 'target_not_clickable');
250
+ }
251
+ throw err;
252
+ }
253
+ return;
254
+ }
255
+ await scrollResolved(live, resolved, parsedAction.to, { mark: action });
256
+ }
257
+
258
+ /** Exported for the focused artifact-layout test. This is the one-shot
259
+ * counterpart of U14's composed finalizer: it writes the same finalized
260
+ * inventory and deliberately never creates recorder.json. */
261
+ export function finalizeOneShotRecording(
262
+ recDir: string,
263
+ recId: string,
264
+ url: string,
265
+ action: string,
266
+ stopped: { frameCount: number; eventCount: number; durationMs: number; markers: unknown },
267
+ encodeVideo = encodeVideoIfAvailable,
268
+ viewportRestored: boolean | null = null,
269
+ ): FinalizedRecording & { action: string; video: VideoEncoding } {
270
+ ensureFinalizedInventory(recDir);
271
+ const video = encodeVideo(recDir, stopped.durationMs);
272
+ const fps = stopped.durationMs > 0 ? Math.round((stopped.frameCount / (stopped.durationMs / 1000)) * 10) / 10 : 0;
273
+ const state = stopped.frameCount > 0 ? 'finalized' : 'partial';
274
+ writeJsonPrivate(path.join(recDir, 'markers.json'), stopped.markers);
275
+ const meta: RecMeta & { url: string; fps: number; eventCount: number; video: VideoEncoding; viewportRestored: boolean | null; reason?: 'no_frames' } = {
276
+ id: recId,
277
+ action,
278
+ frames: stopped.frameCount,
279
+ durationMs: stopped.durationMs,
280
+ state,
281
+ ...(state === 'partial' ? { reason: 'no_frames' as const } : {}),
282
+ url,
283
+ fps,
284
+ eventCount: stopped.eventCount,
285
+ video,
286
+ viewportRestored,
287
+ };
288
+ writeJsonPrivate(path.join(recDir, 'meta.json'), meta);
289
+ return { recId, recDir, frames: stopped.frameCount, durationMs: stopped.durationMs, fps, state, eventCount: stopped.eventCount, viewportRestored, action, video };
290
+ }
291
+
292
+ function ensureFinalizedInventory(recDir: string): void {
293
+ ensurePrivateDir(path.join(recDir, 'frames'));
294
+ for (const filename of ['rects.jsonl', 'events.jsonl']) {
295
+ const artifactPath = path.join(recDir, filename);
296
+ if (!fs.existsSync(artifactPath)) writeNdjsonPrivate(artifactPath, []);
297
+ }
298
+ }
299
+
300
+ function validateLifecycleInputs(parsed: ParsedArgs): { status: string; message: string } | null {
301
+ if (parsed.start && parsed.stop) return { status: 'invalid_lifecycle', message: '`--start` and `--stop` cannot be used together.' };
302
+ if (!parsed.stop && parsed.recId) return { status: parsed.start ? 'invalid_lifecycle' : 'invalid_oneshot', message: '`--rec-id` is valid only with `--stop`.' };
303
+ if (parsed.start) {
304
+ if (parsed.positional.length > 0) return { status: 'invalid_lifecycle', message: '`--start` does not accept a positional URL.' };
305
+ if (parsed.do) return { status: 'invalid_lifecycle', message: '`--start` cannot be combined with `--do`.' };
306
+ if (parsed.duration !== undefined) return { status: 'invalid_lifecycle', message: '`--start` cannot be combined with `--duration`.' };
25
307
  }
308
+ if (parsed.stop) {
309
+ if (parsed.positional.length > 0) return { status: 'invalid_lifecycle', message: '`--stop` does not accept positional arguments.' };
310
+ if (parsed.do) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--do`.' };
311
+ if (parsed.duration !== undefined) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--duration`.' };
312
+ if (parsed.viewport) return { status: 'invalid_lifecycle', message: '`--stop` cannot be combined with `--viewport`; viewport restoration uses the recording started by `--start`.' };
313
+ }
314
+ return null;
315
+ }
316
+
317
+ async function applyViewportOverride(client: Pick<CDPClient, 'send'>, viewport: Viewport | undefined): Promise<boolean> {
318
+ if (!viewport) return false;
319
+ await client.send('Emulation.setDeviceMetricsOverride', {
320
+ width: viewport.width,
321
+ height: viewport.height,
322
+ deviceScaleFactor: 1,
323
+ mobile: false,
324
+ });
325
+ return true;
326
+ }
327
+
328
+ async function restoreSessionViewportForClient(client: Pick<CDPClient, 'send'>): Promise<boolean> {
329
+ try {
330
+ await client.send('Emulation.clearDeviceMetricsOverride');
331
+ return true;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+
338
+ async function waitForPageReady(client: Pick<CDPClient, 'send'>, timeoutMs = 5000): Promise<void> {
339
+ const deadline = Date.now() + timeoutMs;
340
+ let lastState = 'unknown';
341
+ while (Date.now() < deadline) {
342
+ const response = await client.send('Runtime.evaluate', {
343
+ expression: `(() => ({ readyState: document.readyState, href: location.href }))()`,
344
+ returnByValue: true,
345
+ }) as { exceptionDetails?: unknown; result?: { value?: { readyState?: unknown; href?: unknown } } };
346
+ if (!response.exceptionDetails) {
347
+ const value = response.result?.value;
348
+ const readyState = typeof value?.readyState === 'string' ? value.readyState : 'unknown';
349
+ const href = typeof value?.href === 'string' ? value.href : '';
350
+ lastState = `${readyState} ${href}`.trim();
351
+ if ((readyState === 'interactive' || readyState === 'complete') && href !== 'about:blank') return;
352
+ }
353
+ await sleep(50);
354
+ }
355
+ throw new Error(`Timed out waiting for page readiness before recording one-shot action (last state: ${lastState}).`);
356
+ }
357
+
358
+ export type VideoEncoding = { status: 'encoded' | 'unavailable' | 'failed'; reason?: string };
359
+
360
+ /** ffmpeg encode time grows with frame count, so a fixed 30s ceiling spuriously
361
+ * fails long recordings (thousands of frames take minutes to encode). Budget a
362
+ * generous fixed base plus a per-frame allowance, capped by an upper safety bound
363
+ * so a pathological run still cannot hang indefinitely. */
364
+ export function encodeTimeoutMs(frameCount: number): number {
365
+ const BASE_MS = 30_000; // fixed startup + probe + muxing overhead
366
+ const PER_FRAME_MS = 60; // per-frame VP9 encode budget (generous for slow hosts)
367
+ const MAX_MS = 15 * 60_000; // 15-minute upper safety bound
368
+ return Math.min(MAX_MS, BASE_MS + Math.max(0, frameCount) * PER_FRAME_MS);
369
+ }
370
+
371
+ /** Distinguishes a timeout kill from a genuine encode failure for precise
372
+ * provenance. Only spawnSync's own timeout — surfaced as an ETIMEDOUT error —
373
+ * establishes a timeout; the accompanying SIGTERM is how spawnSync kills the
374
+ * child on timeout, but a bare SIGTERM with no ETIMEDOUT is an external/self
375
+ * termination, not proof of a timeout, so it is reported as termination rather
376
+ * than falsely as timed-out. This is provenance, not coaching. */
377
+ export function classifyEncodeFailure(result: { error?: (Error & { code?: string }) | null; signal?: NodeJS.Signals | null }): string {
378
+ if (result.error && (result.error as { code?: string }).code === 'ETIMEDOUT') return 'ffmpeg_encoding_timed_out';
379
+ if (result.error) return 'ffmpeg_execution_failed';
380
+ if (result.signal === 'SIGTERM') return 'ffmpeg_terminated';
381
+ return 'ffmpeg_encoding_failed';
382
+ }
383
+
384
+ /** Encodes inside a new private directory, then installs through the shared
385
+ * atomic no-follow writer. Frame cadence is measured from the recording's
386
+ * duration rather than assumed from a display refresh rate. */
387
+ export function encodeVideoIfAvailable(recDir: string, durationMs: number): VideoEncoding {
388
+ const framesDir = path.join(recDir, 'frames');
389
+ const frames = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter((name) => name.endsWith('.png')).sort() : [];
390
+ if (frames.length === 0) return { status: 'unavailable', reason: 'no_frames' };
391
+ if (durationMs <= 0) return { status: 'unavailable', reason: 'recording_duration_unavailable' };
392
+ const probe = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5_000 });
393
+ if (probe.error || probe.status !== 0 || !String(probe.stdout ?? '').includes('libvpx-vp9')) return { status: 'unavailable', reason: 'ffmpeg_or_vp9_encoder_unavailable' };
394
+ const tempDir = path.join(recDir, `.video-encode-${crypto.randomBytes(8).toString('hex')}`);
395
+ ensurePrivateDir(tempDir);
396
+ const tempOutput = path.join(tempDir, 'video.webm');
397
+ const cadence = String(frames.length / (durationMs / 1000));
398
+ const result = spawnSync('ffmpeg', [
399
+ '-y', '-framerate', cadence, '-pattern_type', 'glob', '-i', path.join(framesDir, '*.png'),
400
+ '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', tempOutput,
401
+ ], { stdio: 'ignore', timeout: encodeTimeoutMs(frames.length) });
402
+ try {
403
+ if (result.error || result.status !== 0 || !fs.existsSync(tempOutput)) {
404
+ return { status: 'failed', reason: classifyEncodeFailure(result) };
405
+ }
406
+ try {
407
+ writeBinaryPrivate(path.join(recDir, 'video.webm'), fs.readFileSync(tempOutput));
408
+ return { status: 'encoded' };
409
+ } catch {
410
+ return { status: 'failed', reason: 'video_install_failed' };
411
+ }
412
+ } finally {
413
+ fs.rmSync(tempDir, { recursive: true, force: true });
414
+ }
415
+ }
416
+
417
+ function sleep(ms: number): Promise<void> {
418
+ return new Promise((resolve) => setTimeout(resolve, ms));
419
+ }
420
+
421
+ async function handleStart(parsed: ParsedArgs, viewport: Viewport | undefined): Promise<void> {
422
+ const session = deps.getActiveSession();
423
+ if (!session) return emitCommandError(parsed, 'no_active_session', 'No active capture session. Start one first: `capture session start --url <url>`.');
424
+ if (!session.targetId) return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to record.');
425
+
426
+ let started: StartRecorderResult;
427
+ try {
428
+ started = await deps.startComposedRecorder({ sessionDir: session.dir, viewport });
429
+ } catch (err) {
430
+ const viewportRestored = err instanceof StartRecorderError ? err.viewportRestored : null;
431
+ return emitCommandError(
432
+ parsed,
433
+ 'start_failed',
434
+ err instanceof Error ? err.message : String(err),
435
+ viewportRestored === null ? undefined : { 'viewport-restored': viewportRestored },
436
+ );
437
+ }
438
+
439
+ const staleViewport = started.reapedStale?.viewportRestored;
26
440
  const result: RenderableResult = {
27
- tag: 'error',
28
- attrs: { command: 'motion rec', status: 'not_implemented' },
29
- summary: fact`\`motion rec\` is not implemented yet.`,
441
+ tag: 'recording',
442
+ attestation: { kind: 'recording', id: started.recId, path: started.recDir },
443
+ attrs: {
444
+ state: 'recording',
445
+ ...(staleViewport !== null && staleViewport !== undefined ? { 'stale-viewport-restored': staleViewport } : {}),
446
+ 'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
447
+ },
448
+ summary: fact`Recorder armed on the session tab (screencast + tracing + observers).`,
449
+ sections: started.reapedStale ? [fact`A stale recording (${started.reapedStale.recId}, dead process) was reaped before this one armed.`] : undefined,
30
450
  };
31
451
  emitResult(result, { json: parsed.json });
32
- process.exit(1);
452
+ }
453
+
454
+ async function handleStop(parsed: ParsedArgs): Promise<void> {
455
+ const session = deps.getActiveSession();
456
+ if (!session) return emitCommandError(parsed, 'no_active_session', 'No active capture session. Start one first: `capture session start --url <url>`.');
457
+ if (!session.targetId) return emitCommandError(parsed, 'no_session_target', 'Active capture session has no target tab to restore.');
458
+
459
+ let stopped: FinalizedRecording;
460
+ try {
461
+ stopped = await deps.stopComposedRecorder({ sessionDir: session.dir, recId: parsed.recId });
462
+ ensureFinalizedInventory(stopped.recDir);
463
+ const video = deps.encodeVideo(stopped.recDir, stopped.durationMs);
464
+ const metaPath = path.join(stopped.recDir, 'meta.json');
465
+ if (fs.existsSync(metaPath)) {
466
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
467
+ writeJsonPrivate(metaPath, { ...meta, video });
468
+ }
469
+ (stopped as FinalizedRecording & { video?: VideoEncoding }).video = video;
470
+ } catch (err) {
471
+ return emitCommandError(parsed, 'stop_failed', err instanceof Error ? err.message : String(err));
472
+ }
473
+ emitFinalizedResult(parsed, stopped);
474
+ }
475
+
476
+ function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & { action?: string }): void {
477
+ const durationS = `${(stopped.durationMs / 1000).toFixed(1)}s`;
478
+ const result: RenderableResult = {
479
+ tag: 'recording',
480
+ attestation: { kind: 'recording', id: stopped.recId, path: stopped.recDir },
481
+ attrs: {
482
+ frames: stopped.frames,
483
+ fps: stopped.fps,
484
+ duration: durationS,
485
+ state: stopped.state,
486
+ 'event-records': stopped.eventCount ?? 'unavailable',
487
+ 'baseline-availability': baselineAvailability(stopped.recDir),
488
+ ...(stopped.action ? { action: stopped.action } : {}),
489
+ ...(stopped.viewportRestored !== null ? { 'viewport-restored': stopped.viewportRestored } : {}),
490
+ ...('video' in stopped ? { video: (stopped as { video?: VideoEncoding }).video?.status ?? 'unavailable' } : {}),
491
+ 'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
492
+ },
493
+ summary: stopped.state === 'partial'
494
+ ? fact`Recording is partial: no screencast frames were captured over ${durationS}; retained event and recorder artifacts are available.`
495
+ : stopped.state === 'orphaned-finalized'
496
+ ? fact`Recorder process was no longer running; finalized best-effort from artifacts already flushed to disk.`
497
+ : stopped.eventCount !== null
498
+ ? fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}, ${stopped.eventCount} event record(s).`
499
+ : fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}.`,
500
+ artifacts: formatArtifactList(listRecordingArtifacts(stopped.recDir)),
501
+ };
502
+ emitResult(result, { json: parsed.json });
503
+ }
504
+
505
+ function baselineAvailability(recDir: string): string {
506
+ try {
507
+ const markers = JSON.parse(fs.readFileSync(path.join(recDir, 'markers.json'), 'utf8')) as { baselinesPending?: unknown; performanceNowMs?: unknown };
508
+ return markers.baselinesPending ? 'pending' : typeof markers.performanceNowMs === 'number' ? 'available' : 'unavailable';
509
+ } catch {
510
+ return 'unavailable';
511
+ }
512
+ }
513
+
514
+ function emitCommandError(parsed: ParsedArgs, status: string, message: string, attrs: Record<string, boolean> = {}): void {
515
+ emitResult({ tag: 'error', attrs: { command: 'motion rec', status, ...attrs }, summary: fact`${message}` }, { json: parsed.json });
516
+ process.exitCode = 1;
517
+ }
518
+
519
+ /** Lists only artifacts actually present — a best-effort orphaned finalize
520
+ * can legitimately lack video encoding or flushed frame data. */
521
+ function listRecordingArtifacts(recDir: string): ArtifactEntry[] {
522
+ let entries: string[];
523
+ try { entries = fs.readdirSync(recDir); } catch { return []; }
524
+ const names: string[] = [];
525
+ for (const entry of entries) {
526
+ const full = path.join(recDir, entry);
527
+ if (fs.statSync(full).isDirectory()) {
528
+ if (fs.readdirSync(full).length > 0) names.push(`${entry}/`);
529
+ } else {
530
+ names.push(entry);
531
+ }
532
+ }
533
+ return names.sort().map((name) => ({ name }));
33
534
  }
@@ -1,26 +1,76 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
2
+ import { emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
3
+ import { ArtifactResolutionError } from '../../../output/artifact.js';
4
+ import { loadResponseTimeline, ResponseActionSelectionError, type ResponsePoint } from '../../motion/response.js';
3
5
 
4
- const USAGE = `Usage: capture motion response <rec> [--action <action>]
6
+ const USAGE = `capture motion response <rec> — input-to-settled response timeline over a finalized recording
5
7
 
6
- Input-to-settled response timeline over a finalized recording:
7
- input -> mutation -> layout -> paint -> network -> settle.
8
+ input:
9
+ <rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
10
+ --action <action> narrow to one recorded action
11
+ --occurrence <n> select the nth (1-based) occurrence of a repeated action label
12
+ output: <response …> — the input → mutation → layout → paint → network → settle timeline for the selected action; --json mirrors
13
+ effects: read-only — reads the finalized recording artifact, never drives the browser`;
8
14
 
9
- Options:
10
- --action <action> Narrow to one recorded action
15
+ function formatPoint(point: ResponsePoint): FactLine {
16
+ const uncertainty = point.precision === 'frame' ? text` (±1 frame)` : text``;
17
+ return line(fact`${point.stage}: t=${point.timestampMs.toFixed(2)}ms, Δ=${point.deltaMs.toFixed(2)}ms — ${point.source}`, uncertainty);
18
+ }
11
19
 
12
- Not yet implemented.`;
20
+ function emitError(parsed: ParsedArgs, status: string, message: string, sections?: readonly FactLine[]): void {
21
+ const result: RenderableResult = {
22
+ tag: 'error',
23
+ attrs: { command: 'motion response', status },
24
+ summary: fact`${message}`,
25
+ sections,
26
+ };
27
+ emitResult(result, { json: parsed.json });
28
+ process.exitCode = 1;
29
+ }
13
30
 
14
31
  export async function cmdMotionResponse(parsed: ParsedArgs, _args: string[]): Promise<void> {
15
32
  if (parsed.help) {
16
33
  console.log(USAGE);
17
- process.exit(0);
34
+ return;
35
+ }
36
+ if (parsed.positional.length !== 1) {
37
+ return emitError(parsed, 'invalid_input', 'motion response requires exactly one recording id or absolute recording path. Create one with `capture motion rec`.');
38
+ }
39
+
40
+ try {
41
+ const loaded = loadResponseTimeline(parsed.positional[0], parsed.action, parsed.occurrence);
42
+ const { ref, timeline } = loaded;
43
+ const result: RenderableResult = {
44
+ tag: 'response',
45
+ attestation: {
46
+ kind: 'recording',
47
+ id: ref.id,
48
+ path: ref.dir,
49
+ note: fact`${timeline.timingNote} Recording state: ${timeline.state}.`,
50
+ },
51
+ attrs: {
52
+ action: timeline.action,
53
+ state: timeline.state,
54
+ 'timing-domain': 'performance.now() relative to recorder arm',
55
+ 'frame-uncertainty': '±1 frame where marked',
56
+ },
57
+ summary: fact`Input dispatch bracket: t=${timeline.inputStartMs.toFixed(2)}ms to ${timeline.inputEndMs.toFixed(2)}ms. Response stages present: ${timeline.points.length - 1}. Unavailable stages: ${timeline.unavailableStages.length ? timeline.unavailableStages.join(', ') : 'none'}.`,
58
+ sections: [
59
+ lineList(timeline.points.map(formatPoint)),
60
+ ...(timeline.caveats.length ? [lineList(timeline.caveats.map((caveat) => fact`caveat: ${caveat}`))] : []),
61
+ ],
62
+ };
63
+ emitResult(result, { json: parsed.json });
64
+ } catch (err) {
65
+ if (err instanceof ResponseActionSelectionError) {
66
+ const actions = err.actions.length
67
+ ? lineList(err.actions.map((action) => fact`available action: ${action}`))
68
+ : text`No input marks were recorded. Record a driven capture action with \`capture motion rec <url> --do <action>\` or drive an action during composed recording.`;
69
+ return emitError(parsed, 'action_required', err.message, [actions]);
70
+ }
71
+ if (err instanceof ArtifactResolutionError) {
72
+ return emitError(parsed, 'artifact_unavailable', err.message);
73
+ }
74
+ return emitError(parsed, 'response_unavailable', err instanceof Error ? err.message : String(err));
18
75
  }
19
- const result: RenderableResult = {
20
- tag: 'error',
21
- attrs: { command: 'motion response', status: 'not_implemented' },
22
- summary: fact`\`motion response\` is not implemented yet.`,
23
- };
24
- emitResult(result, { json: parsed.json });
25
- process.exit(1);
26
76
  }