@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
package/src/interact.ts
CHANGED
|
@@ -1,144 +1,456 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
role
|
|
24
|
-
name
|
|
25
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
35
|
-
client:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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] —
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
135
|
-
|
|
348
|
+
client: LiveClient,
|
|
349
|
+
resolved: ResolvedTarget,
|
|
136
350
|
text: string,
|
|
137
|
-
|
|
138
|
-
)
|
|
139
|
-
const
|
|
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
|
|
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
|
}
|