@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
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `capture page navigate <url>` — navigate the SOURCE tab to a destination
3
+ * URL, then report the load outcome as a fact separate from the settle.
4
+ *
5
+ * Source targeting is ordinary page targeting: `--target <id>` / `--url
6
+ * <pattern>` (or, in a session, the session's own tab) selects which tab
7
+ * navigates; the single positional is the destination only. Navigate never
8
+ * creates a tab — `tab open` is the sole tab-creating verb — so an
9
+ * unresolvable source is `connectForCommand`'s structured targeting error,
10
+ * not a silently-opened tab.
11
+ *
12
+ * Dispatch is single-shot: exactly one `Page.navigate` for the destination
13
+ * (plus a `dest → about:blank → dest` bounce only when the target is a
14
+ * same-document/fragment change that returns no loaderId). The load wait
15
+ * (`Page.loadEventFired`) is armed BEFORE the destination send and reported
16
+ * as its own factual outcome — `observed` (the event fired inside the
17
+ * load-wait window) or `bounded-timeout` (it did not; the navigation still
18
+ * dispatched). A load-wait timeout NEVER discards the navigation's own
19
+ * result (its loaderId), and the navigation is never redispatched to chase a
20
+ * missing event.
21
+ */
22
+ import { connectForCommand, withPageAction, type SettleFacts } from '../../connection.js';
23
+ import { EventBroker } from '../../bridge/server.js';
24
+ import { isRecorderHeldClient, type RecorderHeldClient } from '../../recorder-client.js';
25
+ import { type CDPClient } from '../../client.js';
26
+ import { type ParsedArgs } from '../../types.js';
27
+ import { CaptureError, captureError, invalidInput } from '../../../errors.js';
28
+ import { isParseableUrl } from '../../leaf-grammar.js';
29
+ import { emitResult, fact, text, type FactLine, type RenderableResult } from '../../../output/render.js';
30
+
31
+ const LOAD_EVENT_NAME = 'Page.loadEventFired';
32
+ const DEFAULT_SETTLE_MS = 2000;
33
+
34
+ /** Load-wait window (inner) and total nav-phase deadline (outer) in ms.
35
+ * Injectable so tests drive the timing with tiny timers instead of the real
36
+ * multi-second waits — mirrors `connection.ts`'s `__setConnectionSeamsForTest`
37
+ * seam pattern. */
38
+ interface NavigateTiming {
39
+ innerTimeoutMs: number;
40
+ outerDeadlineMs: number;
41
+ }
42
+ let timing: NavigateTiming = { innerTimeoutMs: 10_000, outerDeadlineMs: 60_000 };
43
+ export function __setNavigateTimingForTest(overrides: Partial<NavigateTiming>): () => void {
44
+ const previous = timing;
45
+ timing = { ...timing, ...overrides };
46
+ return () => { timing = previous; };
47
+ }
48
+
49
+ const USAGE = `capture page navigate <url> [--settle <ms>] — navigate the source tab to a URL and report the load outcome.
50
+
51
+ Input:
52
+ <url> Destination URL (absolute, WHATWG-parseable). This is the
53
+ destination only — it never selects or creates a tab.
54
+ --settle <ms> Post-load settle wait; default ${DEFAULT_SETTLE_MS}ms. 0 disables.
55
+ --target <id> Select the SOURCE tab to navigate (ordinary page targeting).
56
+ --url <pat> Select the SOURCE tab by URL pattern (ordinary page targeting).
57
+
58
+ Source resolution: --target / --url (or, in a session, the session's own tab)
59
+ picks which tab navigates. Navigate never creates a tab — use "tab open" for
60
+ that; an unresolvable source is a targeting error, not a new tab.
61
+
62
+ Output:
63
+ One <navigated url=… settle=… load-outcome=… deadline-exceeded=…> block:
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
67
+ any about:blank bounce for a same-document target), and whether the CDP
68
+ routed through an active recording. --json mirrors the same fields.
69
+
70
+ Effects:
71
+ Navigates the source tab (exactly one Page.navigate for the destination;
72
+ one dest→about:blank→dest bounce only for a same-document/fragment target).
73
+ During a live composed recording the CDP routes through the recorder socket
74
+ and Page.navigate carries a labeled performance.mark landmark; local
75
+ console/HAR collectors are skipped while routed — traffic streams into the
76
+ session HAR via the recording's held connection.`;
77
+
78
+ /** Load outcome plus the dispatched navigation's own facts, produced by one
79
+ * navigation core (recorder-routed or direct). Distinct fields so the render
80
+ * layer can report the load result separately from the method result. */
81
+ interface NavCore {
82
+ loadOutcome: 'observed' | 'bounded-timeout';
83
+ /** The dispatched navigation's loaderId (present on a real cross-document
84
+ * navigation; absent for a same-document target before the bounce). */
85
+ loaderId?: string;
86
+ /** True when a same-document (fragment-only) target forced the
87
+ * dest→about:blank→dest bounce so a freshly-mounted document sees it. */
88
+ bounced: boolean;
89
+ }
90
+
91
+ /**
92
+ * Recorder-routed navigation. The load wait is bundled atomically onto the
93
+ * destination `Page.navigate` via `dispatch()` (the bridge arms the wait
94
+ * before sending), so a fast load cannot race a separate subscription and a
95
+ * wait timeout no longer discards the navigation's loaderId. A same-document
96
+ * target returns no loaderId — recover with exactly one dest→about:blank→dest
97
+ * bounce, bundling the wait only on the final re-navigate.
98
+ */
99
+ async function navigateViaRecorder(client: RecorderHeldClient, url: string): Promise<NavCore> {
100
+ const first = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
101
+ const loaderId = (first.result as { loaderId?: string } | undefined)?.loaderId;
102
+ if (loaderId) {
103
+ return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', loaderId, bounced: false };
104
+ }
105
+ // Same-document target: the destination is already committed (no loaderId);
106
+ // bounce through about:blank and re-navigate so a fresh document mounts.
107
+ await client.dispatch('Page.navigate', { url: 'about:blank' });
108
+ const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
109
+ const finalLoaderId = (final.result as { loaderId?: string } | undefined)?.loaderId;
110
+ return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', loaderId: finalLoaderId, bounced: true };
111
+ }
112
+
113
+ /** Arms a bounded `Page.loadEventFired` wait on a direct client's broker and
114
+ * returns the wait handle plus a never-rejecting outcome promise
115
+ * (`observed` | `bounded-timeout`). Arm this BEFORE the paired
116
+ * `Page.navigate` send so a synchronous load event cannot be missed. */
117
+ function armLoadWait(broker: EventBroker): {
118
+ wait: ReturnType<EventBroker['wait']>;
119
+ outcome: Promise<'observed' | 'bounded-timeout'>;
120
+ } {
121
+ const wait = broker.wait(LOAD_EVENT_NAME, undefined, timing.innerTimeoutMs);
122
+ const outcome = wait.result().then(
123
+ () => 'observed' as const,
124
+ () => 'bounded-timeout' as const,
125
+ );
126
+ return { wait, outcome };
127
+ }
128
+
129
+ /**
130
+ * Direct (non-recorder) navigation over a real CDP client. The load wait is
131
+ * armed before the destination send (via the shared `EventBroker`), so a
132
+ * synchronous `Page.loadEventFired` is still observed. A method throw
133
+ * propagates (no retry). A same-document target (no loaderId) recovers with
134
+ * exactly one dest→about:blank→dest bounce, re-arming the wait before the
135
+ * final send.
136
+ */
137
+ async function navigateDirect(
138
+ client: CDPClient,
139
+ url: string,
140
+ armed?: { cancel: () => void },
141
+ ): Promise<NavCore> {
142
+ await client.send('Page.enable');
143
+ const broker = new EventBroker(client);
144
+
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
+ 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
+ }
159
+
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;
172
+ }
173
+ return { loadOutcome: await second.outcome, loaderId: navFinal?.loaderId, bounced: true };
174
+ }
175
+
176
+ /** Races the nav+load phase against the outer deadline. The deadline never
177
+ * throws — on elapse it returns `{ completed: false }` and the caller reports
178
+ * `deadline-exceeded` as a fact alongside whatever partial outcome it holds.
179
+ * A method rejection from `work` propagates (no retry). */
180
+ async function raceNavDeadline(work: Promise<NavCore>): Promise<{ completed: true; core: NavCore } | { completed: false }> {
181
+ let timer: ReturnType<typeof setTimeout>;
182
+ const deadline = new Promise<{ completed: false }>((resolve) => {
183
+ timer = setTimeout(() => resolve({ completed: false }), timing.outerDeadlineMs);
184
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
185
+ });
186
+ const workOutcome = work.then((core) => ({ completed: true as const, core }));
187
+ // If the deadline wins the race, `work` is still pending and may later
188
+ // reject (e.g. a hung dispatch that eventually errors) — mark it handled so
189
+ // that late rejection is not an unhandled promise. This does not suppress a
190
+ // rejection that arrives BEFORE the deadline: Promise.race still observes it.
191
+ workOutcome.catch(() => {});
192
+ try {
193
+ return await Promise.race([workOutcome, deadline]);
194
+ } finally {
195
+ clearTimeout(timer!);
196
+ }
197
+ }
198
+
199
+ interface NavigatedFacts {
200
+ tabUrl: string;
201
+ requestedUrl: string;
202
+ loadOutcome: 'observed' | 'bounded-timeout';
203
+ deadlineExceeded: boolean;
204
+ loaderId?: string;
205
+ bounced: boolean;
206
+ routed: boolean;
207
+ settle: SettleFacts;
208
+ }
209
+
210
+ function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
211
+ const sections: FactLine[] = [
212
+ f.loadOutcome === 'observed'
213
+ ? fact`load: ${LOAD_EVENT_NAME} observed within the ${timing.innerTimeoutMs}ms load-wait window.`
214
+ : fact`load: ${LOAD_EVENT_NAME} not observed within the ${timing.innerTimeoutMs}ms load-wait window (bounded-timeout); the navigation still dispatched.`,
215
+ fact`navigation dispatched: loaderId ${f.loaderId ?? '(none)'}${f.bounced ? '; delivered via dest→about:blank→dest bounce (same-document target)' : ''}.`,
216
+ fact`settle: requested ${f.settle.requestedMs}ms, waited ${f.settle.waitedMs}ms.`,
217
+ ];
218
+ if (f.deadlineExceeded) {
219
+ sections.push(fact`navigation phase exceeded its ${timing.outerDeadlineMs / 1000}s deadline before completing.`);
220
+ }
221
+ if (f.routed) {
222
+ sections.push(
223
+ text`Page.navigate routed through the active recording with a labeled performance.mark; local console/HAR collectors skipped while routed — traffic streams into the session HAR via the recording's held connection.`,
224
+ );
225
+ }
226
+ return {
227
+ tag: 'navigated',
228
+ attrs: {
229
+ url: f.tabUrl,
230
+ settle: f.settle.requestedMs,
231
+ 'load-outcome': f.loadOutcome,
232
+ 'deadline-exceeded': f.deadlineExceeded || undefined,
233
+ routed: f.routed || undefined,
234
+ },
235
+ summary: fact`tab at ${f.tabUrl} after Page.navigate to ${f.requestedUrl}.`,
236
+ sections,
237
+ };
238
+ }
239
+
240
+ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Promise<void> {
241
+ if (parsed.help) {
242
+ console.log(USAGE);
243
+ return;
244
+ }
245
+
246
+ // Positional cardinality (exactly 1) is enforced by `validateCliInvocation`
247
+ // before dispatch reaches this leaf; this guard covers direct programmatic
248
+ // callers only. Failures cross the boundary as typed CaptureErrors —
249
+ // capture.ts is the sole renderer/exit-status owner.
250
+ const url = parsed.positional[0];
251
+ if (!url) {
252
+ throw invalidInput('received: no URL; expected: `capture page navigate <url> [--settle <ms>]`.', 'missing_url');
253
+ }
254
+ // Validate the destination BEFORE any effect (connect/send). connectForCommand
255
+ // never runs for an unparseable URL, so no tab is touched.
256
+ if (!isParseableUrl(url)) {
257
+ throw invalidInput(`received: \`${url}\`; expected: an absolute, WHATWG-parseable URL.`, 'invalid_url');
258
+ }
259
+
260
+ const settle = parsed.settle ?? DEFAULT_SETTLE_MS;
261
+
262
+ // Session admission is owned by dispatch.ts's `withActiveSessionAdmission`,
263
+ // which wraps every `page` command already — this leaf mirrors its
264
+ // siblings (click/type/scroll) and does NOT re-admit here.
265
+ //
266
+ // connection.ts derives the recorder landmark label from parsed.command,
267
+ // which the router leaves as the branch token 'page' — restore the verb
268
+ // so a routed navigate's landmark stays `navigate:<url>`.
269
+ //
270
+ // Connection/targeting failures from `connectForCommand` (e.g. its
271
+ // "Use --target/--url" or "No tab found" errors) surface with their own
272
+ // message; only a genuinely raw navigation dispatch failure is re-tagged as
273
+ // `navigate_failed` (scoped to the nav core below), so a valid-URL targeting
274
+ // miss is never mislabeled with a URL/browser hint that doesn't apply.
275
+ const { result: core, settle: settleFacts } = await withPageAction(
276
+ { ...parsed, command: 'navigate' },
277
+ { settleMs: settle },
278
+ async (client, _tab) => {
279
+ const routed = isRecorderHeldClient(client);
280
+ // Holder for the direct path's currently-armed load wait, so an
281
+ // outer-deadline abandonment can cancel it (clearing its inner timer).
282
+ const armed = { cancel: () => {} };
283
+ const work = routed
284
+ ? navigateViaRecorder(client as unknown as RecorderHeldClient, url)
285
+ : navigateDirect(client, url, armed);
286
+ let raced: Awaited<ReturnType<typeof raceNavDeadline>>;
287
+ try {
288
+ raced = await raceNavDeadline(work);
289
+ } catch (err) {
290
+ // A raw navigation dispatch failure — tag it. Typed capture errors
291
+ // (recorder/targeting) pass through unmodified.
292
+ if (err instanceof CaptureError) throw err;
293
+ throw captureError(
294
+ 'world',
295
+ 'navigate_failed',
296
+ `${err instanceof Error ? err.message : String(err)} — check the URL is absolute and a CDP-enabled browser is running (probe: capture tab list).`,
297
+ err,
298
+ );
299
+ }
300
+ if (raced.completed) {
301
+ return { ...raced.core, deadlineExceeded: false, routed, tabUrl: url };
302
+ }
303
+ // Deadline elapsed before the nav phase completed — cancel the armed
304
+ // load wait and report partial facts (load unconfirmed) rather than throwing.
305
+ armed.cancel();
306
+ return {
307
+ loadOutcome: 'bounded-timeout' as const,
308
+ loaderId: undefined,
309
+ bounced: false,
310
+ deadlineExceeded: true,
311
+ routed,
312
+ tabUrl: url,
313
+ };
314
+ },
315
+ );
316
+
317
+ emitResult(
318
+ buildNavigatedResult({
319
+ tabUrl: core.tabUrl,
320
+ requestedUrl: url,
321
+ loadOutcome: core.loadOutcome,
322
+ deadlineExceeded: core.deadlineExceeded,
323
+ loaderId: core.loaderId,
324
+ bounced: core.bounced,
325
+ routed: core.routed,
326
+ settle: settleFacts,
327
+ }),
328
+ { json: parsed.json },
329
+ );
330
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `page scroll <target> --to <top|bottom|px>` — scroll exactly one
3
+ * live-resolved container to a destination (design D3).
4
+ *
5
+ * Drives through `interact.ts`'s shared `scrollResolved` helper, passing a
6
+ * landmark label so a recorder-routed scroll's one mutating call
7
+ * (`Runtime.callFunctionOn`) lands the same labeled input landmark
8
+ * `motion rec --do scroll:` records.
9
+ */
10
+ import { type ParsedArgs } from '../../types.js';
11
+ import { isScrollDestination } from '../../leaf-grammar.js';
12
+ import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../interact.js';
13
+ import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
14
+ import {
15
+ pageInputDeps,
16
+ effectiveSettle,
17
+ emitInvalidInput,
18
+ emitResolutionError,
19
+ } from './click.js';
20
+
21
+ const USAGE = `capture page scroll <target> --to <top|bottom|px> — scroll one resolved container to a position
22
+
23
+ input:
24
+ <target> resolved against the LIVE page: bare CSS selector, 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
+ --to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
26
+ --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
27
+ --no-screenshot skip the auto-screenshot
28
+ output:
29
+ <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
30
+ effects:
31
+ 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`;
32
+
33
+ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promise<void> {
34
+ if (parsed.help) {
35
+ console.log(USAGE);
36
+ return;
37
+ }
38
+ const target = parsed.positional[0];
39
+ if (!target || parsed.positional.length !== 1) {
40
+ return emitInvalidInput(
41
+ parsed,
42
+ 'page scroll',
43
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`,
44
+ );
45
+ }
46
+ if (parsed.to === undefined) {
47
+ return emitInvalidInput(
48
+ parsed,
49
+ 'page scroll',
50
+ fact`received: no --to destination; expected --to <top|bottom|px>.`,
51
+ );
52
+ }
53
+ const to = parsed.to;
54
+ if (!isScrollDestination(to)) {
55
+ return emitInvalidInput(
56
+ parsed,
57
+ 'page scroll',
58
+ fact`received: --to \`${to}\`; expected top, bottom, or a pixel offset.`,
59
+ );
60
+ }
61
+
62
+ const deps = pageInputDeps();
63
+ const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
64
+ // connection.ts derives the recorder landmark label from parsed.command,
65
+ // which the router leaves as the branch token 'page' — restore the verb so
66
+ // a routed scroll's connection-level label stays `scroll:<target>`.
67
+ const { result: outcome, settle: settleFacts } = await deps.withPageAction(
68
+ { ...parsed, command: 'scroll' },
69
+ { settleMs: settle },
70
+ async (client) => {
71
+ const live = client as unknown as LiveClient;
72
+ const resolved = await resolveLiveTarget(live, target);
73
+ if (!resolved.ok) return { failure: resolved } as const;
74
+ // Same landmark shape `motion rec --do scroll:` records; carried by
75
+ // the one mutating call when the transport records landmarks.
76
+ const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
77
+ const screenshot = await deps.autoScreenshot(client, 'scroll', target, parsed.noScreenshot);
78
+ return { dispatch, screenshot } as const;
79
+ },
80
+ );
81
+
82
+ if ('failure' in outcome) {
83
+ return emitResolutionError(parsed, 'page scroll', outcome.failure);
84
+ }
85
+
86
+ const { dispatch, screenshot } = outcome;
87
+ const rows: FactLine[] = [
88
+ fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
89
+ fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
90
+ ];
91
+ if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
92
+
93
+ emitResult(
94
+ {
95
+ tag: 'scrolled',
96
+ attrs: {
97
+ 'backend-node-id': dispatch.backendNodeId,
98
+ role: dispatch.role ?? undefined,
99
+ name: dispatch.name ?? undefined,
100
+ to: dispatch.to,
101
+ 'scroll-top': dispatch.scrollTop,
102
+ },
103
+ summary: lineList(rows),
104
+ },
105
+ { json: parsed.json },
106
+ );
107
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `page shot` — capture a PNG of the tab as it is right now (design D2/D10).
3
+ *
4
+ * The navigational look: vision as navigator, never inspector — cheap, no
5
+ * settling, no collectors. With no flags it captures the browser's ACTUAL
6
+ * current viewport and performs zero `Emulation.*` calls; emulation exists
7
+ * only when explicitly asked (`--viewport <WxH>` / `--full-page`), applied
8
+ * as a transient `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
9
+ * and cleared after — two page-observable resizes, declared as the leaf's
10
+ * effect in `-h` (I-1/I-2 posture). Explicit viewport emulation uses the
11
+ * single `WxH` grammar (D10).
12
+ *
13
+ * Destination: `--out <path>` wins; otherwise the active session's `shots/`
14
+ * sequence; sessionless with no `--out` → a fresh `oneshot-{id}/page` dir
15
+ * under the capture root (never a loose /tmp file).
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { type ParsedArgs } from '../../types.js';
20
+ import { withConnection } from '../../connection.js';
21
+ import { captureScreenshot } from '../../screenshot.js';
22
+ import { parseViewport, type Viewport } from '../../viewport.js';
23
+ import { nextStepPath } from '../../../session-context.js';
24
+ import { createOneshotSession } from '../../../session/commands.js';
25
+ import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
26
+ import {
27
+ emitResult,
28
+ fact,
29
+ text,
30
+ type FactLine,
31
+ type RenderableResult,
32
+ } from '../../../output/render.js';
33
+
34
+ const USAGE = `capture page shot — capture a PNG of the tab as it is right now
35
+
36
+ input:
37
+ --viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
38
+ --full-page transient override to the full scrollable content height for this capture
39
+ --out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
40
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
41
+ --json mirror the result as JSON
42
+ output:
43
+ <screenshot path=… width=… height=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, byte size, and the emulation fact (whether a transient override was applied, or its absence)
44
+ effects:
45
+ no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Test-injectable dependency seam (the CDP-stub test pattern; the capture
49
+ // pipeline itself — captureScreenshot — is NOT injectable, so the tests
50
+ // prove the real CDP traffic against a stub client).
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface PageShotDeps {
54
+ withConnection: typeof withConnection;
55
+ nextStepPath: typeof nextStepPath;
56
+ createOneshotSession: typeof createOneshotSession;
57
+ }
58
+
59
+ let deps: PageShotDeps = { withConnection, nextStepPath, createOneshotSession };
60
+
61
+ /** Swap the connection/session seams for the CDP-stub tests. */
62
+ export function __setPageShotDepsForTest(overrides: Partial<PageShotDeps>): () => void {
63
+ const previous = deps;
64
+ deps = { ...deps, ...overrides };
65
+ return () => { deps = previous; };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // PNG dimensions — measured from the actual bytes written, not echoed input
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
73
+
74
+ /** Reads width/height from the IHDR chunk (always first per the PNG spec).
75
+ * Returns null for anything that is not a parseable PNG. */
76
+ export function pngDimensions(png: Buffer): { width: number; height: number } | null {
77
+ if (png.length < 24) return null;
78
+ if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
79
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Destination + private write
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Resolves the output path: explicit `--out` > active session's `shots/`
88
+ * sequence > a fresh `oneshot-{id}/page` dir under the capture root.
89
+ */
90
+ async function resolveOutPath(parsed: ParsedArgs): Promise<string> {
91
+ if (parsed.out) return parsed.out;
92
+ const sessionPath = await deps.nextStepPath('shot', 'manual');
93
+ if (sessionPath) return sessionPath;
94
+ return path.join(deps.createOneshotSession('page').artifactsDir, 'shot.png');
95
+ }
96
+
97
+ /**
98
+ * Writes the PNG privately (0600 under a 0700 dir) when it lands under
99
+ * CAPTURE_ROOT (auto-generated session/oneshot paths), and with a plain
100
+ * write when the user gave an explicit `--out` outside the capture tree —
101
+ * a user-chosen destination whose permissions are the user's to decide.
102
+ */
103
+ function writeScreenshot(outPath: string, png: Buffer): void {
104
+ try {
105
+ assertUnderCaptureRoot(outPath);
106
+ } catch {
107
+ fs.writeFileSync(outPath, png);
108
+ return;
109
+ }
110
+ writeBinaryPrivate(outPath, png);
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Result assembly
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export type EmulationMode = 'none' | 'viewport' | 'full-page';
118
+
119
+ export function buildScreenshotResult(f: {
120
+ path: string;
121
+ bytes: number;
122
+ dimensions: { width: number; height: number } | null;
123
+ emulation: EmulationMode;
124
+ viewport?: { width: number; height: number };
125
+ }): RenderableResult {
126
+ const sections: FactLine[] = [
127
+ f.emulation === 'none'
128
+ ? text`emulation: none — the browser's actual current viewport was captured; no Emulation call was made.`
129
+ : f.emulation === 'viewport'
130
+ ? fact`emulation: transient ${f.viewport!.width}x${f.viewport!.height} device-metrics override applied for the capture and cleared after — two page-observable resizes.`
131
+ : f.viewport
132
+ ? fact`emulation: transient full-page device-metrics override (from ${f.viewport.width}x${f.viewport.height}) applied for the capture and cleared after — two page-observable resizes.`
133
+ : text`emulation: transient full-page device-metrics override applied for the capture and cleared after — two page-observable resizes.`,
134
+ ];
135
+ return {
136
+ tag: 'screenshot',
137
+ attrs: {
138
+ path: f.path,
139
+ width: f.dimensions?.width,
140
+ height: f.dimensions?.height,
141
+ emulation: f.emulation,
142
+ },
143
+ summary: f.dimensions
144
+ ? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
145
+ : fact`saved ${f.path} — ${f.bytes} bytes.`,
146
+ sections,
147
+ };
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // page shot
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<void> {
155
+ if (parsed.help) {
156
+ console.log(USAGE);
157
+ return;
158
+ }
159
+
160
+ if (parsed.positional.length > 0) {
161
+ emitResult(
162
+ {
163
+ tag: 'error',
164
+ attrs: { command: 'page shot', code: 'invalid_input' },
165
+ summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--out <path>]\`.`,
166
+ },
167
+ { json: parsed.json },
168
+ );
169
+ process.exitCode = 1;
170
+ return;
171
+ }
172
+
173
+ let viewport: Viewport | undefined;
174
+ if (parsed.viewport !== undefined) {
175
+ try {
176
+ viewport = parseViewport(parsed.viewport);
177
+ } catch {
178
+ emitResult(
179
+ {
180
+ tag: 'error',
181
+ attrs: { command: 'page shot', code: 'invalid_viewport' },
182
+ summary: fact`received: --viewport ${parsed.viewport}; expected: <positive-safe-int>x<positive-safe-int> with exact lowercase x and no whitespace. Preset names are not accepted.`,
183
+ },
184
+ { json: parsed.json },
185
+ );
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ }
190
+
191
+ const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
192
+
193
+ // connection.ts derives the recorder-routed action label from
194
+ // parsed.command, which the router leaves as the branch token 'page' —
195
+ // restore the verb so stderr diagnostics identify this leaf.
196
+ const result = await deps.withConnection(
197
+ { ...parsed, command: 'shot' },
198
+ async (client) => {
199
+ const png = await captureScreenshot(client, viewport, { fullPage: parsed.fullPage });
200
+ const outPath = await resolveOutPath(parsed);
201
+ writeScreenshot(outPath, png);
202
+ return { path: outPath, bytes: png.length, dimensions: pngDimensions(png) };
203
+ },
204
+ { settle: 0 },
205
+ );
206
+
207
+ emitResult(buildScreenshotResult({ ...result, emulation, viewport }), { json: parsed.json });
208
+ }