@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
@@ -6,7 +6,7 @@ import { type ParsedArgs } from '../../types.js';
6
6
  import { CDPClient } from '../../client.js';
7
7
  import { findTabById, openTab } from '../../targets.js';
8
8
  import { detectCdpPort } from '../../detect.js';
9
- import { RecorderSession } from '../../recorder-bridge.js';
9
+ import { RecorderSession } from '../../host/collectors/motion.js';
10
10
  import { parseViewport, type Viewport } from '../../viewport.js';
11
11
  import {
12
12
  emitResult,
@@ -18,7 +18,7 @@ import {
18
18
  import { getActiveSession } from '../../../session-context.js';
19
19
  import { createOneshotSession } from '../../../session/commands.js';
20
20
  import { parseDoAction } from '../../leaf-grammar.js';
21
- import { ensurePrivateDir, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, type RecMeta } from '../../../session/artifacts.js';
21
+ import { ensurePrivateDir, readPrivateFile, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, writePrivateFile, type RecMeta } from '../../../session/artifacts.js';
22
22
  import {
23
23
  startComposedRecorder,
24
24
  stopComposedRecorder,
@@ -34,6 +34,8 @@ import {
34
34
  type ResolutionFailure,
35
35
  } from '../../../interact.js';
36
36
  import { CaptureError } from '../../../errors.js';
37
+ import { sendHostRequest } from '../../host/client.js';
38
+ import { scanCollectorHost } from '../../host/handle.js';
37
39
 
38
40
  interface RecCommandDeps {
39
41
  detectCdpPort: typeof detectCdpPort;
@@ -76,6 +78,7 @@ input:
76
78
  must resolve to exactly one live element; text: is not accepted by driving actions
77
79
  --duration <seconds> keep recording after the action (default: 0)
78
80
  --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
81
+ --artifact-dir <path> root for a sessionless one-shot recording bundle
79
82
  --start arm the composed recorder on the active session tab (requires \`capture session start\`)
80
83
  --viewport <WxH> as above; restored on --stop
81
84
  --stop finalize the composed recording
@@ -83,9 +86,10 @@ input:
83
86
 
84
87
  output:
85
88
  <recording> block — frames, fps, duration, state, event-records, video status, artifact list; --json mirrors.
89
+ Caveat: screencast frames are change-driven, not a dropped-frame measurement; use \`capture perf trace\` for frame-timing questions.
86
90
 
87
91
  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.`;
92
+ One-shot on a URL opens a new tab and writes a private one-shot artifact dir under --artifact-dir when supplied; with <url> omitted it records the active session tab through the session collector host and writes under the session. If that active-session one-shot process is interrupted after recording starts, the collector remains live and \`capture motion rec --stop\` finalizes it. Composed writes under the active session. Scripted actions dispatch real input, marked as labeled landmarks in events.jsonl. Video encodes via ffmpeg when available.`;
89
93
 
90
94
  export async function cmdMotionRec(parsed: ParsedArgs, _args: string[]): Promise<void> {
91
95
  if (parsed.help) {
@@ -132,7 +136,12 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
132
136
  }
133
137
  }
134
138
 
135
- const oneshot = active ? undefined : deps.createOneshotSession('motion');
139
+ // The active-session form uses the detached collector host so an external
140
+ // timeout cannot take its only recorder down with the CLI process. The
141
+ // host row is then discoverable through session collectors and --stop.
142
+ if (active && !parsedUrl && active.harId) return handleHostedSessionOneShot(parsed, active, viewport);
143
+
144
+ const oneshot = active ? undefined : deps.createOneshotSession('motion', parsed.artifactDir);
136
145
  const destination = active ? path.join(active.dir, 'motion', 'recs') : oneshot!.artifactsDir;
137
146
  const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
138
147
  const recDir = path.join(destination, recId);
@@ -140,6 +149,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
140
149
  ensurePrivateDir(path.join(recDir, 'frames'));
141
150
 
142
151
  let client: CDPClient | undefined;
152
+ let originalViewport: ActualViewport | undefined;
143
153
  let viewportMayHaveApplied = false;
144
154
  let failure: string | null = null;
145
155
  let failureStatus = 'oneshot_failed';
@@ -157,8 +167,12 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
157
167
  client = deps.createClient(tab.webSocketDebuggerUrl);
158
168
  await client.waitReady();
159
169
  await client.send('Page.enable');
160
- viewportMayHaveApplied = viewport !== undefined;
161
- await applyViewportOverride(client, viewport);
170
+ if (viewport) {
171
+ // Clear is unreliable on adopted headed tabs; restoration must set and verify this measured state.
172
+ originalViewport = await readActualViewport(client);
173
+ viewportMayHaveApplied = true;
174
+ await applyViewportOverride(client, viewport);
175
+ }
162
176
  await waitForPageReady(client);
163
177
 
164
178
  const recorder = deps.createRecorderSession({ client, recDir });
@@ -168,7 +182,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
168
182
  const stopped = await recorder.stop();
169
183
  let viewportRestored: boolean | null = null;
170
184
  if (viewportMayHaveApplied) {
171
- viewportRestored = await restoreSessionViewportForClient(client);
185
+ viewportRestored = await restoreSessionViewportForClient(client, originalViewport!);
172
186
  viewportMayHaveApplied = false;
173
187
  }
174
188
  const finalized = finalizeOneShotRecording(recDir, recId, parsedUrl?.toString() ?? tab.url, parsed.do, stopped, deps.encodeVideo, viewportRestored);
@@ -178,7 +192,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
178
192
  if (err instanceof DoActionError) failureStatus = err.status;
179
193
  } finally {
180
194
  let restored: boolean | null = null;
181
- if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client);
195
+ if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client, originalViewport!);
182
196
  client?.close();
183
197
  if (failure !== null) {
184
198
  // A failed action is not a completed measurement. Keep the private partial
@@ -188,6 +202,43 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
188
202
  }
189
203
  }
190
204
 
205
+ async function handleHostedSessionOneShot(parsed: ParsedArgs, session: NonNullable<ReturnType<typeof getActiveSession>>, viewport: Viewport | undefined): Promise<void> {
206
+ let started: StartRecorderResult | undefined;
207
+ try {
208
+ started = await deps.startComposedRecorder({ sessionDir: session.dir, viewport });
209
+ const scanned = scanCollectorHost(session.dir);
210
+ if (scanned.classification !== 'live' || !scanned.handle) throw new Error(`collector host is ${scanned.classification} after recording start`);
211
+ const host = scanned.handle;
212
+ await driveOneShotAction({
213
+ async handleCdp(request) {
214
+ const response = await sendHostRequest(host.socketPath, {
215
+ type: 'cdp', nonce: host.nonce, method: request.method, params: request.params ?? {}, ...(request.mark ? { annotation: request.mark } : {}),
216
+ }, 30_000);
217
+ if (!response.ok) throw new Error(response.error ?? `collector host rejected ${request.method}`);
218
+ return response as { result: unknown };
219
+ },
220
+ }, parsed.do!);
221
+ if (parsed.duration) await sleep(parsed.duration);
222
+ const stopped = await deps.stopComposedRecorder({ sessionDir: session.dir, recId: started.recId });
223
+ ensureFinalizedInventory(stopped.recDir);
224
+ const video = deps.encodeVideo(stopped.recDir, stopped.durationMs);
225
+ const metaPath = path.join(stopped.recDir, 'meta.json');
226
+ if (fs.existsSync(metaPath)) {
227
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
228
+ writeJsonPrivate(metaPath, { ...meta, video });
229
+ }
230
+ (stopped as FinalizedRecording & { video?: VideoEncoding }).video = video;
231
+ emitFinalizedResult(parsed, stopped);
232
+ } catch (err) {
233
+ const status = err instanceof DoActionError ? err.status : 'oneshot_failed';
234
+ const message = err instanceof Error ? err.message : String(err);
235
+ const recovery = started
236
+ ? `${message}. Recording ${started.recId} remains live at ${started.recDir}; finalize it with \`capture motion rec --stop\` (or inspect \`capture session collectors\`).`
237
+ : message;
238
+ emitCommandError(parsed, status, recovery);
239
+ }
240
+ }
241
+
191
242
  /** A one-shot `--do` failure carrying a precise status for the `<error>` block. */
192
243
  export class DoActionError extends Error {
193
244
  constructor(message: string, readonly status: string) {
@@ -255,9 +306,9 @@ export async function driveOneShotAction(recorder: Pick<RecorderSession, 'handle
255
306
  await scrollResolved(live, resolved, parsedAction.to, { mark: action });
256
307
  }
257
308
 
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. */
309
+ /** Exported for the focused artifact-layout test. This one-shot counterpart
310
+ * of the composed host finalizer writes the same finalized inventory without
311
+ * creating a live collector-host handle. */
261
312
  export function finalizeOneShotRecording(
262
313
  recDir: string,
263
314
  recId: string,
@@ -268,11 +319,11 @@ export function finalizeOneShotRecording(
268
319
  viewportRestored: boolean | null = null,
269
320
  ): FinalizedRecording & { action: string; video: VideoEncoding } {
270
321
  ensureFinalizedInventory(recDir);
322
+ writeJsonPrivate(path.join(recDir, 'markers.json'), stopped.markers);
271
323
  const video = encodeVideo(recDir, stopped.durationMs);
272
324
  const fps = stopped.durationMs > 0 ? Math.round((stopped.frameCount / (stopped.durationMs / 1000)) * 10) / 10 : 0;
273
325
  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' } = {
326
+ const meta: RecMeta & { url: string; fps: number; eventCount: number; video: VideoEncoding; viewportRestored: boolean | null; viewportRetained: boolean; reason?: 'no_frames' } = {
276
327
  id: recId,
277
328
  action,
278
329
  frames: stopped.frameCount,
@@ -284,9 +335,10 @@ export function finalizeOneShotRecording(
284
335
  eventCount: stopped.eventCount,
285
336
  video,
286
337
  viewportRestored,
338
+ viewportRetained: false,
287
339
  };
288
340
  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 };
341
+ return { recId, recDir, frames: stopped.frameCount, durationMs: stopped.durationMs, fps, state, eventCount: stopped.eventCount, viewportRestored, viewportRetained: false, action, video };
290
342
  }
291
343
 
292
344
  function ensureFinalizedInventory(recDir: string): void {
@@ -325,10 +377,36 @@ async function applyViewportOverride(client: Pick<CDPClient, 'send'>, viewport:
325
377
  return true;
326
378
  }
327
379
 
328
- async function restoreSessionViewportForClient(client: Pick<CDPClient, 'send'>): Promise<boolean> {
380
+ interface ActualViewport {
381
+ width: number;
382
+ height: number;
383
+ deviceScaleFactor: number;
384
+ }
385
+
386
+ async function readActualViewport(client: Pick<CDPClient, 'send'>): Promise<ActualViewport> {
387
+ const response = await client.send('Runtime.evaluate', {
388
+ expression: '(() => ({ width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio }))()',
389
+ returnByValue: true,
390
+ }) as { result?: { value?: Partial<ActualViewport> } };
391
+ const viewport = response.result?.value;
392
+ if (!viewport || ![viewport.width, viewport.height, viewport.deviceScaleFactor].every((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)) {
393
+ throw new Error('Could not read the target’s actual viewport before recording; refusing to apply an override that cannot be verified on restore.');
394
+ }
395
+ return viewport as ActualViewport;
396
+ }
397
+
398
+ async function restoreSessionViewportForClient(client: Pick<CDPClient, 'send'>, original: ActualViewport): Promise<boolean> {
329
399
  try {
330
- await client.send('Emulation.clearDeviceMetricsOverride');
331
- return true;
400
+ await client.send('Emulation.setDeviceMetricsOverride', {
401
+ width: original.width,
402
+ height: original.height,
403
+ deviceScaleFactor: original.deviceScaleFactor,
404
+ mobile: false,
405
+ });
406
+ const restored = await readActualViewport(client);
407
+ return restored.width === original.width
408
+ && restored.height === original.height
409
+ && restored.deviceScaleFactor === original.deviceScaleFactor;
332
410
  } catch {
333
411
  return false;
334
412
  }
@@ -381,25 +459,66 @@ export function classifyEncodeFailure(result: { error?: (Error & { code?: string
381
459
  return 'ffmpeg_encoding_failed';
382
460
  }
383
461
 
462
+ /** Builds the concat manifest from the recorder's frame timestamps. A screencast is
463
+ * change-driven, but each retained frame's presentation timestamp is factual; a
464
+ * recording without a complete, ordered timestamp sequence cannot produce a
465
+ * timestamp-faithful video. */
466
+ function timestampedFrameManifest(recDir: string, frames: readonly string[], manifestPath: string): VideoEncoding | undefined {
467
+ let rects: unknown[];
468
+ let markers: Record<string, unknown>;
469
+ try {
470
+ rects = readPrivateFile(path.join(recDir, 'rects.jsonl')).toString('utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
471
+ markers = JSON.parse(readPrivateFile(path.join(recDir, 'markers.json')).toString('utf8')) as Record<string, unknown>;
472
+ } catch {
473
+ return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
474
+ }
475
+ const timestamps = new Map<string, number>();
476
+ for (const rect of rects) {
477
+ if (!rect || typeof rect !== 'object') continue;
478
+ const { file, screencastTimestamp } = rect as { file?: unknown; screencastTimestamp?: unknown };
479
+ if (typeof file !== 'string' || typeof screencastTimestamp !== 'number' || !Number.isFinite(screencastTimestamp) || timestamps.has(file)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
480
+ timestamps.set(file, screencastTimestamp * 1000);
481
+ }
482
+ const frameTimes = frames.map(frame => timestamps.get(frame));
483
+ if (frameTimes.some((timestamp): timestamp is undefined => timestamp === undefined)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
484
+ if (frameTimes.some((timestamp, index) => index > 0 && timestamp <= frameTimes[index - 1]!)) return { status: 'unavailable', reason: 'frame_timestamps_unordered' };
485
+ const stoppedAtWallClockMs = markers.stoppedAtWallClockMs;
486
+ if (typeof stoppedAtWallClockMs !== 'number' || !Number.isFinite(stoppedAtWallClockMs)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
487
+ const finalDurationMs = stoppedAtWallClockMs - frameTimes[frameTimes.length - 1]!;
488
+ if (!(finalDurationMs > 0)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
489
+ const durationsMs = frameTimes.slice(1).map((timestamp, index) => timestamp - frameTimes[index]!);
490
+ durationsMs.push(finalDurationMs);
491
+ const quote = (filename: string) => filename.replace(/'/g, "'\\''");
492
+ const lines = ['ffconcat version 1.0'];
493
+ for (let index = 0; index < frames.length; index++) {
494
+ lines.push(`file '${quote(path.join(recDir, 'frames', frames[index]!))}'`, 'option framerate 1000', `duration ${(durationsMs[index]! / 1000).toFixed(9)}`);
495
+ }
496
+ // ffconcat applies a duration to the following file, so repeat the final
497
+ // image at the recorder's observed stop boundary to retain its final hold.
498
+ lines.push(`file '${quote(path.join(recDir, 'frames', frames[frames.length - 1]!))}'`, 'option framerate 1000');
499
+ writePrivateFile(manifestPath, lines.join('\n') + '\n');
500
+ return undefined;
501
+ }
502
+
384
503
  /** 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 {
504
+ * atomic no-follow writer. The concat demuxer receives every recorded
505
+ * screencast presentation timestamp instead of a fabricated constant cadence. */
506
+ export function encodeVideoIfAvailable(recDir: string, _durationMs: number): VideoEncoding {
388
507
  const framesDir = path.join(recDir, 'frames');
389
508
  const frames = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter((name) => name.endsWith('.png')).sort() : [];
390
509
  if (frames.length === 0) return { status: 'unavailable', reason: 'no_frames' };
391
- if (durationMs <= 0) return { status: 'unavailable', reason: 'recording_duration_unavailable' };
392
510
  const probe = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5_000 });
393
511
  if (probe.error || probe.status !== 0 || !String(probe.stdout ?? '').includes('libvpx-vp9')) return { status: 'unavailable', reason: 'ffmpeg_or_vp9_encoder_unavailable' };
394
512
  const tempDir = path.join(recDir, `.video-encode-${crypto.randomBytes(8).toString('hex')}`);
395
513
  ensurePrivateDir(tempDir);
396
514
  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
515
  try {
516
+ const manifestOutcome = timestampedFrameManifest(recDir, frames, path.join(tempDir, 'frames.ffconcat'));
517
+ if (manifestOutcome) return manifestOutcome;
518
+ const result = spawnSync('ffmpeg', [
519
+ '-y', '-f', 'concat', '-safe', '0', '-i', path.join(tempDir, 'frames.ffconcat'), '-fps_mode', 'vfr',
520
+ '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', tempOutput,
521
+ ], { stdio: 'ignore', timeout: encodeTimeoutMs(frames.length) });
403
522
  if (result.error || result.status !== 0 || !fs.existsSync(tempOutput)) {
404
523
  return { status: 'failed', reason: classifyEncodeFailure(result) };
405
524
  }
@@ -473,8 +592,88 @@ async function handleStop(parsed: ParsedArgs): Promise<void> {
473
592
  emitFinalizedResult(parsed, stopped);
474
593
  }
475
594
 
595
+ type RecordingLossCounts =
596
+ | { rectSampleElementsDropped: number; bindingRecordsDropped: number; rectSampleFramesTruncated: number; rectSampleFramesCandidateTruncated: number }
597
+ | { unavailableReason: string };
598
+
599
+ function recordingLossCounts(recDir: string): RecordingLossCounts {
600
+ let raw: string;
601
+ try {
602
+ raw = readPrivateFile(path.join(recDir, 'events.jsonl')).toString('utf8');
603
+ } catch (error) {
604
+ return { unavailableReason: `events.jsonl could not be read (${error instanceof Error ? error.message : String(error)})` };
605
+ }
606
+ let rectSampleElementsDropped = 0;
607
+ let bindingRecordsDropped = 0;
608
+ let rectSampleFramesTruncated = 0;
609
+ let rectSampleFramesCandidateTruncated = 0;
610
+ for (const line of raw.split('\n').filter(Boolean)) {
611
+ let event: unknown;
612
+ try {
613
+ event = JSON.parse(line);
614
+ } catch {
615
+ return { unavailableReason: 'events.jsonl contains a malformed event record' };
616
+ }
617
+ if (!event || typeof event !== 'object') continue;
618
+ const { kind, count } = event as { kind?: unknown; count?: unknown };
619
+ if (kind !== 'rect-sample-dropped' && kind !== 'binding-dropped') continue;
620
+ if (!Number.isSafeInteger(count) || count < 0) return { unavailableReason: `${String(kind)} has no non-negative integer count` };
621
+ if (kind === 'rect-sample-dropped') {
622
+ if (!Number.isSafeInteger(rectSampleElementsDropped + count)) return { unavailableReason: 'rect-sample-dropped count exceeds the safe integer range' };
623
+ rectSampleElementsDropped += count;
624
+ } else {
625
+ if (!Number.isSafeInteger(bindingRecordsDropped + count)) return { unavailableReason: 'binding-dropped count exceeds the safe integer range' };
626
+ bindingRecordsDropped += count;
627
+ }
628
+ }
629
+ let rectsRaw: string;
630
+ try {
631
+ rectsRaw = readPrivateFile(path.join(recDir, 'rects.jsonl')).toString('utf8');
632
+ } catch (error) {
633
+ return { unavailableReason: `rects.jsonl could not be read (${error instanceof Error ? error.message : String(error)})` };
634
+ }
635
+ for (const line of rectsRaw.split('\n').filter(Boolean)) {
636
+ let rect: unknown;
637
+ try {
638
+ rect = JSON.parse(line);
639
+ } catch {
640
+ return { unavailableReason: 'rects.jsonl contains a malformed rect record' };
641
+ }
642
+ if (!rect || typeof rect !== 'object') continue;
643
+ if ((rect as { elementSampleTruncated?: unknown }).elementSampleTruncated === true) {
644
+ if (!Number.isSafeInteger(rectSampleFramesTruncated + 1)) return { unavailableReason: 'rect-sample truncation count exceeds the safe integer range' };
645
+ rectSampleFramesTruncated++;
646
+ }
647
+ if ((rect as { candidateSampleTruncated?: unknown }).candidateSampleTruncated === true) {
648
+ if (!Number.isSafeInteger(rectSampleFramesCandidateTruncated + 1)) return { unavailableReason: 'rect-sample candidate truncation count exceeds the safe integer range' };
649
+ rectSampleFramesCandidateTruncated++;
650
+ }
651
+ }
652
+ return { rectSampleElementsDropped, bindingRecordsDropped, rectSampleFramesTruncated, rectSampleFramesCandidateTruncated };
653
+ }
654
+
476
655
  function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & { action?: string }): void {
477
656
  const durationS = `${(stopped.durationMs / 1000).toFixed(1)}s`;
657
+ const lossCounts = recordingLossCounts(stopped.recDir);
658
+ const lossAttrs = 'unavailableReason' in lossCounts
659
+ ? { 'rect-sample-elements-dropped': 'unavailable', 'binding-records-dropped': 'unavailable', 'rect-sample-frames-truncated': 'unavailable', 'rect-sample-frames-candidate-truncated': 'unavailable' }
660
+ : {
661
+ 'rect-sample-elements-dropped': lossCounts.rectSampleElementsDropped,
662
+ 'binding-records-dropped': lossCounts.bindingRecordsDropped,
663
+ 'rect-sample-frames-truncated': lossCounts.rectSampleFramesTruncated,
664
+ 'rect-sample-frames-candidate-truncated': lossCounts.rectSampleFramesCandidateTruncated,
665
+ };
666
+ const lossFacts = 'unavailableReason' in lossCounts
667
+ ? [fact`Dropped rect-sample element and binding-record counts and rect-sample truncation state are unavailable: ${lossCounts.unavailableReason}.`]
668
+ : [
669
+ fact`Retained rect samples contain only the frame and element facts the recorder retained; ${lossCounts.rectSampleElementsDropped} rect-sample element fact(s) and ${lossCounts.bindingRecordsDropped} binding record(s) were dropped.`,
670
+ ...(lossCounts.rectSampleFramesTruncated > 0
671
+ ? [fact`${lossCounts.rectSampleFramesTruncated} rect sample frame(s) stopped after 4000 non-zero-size elements; remaining DOM candidates were not sampled because of the element cap.`]
672
+ : []),
673
+ ...(lossCounts.rectSampleFramesCandidateTruncated > 0
674
+ ? [fact`${lossCounts.rectSampleFramesCandidateTruncated} rect sample frame(s) sampled rect geometry from 8000 DOM element candidates; remaining DOM candidates were not sampled because of the geometry-read cap.`]
675
+ : []),
676
+ ];
478
677
  const result: RenderableResult = {
479
678
  tag: 'recording',
480
679
  attestation: { kind: 'recording', id: stopped.recId, path: stopped.recDir },
@@ -484,9 +683,11 @@ function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & {
484
683
  duration: durationS,
485
684
  state: stopped.state,
486
685
  'event-records': stopped.eventCount ?? 'unavailable',
686
+ ...lossAttrs,
487
687
  'baseline-availability': baselineAvailability(stopped.recDir),
488
688
  ...(stopped.action ? { action: stopped.action } : {}),
489
689
  ...(stopped.viewportRestored !== null ? { 'viewport-restored': stopped.viewportRestored } : {}),
690
+ ...(stopped.viewportRetained ? { 'viewport-retained': true } : {}),
490
691
  ...('video' in stopped ? { video: (stopped as { video?: VideoEncoding }).video?.status ?? 'unavailable' } : {}),
491
692
  'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
492
693
  },
@@ -497,6 +698,7 @@ function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & {
497
698
  : stopped.eventCount !== null
498
699
  ? fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}, ${stopped.eventCount} event record(s).`
499
700
  : fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}.`,
701
+ sections: lossFacts,
500
702
  artifacts: formatArtifactList(listRecordingArtifacts(stopped.recDir)),
501
703
  };
502
704
  emitResult(result, { json: parsed.json });
@@ -87,7 +87,7 @@ export async function cmdMotionTimeline(parsed: ParsedArgs, _args: string[]): Pr
87
87
  { name: 'meta.json', note: `recording state ${meta.state}` },
88
88
  ]),
89
89
  sections: rows.length ? [lineList(rows)] : [text`The selected element had no complete bounding-box samples.`],
90
- followUp: text`Use \`capture motion jank <rec>\` for dropped-frame and observer-event facts, or \`capture motion response <rec>\` for input-to-settle timing facts.`,
90
+ followUp: text`Use \`capture motion jank <rec>\` for observer-event facts, or \`capture motion response <rec>\` for input-to-settle timing facts.`,
91
91
  };
92
92
  emitResult(result, { json: parsed.json });
93
93
  } catch (err) {
@@ -63,10 +63,11 @@ export async function capturePageInputScreenshot(
63
63
  action: string,
64
64
  label: string,
65
65
  noScreenshot?: boolean,
66
+ out?: string,
66
67
  ): Promise<PageInputScreenshot> {
67
68
  try {
68
69
  return {
69
- screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
70
+ screenshot: await deps.autoScreenshot(client, action, label, noScreenshot, out),
70
71
  screenshotWarning: null,
71
72
  };
72
73
  } catch (error) {
@@ -173,10 +174,11 @@ input:
173
174
  <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required — zero or many matches is a structured error listing candidates with backend:<id> retry keys.
174
175
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
175
176
  --no-screenshot skip the auto-screenshot
177
+ --out <path> destination for the auto-screenshot; same semantics as page shot — overrides the active session's shots/ sequence and enables the screenshot without an active session
176
178
  output:
177
- <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
179
+ <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, a hit-test receiver fact when another node covers that dispatch point, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
178
180
  effects:
179
- scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
181
+ scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot. --out writes that screenshot to the caller-chosen path instead and works without an active session`;
180
182
 
181
183
  export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise<void> {
182
184
  if (parsed.help) {
@@ -192,6 +194,9 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
192
194
  );
193
195
  }
194
196
 
197
+ const settleDefaultSource = parsed.settle === undefined
198
+ ? deps.getActiveSession() === null ? 'standalone default' : 'active-session default'
199
+ : undefined;
195
200
  const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
196
201
  // connection.ts derives the recorder landmark label from parsed.command,
197
202
  // which the router leaves as the branch token 'page' — restore the verb so
@@ -203,8 +208,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
203
208
  const live = client as unknown as LiveClient;
204
209
  const resolved = await resolveLiveTarget(live, target);
205
210
  if (!resolved.ok) return { failure: resolved } as const;
206
- const dispatch = await clickResolved(live, resolved);
207
- const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
211
+ const dispatch = await clickResolved(live, resolved, { inspectHitTest: true });
212
+ const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot, parsed.out);
208
213
  return { dispatch, ...screenshotResult } as const;
209
214
  },
210
215
  );
@@ -216,7 +221,11 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
216
221
  const { dispatch, screenshot, screenshotWarning } = outcome;
217
222
  const rows: FactLine[] = [
218
223
  fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
224
+ ...(dispatch.hitTestReceiverBackendNodeId === undefined
225
+ ? []
226
+ : [fact`dispatch hit test at x=${dispatch.x} y=${dispatch.y} resolved to backend:${dispatch.hitTestReceiverBackendNodeId}, not target backend:${dispatch.backendNodeId}`]),
219
227
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
228
+ ...(settleDefaultSource === undefined ? [] : [fact`settle source: ${settleDefaultSource}; no --settle flag was passed.`]),
220
229
  ];
221
230
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
222
231
  if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
@@ -228,6 +237,7 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
228
237
  'backend-node-id': dispatch.backendNodeId,
229
238
  role: dispatch.role ?? undefined,
230
239
  name: dispatch.name ?? undefined,
240
+ 'hit-test-receiver-backend-node-id': dispatch.hitTestReceiverBackendNodeId,
231
241
  },
232
242
  summary: lineList(rows),
233
243
  },
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { type ParsedArgs } from '../../types.js';
20
20
  import { invalidInput } from '../../../errors.js';
21
+ import { selectRecords } from '../../../output/selection.js';
21
22
  import { withConnection } from '../../connection.js';
22
23
  import { INTERACTIVE_ROLES, readFullAXTree } from '../../a11y.js';
23
24
  import {
@@ -37,7 +38,8 @@ const USAGE = `capture page elements — list what can be acted on in the live t
37
38
 
38
39
  Input:
39
40
  --all full exposed accessibility tree instead of interactive elements only
40
- --limit <n> max elements listed (positive integer, default ${DEFAULT_LIMIT}; a capped list states the total as an elements-truncated fact)
41
+ --name <text> case-insensitive substring filter over each exposed accessibility name; applies before --limit
42
+ --limit <n> max filtered elements returned in prose and JSON (positive integer, default ${DEFAULT_LIMIT}; a capped list states the total as an elements-truncated fact)
41
43
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
42
44
  --json mirror the result as JSON
43
45
 
@@ -69,9 +71,24 @@ export interface ElementRecord {
69
71
  */
70
72
  export async function collectElements(
71
73
  client: ElementsClient,
72
- opts: { all?: boolean } = {},
74
+ opts: { all?: boolean; name?: string } = {},
73
75
  ): Promise<ElementRecord[]> {
74
76
  const nodes = await readFullAXTree(client);
77
+ const nodesById = new Map(nodes.map((node) => [node.nodeId, node]));
78
+ const parents = new Map<string, (typeof nodes)[number]>();
79
+ for (const node of nodes) {
80
+ for (const childId of node.childIds ?? []) parents.set(childId, node);
81
+ }
82
+ const nameOf = (node: (typeof nodes)[number]): string => {
83
+ const ownName = node.name?.value ?? '';
84
+ if (ownName) return ownName;
85
+ return (node.childIds ?? [])
86
+ .map((childId) => nodesById.get(childId))
87
+ .filter((child): child is (typeof nodes)[number] => child?.role?.value === 'StaticText')
88
+ .map((child) => child.name?.value ?? '')
89
+ .filter(Boolean)
90
+ .join(' ');
91
+ };
75
92
 
76
93
  const records: ElementRecord[] = [];
77
94
  for (const node of nodes) {
@@ -81,14 +98,29 @@ export async function collectElements(
81
98
  if (!opts.all) {
82
99
  if (!INTERACTIVE_ROLES.has(role)) continue;
83
100
  if (node.backendDOMNodeId === undefined) continue;
101
+ } else {
102
+ const parent = parents.get(node.nodeId);
103
+ const duplicateParentText =
104
+ parent !== undefined &&
105
+ (node.name?.value === parent.name?.value || (role === 'StaticText' && !parent.name?.value));
106
+ if (
107
+ (role === 'StaticText' || role === 'InlineTextBox') &&
108
+ typeof node.name?.value === 'string' &&
109
+ node.name.value.length > 0 &&
110
+ duplicateParentText
111
+ ) {
112
+ continue;
113
+ }
84
114
  }
85
115
  records.push({
86
116
  role,
87
- name: node.name?.value ?? '',
117
+ name: nameOf(node),
88
118
  backendNodeId: node.backendDOMNodeId ?? null,
89
119
  });
90
120
  }
91
- return records;
121
+ return opts.name === undefined
122
+ ? records
123
+ : records.filter((record) => record.name.toLowerCase().includes(opts.name!.toLowerCase()));
92
124
  }
93
125
 
94
126
  /**
@@ -99,10 +131,10 @@ export async function collectElements(
99
131
  */
100
132
  export function buildElementsResult(
101
133
  records: readonly ElementRecord[],
102
- opts: { all: boolean; limit: number },
134
+ opts: { all: boolean; limit: number; name?: string },
103
135
  ): RenderableResult {
104
136
  const total = records.length;
105
- const shown = records.slice(0, opts.limit);
137
+ const shown = selectRecords(records, { limit: opts.limit }, opts.limit);
106
138
 
107
139
  const rows = shown.map((r) =>
108
140
  r.backendNodeId !== null
@@ -118,10 +150,10 @@ export function buildElementsResult(
118
150
 
119
151
  return {
120
152
  tag: 'elements',
121
- attrs: { scope: opts.all ? 'all' : 'interactive', count: total },
153
+ attrs: { scope: opts.all ? 'all' : 'interactive', count: total, 'name-filter': opts.name },
122
154
  summary: opts.all
123
- ? fact`${total} elements in the exposed accessibility tree`
124
- : fact`${total} interactive elements`,
155
+ ? opts.name === undefined ? fact`${total} elements in the exposed accessibility tree` : fact`${total} elements in the exposed accessibility tree with accessible name containing ${opts.name}`
156
+ : opts.name === undefined ? fact`${total} interactive elements` : fact`${total} interactive elements with accessible name containing ${opts.name}`,
125
157
  sections,
126
158
  followUp: text`capture page click <target> · capture measure map ax <url|snap>`,
127
159
  };
@@ -144,11 +176,11 @@ export async function cmdPageElements(parsed: ParsedArgs, _args: string[]): Prom
144
176
 
145
177
  const records = await withConnection(
146
178
  parsed,
147
- (client) => collectElements(client, { all: parsed.all }),
179
+ (client) => collectElements(client, { all: parsed.all, name: parsed.name }),
148
180
  { settle: 0 },
149
181
  );
150
182
 
151
- emitResult(buildElementsResult(records, { all: Boolean(parsed.all), limit }), {
183
+ emitResult(buildElementsResult(records, { all: Boolean(parsed.all), limit, name: parsed.name }), {
152
184
  json: parsed.json,
153
185
  });
154
186
  }