@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
package/src/interact.ts CHANGED
@@ -1,144 +1,456 @@
1
1
  /**
2
- * A11y-driven interactions — click and type using the accessibility tree
3
- * instead of brittle CSS selectors.
2
+ * Live target resolution + input dispatch — the one substrate every live
3
+ * driving verb (`page click|type|scroll`, `motion rec --do`) drives through.
4
4
  *
5
- * Pipeline: getFullAXTree → find by name/role → DOM.getBoxModel → Input.dispatchMouseEvent
5
+ * Targeting speaks the shared selector-input grammar
6
+ * (`src/output/selector.ts`'s `parseSelectorInput`) and resolves it against
7
+ * the LIVE page (not collector-stored records), per prefix:
8
+ *
9
+ * bare string live CSS query — `DOM.getDocument` + `DOM.querySelectorAll`
10
+ * backend:<id> identity — the canonical retry key in every resolution failure
11
+ * ax:<name> case-insensitive SUBSTRING over live `Accessibility.getFullAXTree` names
12
+ * axid:<id> AX node id from the same live fetch
13
+ * text:<needle> not accepted by driving verbs — typed rejection naming the accepted prefixes
14
+ *
15
+ * Cardinality contract: driving requires EXACTLY ONE match. Zero or many
16
+ * matches return a {@link ResolutionFailure} carrying the candidate list
17
+ * (role, name, `backendNodeId`) as the recovery payload. This module renders
18
+ * nothing — command leaves own all output; returned facts carry the resolved
19
+ * identity (`backendNodeId`, role, name) and the dispatched coordinates.
6
20
  *
7
21
  * Uses getFullAXTree instead of queryAXTree because queryAXTree hangs on
8
22
  * complex pages (7000+ AX nodes) when given the DOM root nodeId.
9
23
  */
10
24
 
11
- import type { CDPClient } from './cdp.js';
25
+ import { readFullAXTree, type FullAXNode } from './cdp/a11y.js';
26
+ import { isScrollDestination } from './cdp/leaf-grammar.js';
27
+ import { captureError } from './errors.js';
28
+ import { parseSelectorInput, type SelectorInputKind } from './output/selector.js';
29
+
30
+ /**
31
+ * The minimal CDP surface this module drives. `CDPClient` and
32
+ * `RecorderHeldClient` satisfy it structurally; `motion rec --do` adapts
33
+ * `RecorderSession.handleCdp` onto it.
34
+ */
35
+ export interface LiveClient {
36
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
37
+ /**
38
+ * Optional marked dispatch — transports that record labeled input
39
+ * landmarks (the recorder bridge's `mark` mechanism) expose it so a
40
+ * mutating call that is not auto-markable (`Runtime.callFunctionOn` in
41
+ * {@link scrollResolved}) can carry its landmark. When absent, a marked
42
+ * call degrades to a plain `send` (nothing is recording landmarks).
43
+ */
44
+ sendMarked?(method: string, params: Record<string, unknown>, mark: string): Promise<unknown>;
45
+ /**
46
+ * Optional recorder hook — suppresses the landmark on the next focus
47
+ * click's initiating edge so a routed `type --into` lands one landmark on
48
+ * its actual text insertion (see `RecorderHeldClient`).
49
+ */
50
+ suppressNextFocusClickMark?(): void;
51
+ }
52
+
53
+ /** Prefixes the live driving verbs accept (`text:` is query-leaf-only). */
54
+ export const ACCEPTED_LIVE_PREFIXES = ['css', 'ax:', 'axid:', 'backend:'] as const;
55
+
56
+ /** How many candidates a zero/many-match failure carries at most. */
57
+ const CANDIDATE_LIMIT = 10;
58
+
59
+ export type LiveTargetKind = Exclude<SelectorInputKind, 'text'>;
60
+
61
+ export interface ResolvedTarget {
62
+ readonly ok: true;
63
+ /** Which grammar prefix resolved it. */
64
+ readonly kind: LiveTargetKind;
65
+ readonly backendNodeId: number;
66
+ readonly role: string | null;
67
+ readonly name: string | null;
68
+ }
69
+
70
+ /** One zero/many-match recovery candidate — `backendNodeId` is the retry key. */
71
+ export interface TargetCandidate {
72
+ readonly backendNodeId: number;
73
+ readonly role: string | null;
74
+ readonly name: string | null;
75
+ }
76
+
77
+ export type ResolutionFailure =
78
+ | {
79
+ readonly ok: false;
80
+ readonly code: 'unsupported-prefix';
81
+ readonly input: string;
82
+ readonly acceptedPrefixes: typeof ACCEPTED_LIVE_PREFIXES;
83
+ }
84
+ | {
85
+ readonly ok: false;
86
+ readonly code: 'no-match' | 'ambiguous';
87
+ readonly input: string;
88
+ readonly kind: LiveTargetKind;
89
+ /** Total live matches (`candidates` is capped at {@link CANDIDATE_LIMIT}). */
90
+ readonly matchCount: number;
91
+ readonly candidates: readonly TargetCandidate[];
92
+ };
12
93
 
13
- interface ClickResult {
14
- x: number;
15
- y: number;
16
- role: string;
17
- name: string;
94
+ /** Facts of a dispatched click: resolved identity + click-center coordinates. */
95
+ export interface ClickDispatch {
96
+ readonly backendNodeId: number;
97
+ readonly role: string | null;
98
+ readonly name: string | null;
99
+ readonly x: number;
100
+ readonly y: number;
18
101
  }
19
102
 
20
- interface FullAXNode {
21
- nodeId: string;
22
- backendDOMNodeId?: number;
23
- role?: { value: string };
24
- name?: { value: string };
25
- childIds?: string[];
103
+ /** Facts of a dispatched scroll: resolved identity + where the container landed. */
104
+ export interface ScrollDispatch {
105
+ readonly backendNodeId: number;
106
+ readonly role: string | null;
107
+ readonly name: string | null;
108
+ /** The requested destination: `top`, `bottom`, or a pixel offset. */
109
+ readonly to: string;
110
+ /** The container's `scrollTop` after the dispatch. */
111
+ readonly scrollTop: number;
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Resolution
116
+ // ---------------------------------------------------------------------------
117
+
118
+ // Accessible-name whitespace is presentation-insignificant: browsers may
119
+ // preserve label indentation or a trailing space that users never see.
120
+ function normalizeAccessibleName(name: string): string {
121
+ return name.replace(/\s+/gu, ' ').trim().toLowerCase();
26
122
  }
27
123
 
28
124
  /**
29
- * Click an element by its accessible name. Optionally filter by ARIA role.
30
- *
31
- * Uses Accessibility.getFullAXTree to find elements by name/role,
32
- * then dispatches mouse events at the element's center coordinates.
125
+ * Resolves a unified-grammar target string against the live page. Exactly
126
+ * one match resolves; zero/many matches and `text:` targets return a typed
127
+ * {@link ResolutionFailure} for the calling leaf to render.
33
128
  */
34
- export async function clickByName(
35
- client: CDPClient,
36
- name: string,
37
- role?: string,
38
- ): Promise<ClickResult> {
39
- await client.send('Accessibility.enable');
129
+ export async function resolveLiveTarget(
130
+ client: LiveClient,
131
+ input: string,
132
+ ): Promise<ResolvedTarget | ResolutionFailure> {
133
+ const parsed = parseSelectorInput(input);
134
+ switch (parsed.kind) {
135
+ case 'text':
136
+ return { ok: false, code: 'unsupported-prefix', input, acceptedPrefixes: ACCEPTED_LIVE_PREFIXES };
137
+ case 'backend': {
138
+ const id = parseBackendNodeId(parsed.value);
139
+ if (id === null || !(await isLiveBackendNode(client, id))) {
140
+ return { ok: false, code: 'no-match', input, kind: 'backend', matchCount: 0, candidates: [] };
141
+ }
142
+ const { role, name } = await axIdentityFor(client, id);
143
+ return { ok: true, kind: 'backend', backendNodeId: id, role, name };
144
+ }
145
+ case 'ax': {
146
+ const nodes = await readFullAXTree(client);
147
+ const needle = normalizeAccessibleName(parsed.value);
148
+ const matches = nodes.filter(
149
+ (n) =>
150
+ n.backendDOMNodeId !== undefined &&
151
+ n.name?.value !== undefined &&
152
+ normalizeAccessibleName(n.name.value).includes(needle),
153
+ );
154
+ return settleAxMatches('ax', input, matches);
155
+ }
156
+ case 'axid': {
157
+ const nodes = await readFullAXTree(client);
158
+ const matches = nodes.filter((n) => n.nodeId === parsed.value && n.backendDOMNodeId !== undefined);
159
+ return settleAxMatches('axid', input, matches);
160
+ }
161
+ case 'css':
162
+ return resolveLiveCss(client, input, parsed.value);
163
+ }
164
+ }
165
+
166
+ function settleAxMatches(
167
+ kind: LiveTargetKind,
168
+ input: string,
169
+ matches: FullAXNode[],
170
+ ): ResolvedTarget | ResolutionFailure {
171
+ if (matches.length === 1) {
172
+ const m = matches[0];
173
+ return {
174
+ ok: true,
175
+ kind,
176
+ backendNodeId: m.backendDOMNodeId!,
177
+ role: m.role?.value ?? null,
178
+ name: m.name?.value ?? null,
179
+ };
180
+ }
181
+ return {
182
+ ok: false,
183
+ code: matches.length === 0 ? 'no-match' : 'ambiguous',
184
+ input,
185
+ kind,
186
+ matchCount: matches.length,
187
+ candidates: matches.slice(0, CANDIDATE_LIMIT).map((m) => ({
188
+ backendNodeId: m.backendDOMNodeId!,
189
+ role: m.role?.value ?? null,
190
+ name: m.name?.value ?? null,
191
+ })),
192
+ };
193
+ }
194
+
195
+ async function resolveLiveCss(
196
+ client: LiveClient,
197
+ input: string,
198
+ selector: string,
199
+ ): Promise<ResolvedTarget | ResolutionFailure> {
40
200
  await client.send('DOM.enable');
201
+ const { root } = (await client.send('DOM.getDocument', { depth: 0 })) as { root: { nodeId: number } };
202
+ const { nodeIds } = (await client.send('DOM.querySelectorAll', {
203
+ nodeId: root.nodeId,
204
+ selector,
205
+ })) as { nodeIds: number[] };
206
+
207
+ if (nodeIds.length === 1) {
208
+ const backendNodeId = await backendNodeIdFor(client, nodeIds[0]);
209
+ const { role, name } = await axIdentityFor(client, backendNodeId);
210
+ return { ok: true, kind: 'css', backendNodeId, role, name };
211
+ }
41
212
 
42
- const { nodes } = (await client.send('Accessibility.getFullAXTree')) as {
43
- nodes: FullAXNode[];
213
+ const candidates = await Promise.all(
214
+ nodeIds.slice(0, CANDIDATE_LIMIT).map(async (nodeId): Promise<TargetCandidate> => {
215
+ const backendNodeId = await backendNodeIdFor(client, nodeId);
216
+ const { role, name } = await axIdentityFor(client, backendNodeId);
217
+ return { backendNodeId, role, name };
218
+ }),
219
+ );
220
+ return {
221
+ ok: false,
222
+ code: nodeIds.length === 0 ? 'no-match' : 'ambiguous',
223
+ input,
224
+ kind: 'css',
225
+ matchCount: nodeIds.length,
226
+ candidates,
44
227
  };
45
- await client.send('Accessibility.disable');
46
-
47
- // Find nodes matching the accessible name (exact match)
48
- const nameLower = name.toLowerCase();
49
- const matches = nodes.filter((n) => {
50
- if (!n.backendDOMNodeId) return false;
51
- if (!n.name?.value) return false;
52
- if (n.name.value.toLowerCase() !== nameLower) return false;
53
- if (role && n.role?.value !== role) return false;
54
- return true;
55
- });
56
-
57
- if (matches.length === 0) {
58
- throw new Error(
59
- `No element found with accessible name "${name}"${role ? ` and role "${role}"` : ''}. ` +
60
- 'Run `capture a11y --interactive` to see available elements.',
61
- );
228
+ }
229
+
230
+ /** The 8-number `[x1,y1 … x4,y4]` content quad a click center derives from. */
231
+ function isContentQuad(value: unknown): value is number[] {
232
+ return Array.isArray(value) && value.length >= 8 && value.slice(0, 8).every((n) => typeof n === 'number' && Number.isFinite(n));
233
+ }
234
+
235
+ async function backendNodeIdFor(client: LiveClient, nodeId: number): Promise<number> {
236
+ const { node } = (await client.send('DOM.describeNode', { nodeId })) as { node: { backendNodeId: number } };
237
+ return node.backendNodeId;
238
+ }
239
+
240
+ function parseBackendNodeId(value: string): number | null {
241
+ if (!/^\d+$/.test(value)) return null;
242
+ const id = Number(value);
243
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
244
+ }
245
+
246
+ async function isLiveBackendNode(client: LiveClient, backendNodeId: number): Promise<boolean> {
247
+ try {
248
+ const response = await client.send('DOM.describeNode', { backendNodeId, depth: 0 });
249
+ if (response === null || typeof response !== 'object' || Array.isArray(response)) return false;
250
+ const node = (response as { node?: unknown }).node;
251
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) return false;
252
+ return (node as { backendNodeId?: unknown }).backendNodeId === backendNodeId;
253
+ } catch {
254
+ return false;
62
255
  }
256
+ }
63
257
 
64
- if (matches.length > 1 && !role) {
65
- // Auto-prefer input-like roles for better type --into ergonomics
66
- const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'textarea', 'spinbutton']);
67
- const inputMatches = matches.filter((n) => n.role?.value && INPUT_ROLES.has(n.role.value));
68
- if (inputMatches.length === 1) {
69
- // Unambiguous input element — use it
70
- matches.length = 0;
71
- matches.push(inputMatches[0]);
72
- } else {
73
- const summary = matches
74
- .slice(0, 5)
75
- .map((n) => ` ${n.role?.value} "${n.name?.value}"`)
76
- .join('\n');
77
- throw new Error(
78
- `Multiple elements match "${name}" — disambiguate with --role:\n${summary}`,
79
- );
80
- }
258
+ /**
259
+ * Best-effort AX role/name for a live backend node id via a one-shot
260
+ * `Accessibility.getPartialAXTree`. A node with no AX presence still resolves
261
+ * with null enrichment.
262
+ */
263
+ async function axIdentityFor(
264
+ client: LiveClient,
265
+ backendNodeId: number,
266
+ ): Promise<{ role: string | null; name: string | null }> {
267
+ try {
268
+ const { nodes } = (await client.send('Accessibility.getPartialAXTree', {
269
+ backendNodeId,
270
+ fetchRelatives: false,
271
+ })) as { nodes?: FullAXNode[] };
272
+ const node = nodes?.find((n) => n.backendDOMNodeId === backendNodeId) ?? nodes?.[0];
273
+ return { role: node?.role?.value ?? null, name: node?.name?.value ?? null };
274
+ } catch {
275
+ return { role: null, name: null };
81
276
  }
277
+ }
82
278
 
83
- const match = matches[0];
84
- const backendNodeId = match.backendDOMNodeId!;
279
+ // ---------------------------------------------------------------------------
280
+ // Dispatch
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Clicks a resolved target: scroll into view → box model → center-point
285
+ * `Input.dispatchMouseEvent` press/release pair. When the caller supplies
286
+ * `opts.mark` and the transport records landmarks
287
+ * ({@link LiveClient.sendMarked}), the INITIATING PRESS alone carries the
288
+ * label — one logical click is one landmark, never two (the release and every
289
+ * incidental call stay unmarked). Without a marked lane the press degrades to
290
+ * a plain `send` (nothing is recording landmarks).
291
+ */
292
+ export async function clickResolved(
293
+ client: LiveClient,
294
+ resolved: ResolvedTarget,
295
+ opts: { mark?: string } = {},
296
+ ): Promise<ClickDispatch> {
297
+ const { backendNodeId } = resolved;
85
298
 
86
- // Scroll into view
87
299
  await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId });
88
300
 
89
- // Get box model for click coordinates
90
- const { model } = (await client.send('DOM.getBoxModel', { backendNodeId })) as {
91
- model: { content: number[] };
92
- };
301
+ const boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as
302
+ | { model?: { content?: unknown } }
303
+ | undefined;
93
304
 
94
- // Content quad is [x1,y1, x2,y2, x3,y3, x4,y4] — calculate center
95
- const quad = model.content;
305
+ // Content quad is [x1,y1, x2,y2, x3,y3, x4,y4] — validated here, once, for
306
+ // every click consumer (page click and motion rec's one-shot alike).
307
+ const quad = boxResponse?.model?.content;
308
+ if (!isContentQuad(quad)) {
309
+ throw captureError(
310
+ 'world',
311
+ 'target_not_clickable',
312
+ `Resolved target backend:${backendNodeId} has no box model to click.`,
313
+ { method: 'DOM.getBoxModel', response: boxResponse },
314
+ );
315
+ }
96
316
  const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
97
317
  const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
98
318
 
99
- // Click: mousePressed + mouseReleased
100
- await client.send('Input.dispatchMouseEvent', {
101
- type: 'mousePressed',
102
- x,
103
- y,
104
- button: 'left',
105
- clickCount: 1,
106
- });
107
- await client.send('Input.dispatchMouseEvent', {
108
- type: 'mouseReleased',
109
- x,
110
- y,
111
- button: 'left',
112
- clickCount: 1,
113
- });
319
+ const pressParams = { type: 'mousePressed', x, y, button: 'left', clickCount: 1 };
320
+ if (opts.mark !== undefined && client.sendMarked) {
321
+ await client.sendMarked('Input.dispatchMouseEvent', pressParams, opts.mark);
322
+ } else {
323
+ await client.send('Input.dispatchMouseEvent', pressParams);
324
+ }
325
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
114
326
 
115
327
  return {
328
+ backendNodeId,
329
+ role: resolved.role,
330
+ name: resolved.name,
116
331
  x: Math.round(x),
117
332
  y: Math.round(y),
118
- role: match.role?.value ?? 'unknown',
119
- name: match.name?.value ?? name,
120
333
  };
121
334
  }
122
335
 
123
- /**
124
- * Type text into the currently focused element via CDP Input.insertText.
125
- */
126
- export async function typeText(client: CDPClient, text: string): Promise<void> {
336
+ /** Types text into the currently focused element via CDP `Input.insertText`. */
337
+ export async function typeText(client: LiveClient, text: string): Promise<void> {
127
338
  await client.send('Input.insertText', { text });
128
339
  }
129
340
 
130
341
  /**
131
- * Click a field by accessible name, then type text into it.
342
+ * Clicks a resolved field to focus it, then types text into it. The focus
343
+ * click's landmark is suppressed (a recorder-routed `type` has one
344
+ * command-level landmark: its insertion — the focus click is a
345
+ * prerequisite, not a second action).
132
346
  */
133
347
  export async function focusAndType(
134
- client: CDPClient,
135
- fieldName: string,
348
+ client: LiveClient,
349
+ resolved: ResolvedTarget,
136
350
  text: string,
137
- role?: string,
138
- ): Promise<ClickResult> {
139
- const result = await clickByName(client, fieldName, role);
351
+ ): Promise<ClickDispatch> {
352
+ client.suppressNextFocusClickMark?.();
353
+ const dispatch = await clickResolved(client, resolved);
140
354
  // Small delay for focus to settle
141
355
  await new Promise((r) => setTimeout(r, 100));
142
356
  await typeText(client, text);
143
- return result;
357
+ return dispatch;
358
+ }
359
+
360
+ /**
361
+ * Scrolls a resolved container to `top`, `bottom`, or a pixel offset by
362
+ * assigning its `scrollTop` — `DOM.resolveNode` + `Runtime.callFunctionOn`
363
+ * with the destination passed as data, never concatenated as code. When the
364
+ * caller supplies `opts.mark` and the transport records landmarks
365
+ * ({@link LiveClient.sendMarked}), the one mutating call carries the label —
366
+ * the recorder landmark behavior `motion rec --do scroll:` shares.
367
+ */
368
+ export async function scrollResolved(
369
+ client: LiveClient,
370
+ resolved: ResolvedTarget,
371
+ to: string,
372
+ opts: { mark?: string } = {},
373
+ ): Promise<ScrollDispatch> {
374
+ if (!isScrollDestination(to)) {
375
+ throw captureError(
376
+ 'invocation',
377
+ 'invalid_scroll_destination',
378
+ `Invalid scroll destination "${to}" — expected top, bottom, or a pixel offset.`,
379
+ );
380
+ }
381
+
382
+ const { backendNodeId } = resolved;
383
+ const { object } = (await client.send('DOM.resolveNode', { backendNodeId })) as {
384
+ object?: { objectId?: string };
385
+ };
386
+ const objectId = object?.objectId;
387
+ if (!objectId) {
388
+ throw captureError(
389
+ 'world',
390
+ 'target_not_scrollable',
391
+ `Could not resolve backend node ${backendNodeId} to a live object for scrolling.`,
392
+ );
393
+ }
394
+
395
+ // The runtime object is owned from this line on: every post-resolution exit
396
+ // (success, transport rejection, in-page exception, malformed payload —
397
+ // marked or unmarked) releases it exactly once in the finally below, so a
398
+ // held recorder connection never accumulates leaked handles.
399
+ let primaryFailed = false;
400
+ let primaryError: unknown;
401
+ try {
402
+ const params: Record<string, unknown> = {
403
+ objectId,
404
+ functionDeclaration:
405
+ 'function(to) { const n = to === "top" ? 0 : to === "bottom" ? this.scrollHeight : Number(to); this.scrollTop = n; return this.scrollTop; }',
406
+ arguments: [{ value: to }],
407
+ returnByValue: true,
408
+ };
409
+ const result = (
410
+ opts.mark !== undefined && client.sendMarked
411
+ ? await client.sendMarked('Runtime.callFunctionOn', params, opts.mark)
412
+ : await client.send('Runtime.callFunctionOn', params)
413
+ ) as { result?: { value?: unknown }; exceptionDetails?: { text?: string } } | undefined;
414
+
415
+ if (result?.exceptionDetails) {
416
+ throw captureError(
417
+ 'world',
418
+ 'scroll_dispatch_failed',
419
+ `Scroll dispatch threw in-page: ${result.exceptionDetails.text ?? 'unknown error'}.`,
420
+ );
421
+ }
422
+ const scrollTop = result?.result?.value;
423
+ if (typeof scrollTop !== 'number') {
424
+ throw captureError('world', 'malformed_protocol', 'Scroll did not return a valid scrollTop payload.', {
425
+ method: 'Runtime.callFunctionOn',
426
+ response: result,
427
+ });
428
+ }
429
+
430
+ return { backendNodeId, role: resolved.role, name: resolved.name, to, scrollTop };
431
+ } catch (error) {
432
+ primaryFailed = true;
433
+ primaryError = error;
434
+ throw error;
435
+ } finally {
436
+ // Release is incidental cleanup, never a landmark — always the plain lane.
437
+ try {
438
+ await client.send('Runtime.releaseObject', { objectId });
439
+ } catch (error) {
440
+ const cleanupError = captureError(
441
+ 'cleanup',
442
+ 'scroll_object_release_failed',
443
+ `Runtime.releaseObject failed for the scroll container object of backend node ${backendNodeId}.`,
444
+ error,
445
+ );
446
+ if (primaryFailed) {
447
+ throw new AggregateError(
448
+ [primaryError, cleanupError],
449
+ 'Scroll dispatch failed and runtime-object release also failed.',
450
+ { cause: primaryError },
451
+ );
452
+ }
453
+ throw cleanupError;
454
+ }
455
+ }
144
456
  }