@crouton-kit/capture 1.4.2 → 1.4.11

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 +187 -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
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { connectForCommand, withPageAction, type SettleFacts } from '../../connection.js';
23
23
  import { EventBroker } from '../../bridge/server.js';
24
- import { isRecorderHeldClient, type RecorderHeldClient } from '../../recorder-client.js';
24
+ import { isMotionHeldClient, type MotionHeldClient } from '../../host/held-client.js';
25
25
  import { type CDPClient } from '../../client.js';
26
26
  import { type ParsedArgs } from '../../types.js';
27
27
  import { CaptureError, captureError, invalidInput } from '../../../errors.js';
@@ -60,12 +60,15 @@ picks which tab navigates. Navigate never creates a tab — use "tab open" for
60
60
  that; an unresolvable source is a targeting error, not a new tab.
61
61
 
62
62
  Output:
63
- One <navigated url=… settle=… load-outcome=… deadline-exceeded=…> block:
63
+ One <navigated url=… settle=… load-outcome=… document-outcome=… deadline-exceeded=…> block:
64
64
  the tab URL after navigation, the measured settle, and — reported
65
- separately — the load outcome (observed | bounded-timeout), whether the
66
- nav phase exceeded its deadline, the dispatched navigation's loaderId (and
65
+ separately — the load outcome (observed | bounded-timeout), the document/network
66
+ outcome (http-response | http-error-document | not-observed; the latter has no
67
+ document status or URL), whether the nav phase exceeded its deadline, the
68
+ dispatched navigation's loaderId (and
67
69
  any about:blank bounce for a same-document target), and whether the CDP
68
- routed through an active recording. --json mirrors the same fields.
70
+ routed through an active recording. A document HTTP error does not change the
71
+ navigation dispatch or load outcome. --json mirrors the same fields.
69
72
 
70
73
  Effects:
71
74
  Navigates the source tab (exactly one Page.navigate for the destination;
@@ -78,8 +81,13 @@ Effects:
78
81
  /** Load outcome plus the dispatched navigation's own facts, produced by one
79
82
  * navigation core (recorder-routed or direct). Distinct fields so the render
80
83
  * layer can report the load result separately from the method result. */
84
+ type DocumentOutcome =
85
+ | { kind: 'http-response' | 'http-error-document'; status: number; url: string }
86
+ | { kind: 'not-observed' };
87
+
81
88
  interface NavCore {
82
89
  loadOutcome: 'observed' | 'bounded-timeout';
90
+ documentOutcome: DocumentOutcome;
83
91
  /** The dispatched navigation's loaderId (present on a real cross-document
84
92
  * navigation; absent for a same-document target before the bounce). */
85
93
  loaderId?: string;
@@ -96,18 +104,23 @@ interface NavCore {
96
104
  * target returns no loaderId — recover with exactly one dest→about:blank→dest
97
105
  * bounce, bundling the wait only on the final re-navigate.
98
106
  */
99
- async function navigateViaRecorder(client: RecorderHeldClient, url: string): Promise<NavCore> {
100
- const first = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
107
+ function documentOutcomeFor(response?: { status: number; url: string }): DocumentOutcome {
108
+ if (!response) return { kind: 'not-observed' };
109
+ return { kind: response.status >= 400 ? 'http-error-document' : 'http-response', status: response.status, url: response.url };
110
+ }
111
+
112
+ async function navigateViaRecorder(client: MotionHeldClient, url: string): Promise<NavCore> {
113
+ const first = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs, true);
101
114
  const loaderId = (first.result as { loaderId?: string } | undefined)?.loaderId;
102
115
  if (loaderId) {
103
- return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', loaderId, bounced: false };
116
+ return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', documentOutcome: documentOutcomeFor(first.documentResponse), loaderId, bounced: false };
104
117
  }
105
118
  // Same-document target: the destination is already committed (no loaderId);
106
119
  // bounce through about:blank and re-navigate so a fresh document mounts.
107
120
  await client.dispatch('Page.navigate', { url: 'about:blank' });
108
- const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
121
+ const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs, true);
109
122
  const finalLoaderId = (final.result as { loaderId?: string } | undefined)?.loaderId;
110
- return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', loaderId: finalLoaderId, bounced: true };
123
+ return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', documentOutcome: documentOutcomeFor(final.documentResponse), loaderId: finalLoaderId, bounced: true };
111
124
  }
112
125
 
113
126
  /** Arms a bounded `Page.loadEventFired` wait on a direct client's broker and
@@ -134,43 +147,66 @@ function armLoadWait(broker: EventBroker): {
134
147
  * exactly one dest→about:blank→dest bounce, re-arming the wait before the
135
148
  * final send.
136
149
  */
150
+ function observeDocumentResponses(client: CDPClient): { outcomeFor(loaderId?: string): DocumentOutcome; dispose(): void } {
151
+ const responses: Array<{ loaderId: string; status: number; url: string }> = [];
152
+ const handler = (params: unknown) => {
153
+ if (!params || typeof params !== 'object' || Array.isArray(params)) return;
154
+ const event = params as { type?: unknown; loaderId?: unknown; response?: unknown };
155
+ if (event.type !== 'Document' || typeof event.loaderId !== 'string' || !event.response || typeof event.response !== 'object' || Array.isArray(event.response)) return;
156
+ const response = event.response as { status?: unknown; url?: unknown };
157
+ if (typeof response.status !== 'number' || !Number.isFinite(response.status) || typeof response.url !== 'string') return;
158
+ responses.push({ loaderId: event.loaderId, status: response.status, url: response.url });
159
+ };
160
+ client.on('Network.responseReceived', handler);
161
+ return {
162
+ outcomeFor(loaderId) {
163
+ const response = loaderId === undefined ? undefined : responses.filter((candidate) => candidate.loaderId === loaderId).at(-1);
164
+ if (!response) return { kind: 'not-observed' };
165
+ return { kind: response.status >= 400 ? 'http-error-document' : 'http-response', status: response.status, url: response.url };
166
+ },
167
+ dispose() { client.off('Network.responseReceived', handler); },
168
+ };
169
+ }
170
+
137
171
  async function navigateDirect(
138
172
  client: CDPClient,
139
173
  url: string,
140
174
  armed?: { cancel: () => void },
141
175
  ): Promise<NavCore> {
142
176
  await client.send('Page.enable');
177
+ await client.send('Network.enable');
178
+ const documents = observeDocumentResponses(client);
143
179
  const broker = new EventBroker(client);
144
180
 
145
- const first = armLoadWait(broker);
146
- // Publish the currently-armed wait's canceller so an outer-deadline
147
- // abandonment can clear its inner timer instead of letting it fire on its own.
148
- if (armed) armed.cancel = () => first.wait.cancel();
149
- let nav: { loaderId?: string } | undefined;
150
181
  try {
151
- nav = (await client.send('Page.navigate', { url })) as { loaderId?: string };
152
- } catch (err) {
153
- first.wait.cancel();
154
- throw err;
155
- }
156
- if (nav?.loaderId) {
157
- return { loadOutcome: await first.outcome, loaderId: nav.loaderId, bounced: false };
158
- }
182
+ const first = armLoadWait(broker);
183
+ if (armed) armed.cancel = () => first.wait.cancel();
184
+ let nav: { loaderId?: string } | undefined;
185
+ try {
186
+ nav = (await client.send('Page.navigate', { url })) as { loaderId?: string };
187
+ } catch (err) {
188
+ first.wait.cancel();
189
+ throw err;
190
+ }
191
+ if (nav?.loaderId) {
192
+ return { loadOutcome: await first.outcome, documentOutcome: documents.outcomeFor(nav.loaderId), loaderId: nav.loaderId, bounced: false };
193
+ }
159
194
 
160
- // Same-document target: discard the first wait, bounce through about:blank,
161
- // re-arm the wait, then re-navigate to the destination.
162
- first.wait.cancel();
163
- await client.send('Page.navigate', { url: 'about:blank' });
164
- const second = armLoadWait(broker);
165
- if (armed) armed.cancel = () => second.wait.cancel();
166
- let navFinal: { loaderId?: string } | undefined;
167
- try {
168
- navFinal = (await client.send('Page.navigate', { url })) as { loaderId?: string };
169
- } catch (err) {
170
- second.wait.cancel();
171
- throw err;
195
+ first.wait.cancel();
196
+ await client.send('Page.navigate', { url: 'about:blank' });
197
+ const second = armLoadWait(broker);
198
+ if (armed) armed.cancel = () => second.wait.cancel();
199
+ let navFinal: { loaderId?: string } | undefined;
200
+ try {
201
+ navFinal = (await client.send('Page.navigate', { url })) as { loaderId?: string };
202
+ } catch (err) {
203
+ second.wait.cancel();
204
+ throw err;
205
+ }
206
+ return { loadOutcome: await second.outcome, documentOutcome: documents.outcomeFor(navFinal?.loaderId), loaderId: navFinal?.loaderId, bounced: true };
207
+ } finally {
208
+ documents.dispose();
172
209
  }
173
- return { loadOutcome: await second.outcome, loaderId: navFinal?.loaderId, bounced: true };
174
210
  }
175
211
 
176
212
  /** Races the nav+load phase against the outer deadline. The deadline never
@@ -200,6 +236,7 @@ interface NavigatedFacts {
200
236
  tabUrl: string;
201
237
  requestedUrl: string;
202
238
  loadOutcome: 'observed' | 'bounded-timeout';
239
+ documentOutcome: DocumentOutcome;
203
240
  deadlineExceeded: boolean;
204
241
  loaderId?: string;
205
242
  bounced: boolean;
@@ -212,6 +249,9 @@ function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
212
249
  f.loadOutcome === 'observed'
213
250
  ? fact`load: ${LOAD_EVENT_NAME} observed within the ${timing.innerTimeoutMs}ms load-wait window.`
214
251
  : fact`load: ${LOAD_EVENT_NAME} not observed within the ${timing.innerTimeoutMs}ms load-wait window (bounded-timeout); the navigation still dispatched.`,
252
+ f.documentOutcome.kind === 'not-observed'
253
+ ? fact`document/network outcome: no matching HTTP document response was observed.`
254
+ : fact`document/network outcome: HTTP ${f.documentOutcome.status} at ${f.documentOutcome.url}${f.documentOutcome.kind === 'http-error-document' ? ' (HTTP error document)' : ''}.`,
215
255
  fact`navigation dispatched: loaderId ${f.loaderId ?? '(none)'}${f.bounced ? '; delivered via dest→about:blank→dest bounce (same-document target)' : ''}.`,
216
256
  fact`settle: requested ${f.settle.requestedMs}ms, waited ${f.settle.waitedMs}ms.`,
217
257
  ];
@@ -229,6 +269,9 @@ function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
229
269
  url: f.tabUrl,
230
270
  settle: f.settle.requestedMs,
231
271
  'load-outcome': f.loadOutcome,
272
+ 'document-outcome': f.documentOutcome.kind,
273
+ 'document-status': f.documentOutcome.kind === 'not-observed' ? undefined : f.documentOutcome.status,
274
+ 'document-url': f.documentOutcome.kind === 'not-observed' ? undefined : f.documentOutcome.url,
232
275
  'deadline-exceeded': f.deadlineExceeded || undefined,
233
276
  routed: f.routed || undefined,
234
277
  },
@@ -276,12 +319,12 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
276
319
  { ...parsed, command: 'navigate' },
277
320
  { settleMs: settle },
278
321
  async (client, _tab) => {
279
- const routed = isRecorderHeldClient(client);
322
+ const routed = isMotionHeldClient(client);
280
323
  // Holder for the direct path's currently-armed load wait, so an
281
324
  // outer-deadline abandonment can cancel it (clearing its inner timer).
282
325
  const armed = { cancel: () => {} };
283
326
  const work = routed
284
- ? navigateViaRecorder(client as unknown as RecorderHeldClient, url)
327
+ ? navigateViaRecorder(client as unknown as MotionHeldClient, url)
285
328
  : navigateDirect(client, url, armed);
286
329
  let raced: Awaited<ReturnType<typeof raceNavDeadline>>;
287
330
  try {
@@ -305,6 +348,7 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
305
348
  armed.cancel();
306
349
  return {
307
350
  loadOutcome: 'bounded-timeout' as const,
351
+ documentOutcome: { kind: 'not-observed' } as const,
308
352
  loaderId: undefined,
309
353
  bounced: false,
310
354
  deadlineExceeded: true,
@@ -319,6 +363,7 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
319
363
  tabUrl: core.tabUrl,
320
364
  requestedUrl: url,
321
365
  loadOutcome: core.loadOutcome,
366
+ documentOutcome: core.documentOutcome,
322
367
  deadlineExceeded: core.deadlineExceeded,
323
368
  loaderId: core.loaderId,
324
369
  bounced: core.bounced,
@@ -0,0 +1,176 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { resolveLiveTarget, clickResolved, focusAndType, typeText, type ClickDispatch, type LiveClient, type ResolutionFailure } from '../../../interact.js';
3
+ import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
4
+ import { effectiveSettle, emitInvalidInput, pageInputDeps } from './click.js';
5
+
6
+ const MAX_STEPS = 20;
7
+ const MAX_ITERATIONS = 100;
8
+
9
+ const USAGE = `capture page repeat --steps <json> --iterations <n> [--settle <ms>] — repeat one bounded declarative click/type sequence
10
+
11
+ input:
12
+ --steps <json> required JSON array of 1..${MAX_STEPS} action objects, in order: {"action":"click","target":"<live target>"} or {"action":"type","text":"<text>","into":"<live target>"}; type omits into to insert into the focused element
13
+ --iterations <n> required repeat count from 1 to ${MAX_ITERATIONS}
14
+ --settle <ms> network-settle window applied after each completed sequence (default: 1000; 2500 with an active session; 0 disables)
15
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
16
+ output:
17
+ <repeated iterations=… steps=…> — one factual completion record per iteration with step action, resolved identity when applicable, dispatch coordinates, elapsed duration, and measured requested/waited settle
18
+ effects:
19
+ drives every click through the same live target resolution and real mouse dispatch as page click, and every type through the same focused/targeted real text insertion as page type; each completed iteration receives its own action lifecycle and settle window; no automatic screenshots are written`;
20
+
21
+ type RepeatStep =
22
+ | { readonly action: 'click'; readonly target: string }
23
+ | { readonly action: 'type'; readonly text: string; readonly into?: string };
24
+
25
+ interface ActionRecord {
26
+ readonly action: 'click' | 'type';
27
+ readonly target?: string;
28
+ readonly text?: string;
29
+ readonly backendNodeId?: number;
30
+ readonly role?: string;
31
+ readonly name?: string;
32
+ readonly x?: number;
33
+ readonly y?: number;
34
+ readonly focused?: boolean;
35
+ readonly hitTestReceiverBackendNodeId?: number;
36
+ }
37
+
38
+ interface FailureRecord {
39
+ readonly code: string;
40
+ readonly target: string;
41
+ readonly matches?: number;
42
+ }
43
+
44
+ interface IterationRecord {
45
+ readonly iteration: number;
46
+ readonly completion: 'completed' | 'failed';
47
+ readonly durationMs: number;
48
+ readonly settle?: { readonly requestedMs: number; readonly waitedMs: number };
49
+ readonly actions: readonly ActionRecord[];
50
+ readonly failure?: FailureRecord;
51
+ }
52
+
53
+ class RepeatResolutionFailure extends Error {
54
+ constructor(readonly failure: ResolutionFailure, readonly actions: readonly ActionRecord[]) {
55
+ super('page repeat target resolution did not complete.');
56
+ }
57
+ }
58
+
59
+ function parseSteps(raw: string | undefined): RepeatStep[] | null {
60
+ if (raw === undefined || raw.length > 10_000) return null;
61
+ try {
62
+ const value = JSON.parse(raw) as unknown;
63
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_STEPS) return null;
64
+ const out: RepeatStep[] = [];
65
+ for (const valueStep of value) {
66
+ if (!valueStep || typeof valueStep !== 'object' || Array.isArray(valueStep)) return null;
67
+ const step = valueStep as Record<string, unknown>;
68
+ if (step.action === 'click' && Object.keys(step).length === 2 && typeof step.target === 'string' && step.target.length > 0) {
69
+ out.push({ action: 'click', target: step.target });
70
+ } else if (step.action === 'type' && Object.keys(step).every(key => key === 'action' || key === 'text' || key === 'into') && typeof step.text === 'string' && (step.into === undefined || (typeof step.into === 'string' && step.into.trim().length > 0))) {
71
+ out.push({ action: 'type', text: step.text, ...(typeof step.into === 'string' ? { into: step.into } : {}) });
72
+ } else return null;
73
+ }
74
+ return out;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function actionRecord(action: 'click' | 'type', dispatch: ClickDispatch, target: string, text?: string): ActionRecord {
81
+ return {
82
+ action,
83
+ target,
84
+ ...(text === undefined ? {} : { text }),
85
+ backendNodeId: dispatch.backendNodeId,
86
+ ...(dispatch.role === null ? {} : { role: dispatch.role }),
87
+ ...(dispatch.name === null ? {} : { name: dispatch.name }),
88
+ x: dispatch.x,
89
+ y: dispatch.y,
90
+ ...(action === 'type' ? { focused: true } : {}),
91
+ ...(dispatch.hitTestReceiverBackendNodeId === undefined ? {} : { hitTestReceiverBackendNodeId: dispatch.hitTestReceiverBackendNodeId }),
92
+ };
93
+ }
94
+
95
+ function emitRepeatResult(parsed: ParsedArgs, steps: number, settle: number, records: readonly IterationRecord[], completion: 'completed' | 'partial'): void {
96
+ const sections: FactLine[] = records.map(record => lineList([
97
+ record.completion === 'completed'
98
+ ? fact`iteration ${record.iteration} completed in ${record.durationMs.toFixed(1)}ms; settle requested ${record.settle!.requestedMs}ms, waited ${record.settle!.waitedMs}ms.`
99
+ : fact`iteration ${record.iteration} did not complete in ${record.durationMs.toFixed(1)}ms; target ${record.failure!.target} resolution returned ${record.failure!.code}${record.failure!.matches === undefined ? '' : ` with ${record.failure!.matches} live matches`}.`,
100
+ ...record.actions.map(action => action.action === 'click'
101
+ ? fact`click ${action.target ?? '(unknown)'} → backend:${action.backendNodeId ?? '(unknown)'}${action.role === undefined ? '' : ` role=${action.role}`}${action.name === undefined ? '' : ` name=${action.name}`} at x=${action.x ?? '(unknown)'} y=${action.y ?? '(unknown)'}${action.hitTestReceiverBackendNodeId === undefined ? '' : `; hit-test receiver backend:${action.hitTestReceiverBackendNodeId}`}`
102
+ : action.backendNodeId === undefined
103
+ ? fact`type into focused element: ${action.text ?? ''}`
104
+ : fact`type into ${action.target ?? '(unknown)'} → backend:${action.backendNodeId}${action.role === undefined ? '' : ` role=${action.role}`}${action.name === undefined ? '' : ` name=${action.name}`} at x=${action.x ?? '(unknown)'} y=${action.y ?? '(unknown)'}${action.hitTestReceiverBackendNodeId === undefined ? '' : `; hit-test receiver backend:${action.hitTestReceiverBackendNodeId}`}: ${action.text ?? ''}`),
105
+ ]));
106
+ emitResult({
107
+ tag: 'repeated',
108
+ attrs: { iterations: records.length, steps, 'settle-ms': settle, completion, ...(completion === 'partial' ? { 'failed-iteration': records.at(-1)!.iteration, 'failure-code': records.at(-1)!.failure!.code } : {}) },
109
+ summary: completion === 'completed'
110
+ ? fact`${records.length} iteration(s) completed; each record is one ordered ${steps}-step sequence against the live tab.`
111
+ : fact`${records.length - 1} iteration(s) completed before iteration ${records.at(-1)!.iteration} did not complete; completed action records remain in this result.`,
112
+ sections,
113
+ jsonSections: { iterations: records },
114
+ }, { json: parsed.json });
115
+ if (completion === 'partial') process.exitCode = 1;
116
+ }
117
+
118
+ export async function cmdPageRepeat(parsed: ParsedArgs, _args: string[]): Promise<void> {
119
+ if (parsed.help) {
120
+ console.log(USAGE);
121
+ return;
122
+ }
123
+ const steps = parseSteps(parsed.steps);
124
+ const iterations = parsed.iterations;
125
+ if (parsed.positional.length !== 0 || steps === null || iterations === undefined || !Number.isSafeInteger(iterations) || iterations < 1 || iterations > MAX_ITERATIONS) {
126
+ return emitInvalidInput(parsed, 'page repeat', fact`received: ${parsed.positional.length} positional arguments, --steps=${parsed.steps === undefined ? '(missing)' : 'provided'}, --iterations=${String(iterations ?? '(missing)')}; expected no positionals, a JSON array of 1..${MAX_STEPS} click/type step objects, and an iteration count from 1 to ${MAX_ITERATIONS}.`);
127
+ }
128
+
129
+ const deps = pageInputDeps();
130
+ const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
131
+ const records: IterationRecord[] = [];
132
+
133
+ for (let iteration = 1; iteration <= iterations; iteration++) {
134
+ const started = performance.now();
135
+ try {
136
+ const { result, settle: settleFacts } = await deps.withPageAction(
137
+ { ...parsed, command: 'repeat' },
138
+ { settleMs: settle },
139
+ async (client) => {
140
+ const live = client as unknown as LiveClient;
141
+ const actions: ActionRecord[] = [];
142
+ for (const step of steps) {
143
+ if (step.action === 'click') {
144
+ const resolved = await resolveLiveTarget(live, step.target);
145
+ if (!resolved.ok) throw new RepeatResolutionFailure(resolved, actions);
146
+ const dispatch = await clickResolved(live, resolved, { inspectHitTest: true, includeHitTestReceiver: true });
147
+ actions.push(actionRecord('click', dispatch, step.target));
148
+ } else if (step.into !== undefined) {
149
+ const resolved = await resolveLiveTarget(live, step.into);
150
+ if (!resolved.ok) throw new RepeatResolutionFailure(resolved, actions);
151
+ const dispatch = await focusAndType(live, resolved, step.text, { inspectHitTest: true, includeHitTestReceiver: true });
152
+ actions.push(actionRecord('type', dispatch, step.into, step.text));
153
+ } else {
154
+ await typeText(live, step.text);
155
+ actions.push({ action: 'type', text: step.text, focused: true });
156
+ }
157
+ }
158
+ return { actions } as const;
159
+ },
160
+ );
161
+ records.push({ iteration, completion: 'completed', durationMs: performance.now() - started, settle: { requestedMs: settleFacts.requestedMs, waitedMs: settleFacts.waitedMs }, actions: result.actions });
162
+ } catch (error) {
163
+ if (!(error instanceof RepeatResolutionFailure)) throw error;
164
+ const failure = error.failure;
165
+ const failureRecord: FailureRecord = {
166
+ code: failure.code,
167
+ target: failure.input,
168
+ ...('matchCount' in failure ? { matches: failure.matchCount } : {}),
169
+ };
170
+ records.push({ iteration, completion: 'failed', durationMs: performance.now() - started, actions: error.actions, failure: failureRecord });
171
+ return emitRepeatResult(parsed, steps.length, settle, records, 'partial');
172
+ }
173
+ }
174
+
175
+ emitRepeatResult(parsed, steps.length, settle, records, 'completed');
176
+ }
@@ -22,14 +22,15 @@ import {
22
22
  const USAGE = `capture page scroll <target> --to <top|bottom|px> — scroll one resolved container to a position
23
23
 
24
24
  input:
25
- <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates
25
+ <target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates. This leaf does not enumerate scroll containers; when the target is unknown, use \`capture measure map scroll\`.
26
26
  --to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
27
+ --behavior <instant|smooth> dispatch behavior; instant is the default, smooth waits at most 5000ms for scrollend before reporting the measured offset
27
28
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
28
29
  --no-screenshot skip the auto-screenshot
29
30
  output:
30
- <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, 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
31
+ <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, scrollTop before/after, the container's maximum scrollTop, 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
31
32
  effects:
32
- assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
33
+ drives the container's in-page scroll (instant by default; may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
33
34
 
34
35
  export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promise<void> {
35
36
  if (parsed.help) {
@@ -74,7 +75,7 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
74
75
  if (!resolved.ok) return { failure: resolved } as const;
75
76
  // Same landmark shape `motion rec --do scroll:` records; carried by
76
77
  // the one mutating call when the transport records landmarks.
77
- const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
78
+ const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}`, behavior: parsed.behavior ?? 'instant' });
78
79
  const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
79
80
  return { dispatch, ...screenshotResult } as const;
80
81
  },
@@ -86,7 +87,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
86
87
 
87
88
  const { dispatch, screenshot, screenshotWarning } = outcome;
88
89
  const rows: FactLine[] = [
89
- fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
90
+ fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop ${dispatch.scrollTopBefore} → ${dispatch.scrollTop} within 0…${dispatch.maxScrollTop}`,
91
+ fact`traversed ${Math.abs(dispatch.scrollTop - dispatch.scrollTopBefore)} CSS px of the container's ${dispatch.maxScrollTop} CSS px vertical range`,
90
92
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
91
93
  ];
92
94
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
@@ -100,7 +102,10 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
100
102
  role: dispatch.role ?? undefined,
101
103
  name: dispatch.name ?? undefined,
102
104
  to: dispatch.to,
105
+ 'scroll-top-before': dispatch.scrollTopBefore,
103
106
  'scroll-top': dispatch.scrollTop,
107
+ 'max-scroll-top': dispatch.maxScrollTop,
108
+ 'scroll-range-traversed': Math.abs(dispatch.scrollTop - dispatch.scrollTopBefore),
104
109
  },
105
110
  summary: lineList(rows),
106
111
  },