browser-pilot 0.0.11 → 0.0.13
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/README.md +44 -8
- package/dist/actions.cjs +686 -32
- package/dist/actions.d.cts +3 -3
- package/dist/actions.d.ts +3 -3
- package/dist/actions.mjs +2 -1
- package/dist/browser.cjs +3415 -2324
- package/dist/browser.d.cts +9 -3
- package/dist/browser.d.ts +9 -3
- package/dist/browser.mjs +4 -3
- package/dist/cdp.cjs +19 -4
- package/dist/cdp.d.cts +1 -1
- package/dist/cdp.d.ts +1 -1
- package/dist/cdp.mjs +4 -2
- package/dist/chunk-A2ZRAEO3.mjs +1711 -0
- package/dist/{chunk-BCOZUKWS.mjs → chunk-HP6R3W32.mjs} +22 -16
- package/dist/chunk-JXAUPHZM.mjs +15 -0
- package/dist/{chunk-JHAF52FA.mjs → chunk-VDAMDOS6.mjs} +1014 -738
- package/dist/cli.mjs +4998 -3259
- package/dist/{client-7Nqka5MV.d.ts → client-DRqxBdHv.d.cts} +1 -1
- package/dist/{client-7Nqka5MV.d.cts → client-DRqxBdHv.d.ts} +1 -1
- package/dist/index.cjs +4555 -3314
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.mjs +6 -4
- package/dist/{types-GWuQJs_e.d.cts → types-BXMGFtnB.d.cts} +96 -9
- package/dist/{types-DtGF3yGl.d.ts → types-CzgQjai9.d.ts} +96 -9
- package/package.json +6 -2
- package/dist/chunk-FAUNIZR7.mjs +0 -751
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CDPError
|
|
3
|
+
} from "./chunk-JXAUPHZM.mjs";
|
|
4
|
+
|
|
5
|
+
// src/browser/actionability.ts
|
|
6
|
+
var ActionabilityError = class extends Error {
|
|
7
|
+
failureType;
|
|
8
|
+
coveringElement;
|
|
9
|
+
constructor(message, failureType, coveringElement) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ActionabilityError";
|
|
12
|
+
this.failureType = failureType;
|
|
13
|
+
this.coveringElement = coveringElement;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var CHECK_VISIBLE = `function() {
|
|
17
|
+
// checkVisibility handles display:none, visibility:hidden, content-visibility up the tree
|
|
18
|
+
if (typeof this.checkVisibility === 'function' && !this.checkVisibility()) {
|
|
19
|
+
return { actionable: false, reason: 'Element is not visible (checkVisibility failed). Try scrolling or check if a prior action is needed to reveal it.' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var style = getComputedStyle(this);
|
|
23
|
+
|
|
24
|
+
if (style.visibility !== 'visible') {
|
|
25
|
+
return { actionable: false, reason: 'Element has visibility: ' + style.visibility + '. Try scrolling or check if a prior action is needed to reveal it.' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// display:contents elements have no box themselves \u2014 check children
|
|
29
|
+
if (style.display === 'contents') {
|
|
30
|
+
var children = this.children;
|
|
31
|
+
if (children.length === 0) {
|
|
32
|
+
return { actionable: false, reason: 'Element has display:contents with no children. Try scrolling or check if a prior action is needed to reveal it.' };
|
|
33
|
+
}
|
|
34
|
+
for (var i = 0; i < children.length; i++) {
|
|
35
|
+
var childRect = children[i].getBoundingClientRect();
|
|
36
|
+
if (childRect.width > 0 && childRect.height > 0) {
|
|
37
|
+
return { actionable: true };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { actionable: false, reason: 'Element has display:contents but no visible children. Try scrolling or check if a prior action is needed to reveal it.' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
var rect = this.getBoundingClientRect();
|
|
44
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
45
|
+
return { actionable: false, reason: 'Element has zero size (' + rect.width + 'x' + rect.height + '). Try scrolling or check if a prior action is needed to reveal it.' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { actionable: true };
|
|
49
|
+
}`;
|
|
50
|
+
var CHECK_ENABLED = `function() {
|
|
51
|
+
// Native disabled property
|
|
52
|
+
var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
|
|
53
|
+
if (disableable.indexOf(this.tagName) !== -1 && this.disabled) {
|
|
54
|
+
return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check ancestor FIELDSET[disabled]
|
|
58
|
+
var parent = this.parentElement;
|
|
59
|
+
while (parent) {
|
|
60
|
+
if (parent.tagName === 'FIELDSET' && parent.disabled) {
|
|
61
|
+
// Exception: elements inside the first <legend> of a disabled fieldset are NOT disabled
|
|
62
|
+
var legend = parent.querySelector(':scope > legend');
|
|
63
|
+
if (!legend || !legend.contains(this)) {
|
|
64
|
+
return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
parent = parent.parentElement;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// aria-disabled="true" walking up ancestor chain (crosses shadow DOM)
|
|
71
|
+
var node = this;
|
|
72
|
+
while (node) {
|
|
73
|
+
if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
|
|
74
|
+
return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
|
|
75
|
+
}
|
|
76
|
+
if (node.parentElement) {
|
|
77
|
+
node = node.parentElement;
|
|
78
|
+
} else if (node.getRootNode && node.getRootNode() !== node) {
|
|
79
|
+
// Cross shadow DOM boundary
|
|
80
|
+
var root = node.getRootNode();
|
|
81
|
+
node = root.host || null;
|
|
82
|
+
} else {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { actionable: true };
|
|
88
|
+
}`;
|
|
89
|
+
var CHECK_STABLE = `function() {
|
|
90
|
+
var self = this;
|
|
91
|
+
return new Promise(function(resolve) {
|
|
92
|
+
// If tab is backgrounded, RAF won't fire reliably \u2014 skip stability check
|
|
93
|
+
if (document.visibilityState === 'hidden') {
|
|
94
|
+
var rect = self.getBoundingClientRect();
|
|
95
|
+
resolve({ actionable: rect.width > 0 && rect.height > 0 });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var maxFrames = 30;
|
|
100
|
+
var prev = null;
|
|
101
|
+
var frame = 0;
|
|
102
|
+
var resolved = false;
|
|
103
|
+
|
|
104
|
+
var fallbackTimer = setTimeout(function() {
|
|
105
|
+
if (!resolved) {
|
|
106
|
+
resolved = true;
|
|
107
|
+
resolve({ actionable: false, reason: 'Element stability check timed out (tab may be backgrounded)' });
|
|
108
|
+
}
|
|
109
|
+
}, 2000);
|
|
110
|
+
|
|
111
|
+
function check() {
|
|
112
|
+
if (resolved) return;
|
|
113
|
+
frame++;
|
|
114
|
+
if (frame > maxFrames) {
|
|
115
|
+
resolved = true;
|
|
116
|
+
clearTimeout(fallbackTimer);
|
|
117
|
+
resolve({ actionable: false, reason: 'Element position not stable after ' + maxFrames + ' frames' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
var rect = self.getBoundingClientRect();
|
|
122
|
+
var cur = { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
|
123
|
+
|
|
124
|
+
if (prev !== null &&
|
|
125
|
+
prev.x === cur.x && prev.y === cur.y &&
|
|
126
|
+
prev.w === cur.w && prev.h === cur.h) {
|
|
127
|
+
resolved = true;
|
|
128
|
+
clearTimeout(fallbackTimer);
|
|
129
|
+
resolve({ actionable: true });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
prev = cur;
|
|
134
|
+
requestAnimationFrame(check);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
requestAnimationFrame(check);
|
|
138
|
+
});
|
|
139
|
+
}`;
|
|
140
|
+
var CHECK_HIT_TARGET = `function(x, y) {
|
|
141
|
+
// Compute click center if coordinates not provided
|
|
142
|
+
if (x === undefined || y === undefined) {
|
|
143
|
+
var rect = this.getBoundingClientRect();
|
|
144
|
+
x = rect.x + rect.width / 2;
|
|
145
|
+
y = rect.y + rect.height / 2;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkPoint(root, px, py) {
|
|
149
|
+
var method = root.elementsFromPoint || root.msElementsFromPoint;
|
|
150
|
+
if (!method) return [];
|
|
151
|
+
return method.call(root, px, py) || [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Follow only the top-most hit through nested shadow roots.
|
|
155
|
+
// Accepting any hit in the stack creates false positives for covered elements.
|
|
156
|
+
var root = document;
|
|
157
|
+
var topHits = [];
|
|
158
|
+
var seenRoots = [];
|
|
159
|
+
while (root && seenRoots.indexOf(root) === -1) {
|
|
160
|
+
seenRoots.push(root);
|
|
161
|
+
var hits = checkPoint(root, x, y);
|
|
162
|
+
if (!hits.length) break;
|
|
163
|
+
var top = hits[0];
|
|
164
|
+
topHits.push(top);
|
|
165
|
+
if (top && top.shadowRoot) {
|
|
166
|
+
root = top.shadowRoot;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Target must be the top-most hit element or an ancestor/descendant
|
|
173
|
+
for (var j = 0; j < topHits.length; j++) {
|
|
174
|
+
var hit = topHits[j];
|
|
175
|
+
if (hit === this || this.contains(hit) || hit.contains(this)) {
|
|
176
|
+
return { actionable: true };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Report the covering element
|
|
181
|
+
var top = topHits.length > 0 ? topHits[topHits.length - 1] : null;
|
|
182
|
+
if (top) {
|
|
183
|
+
return {
|
|
184
|
+
actionable: false,
|
|
185
|
+
reason: 'Element is covered by <' + top.tagName.toLowerCase() + '>' +
|
|
186
|
+
(top.id ? '#' + top.id : '') +
|
|
187
|
+
(top.className && typeof top.className === 'string' ? '.' + top.className.split(' ').join('.') : '') +
|
|
188
|
+
'. Try dismissing overlays first.',
|
|
189
|
+
coveringElement: {
|
|
190
|
+
tag: top.tagName.toLowerCase(),
|
|
191
|
+
id: top.id || undefined,
|
|
192
|
+
className: (typeof top.className === 'string' && top.className) || undefined
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { actionable: false, reason: 'No element found at click point (' + x + ', ' + y + '). Try scrolling the element into view first.' };
|
|
198
|
+
}`;
|
|
199
|
+
var CHECK_EDITABLE = `function() {
|
|
200
|
+
// Must be an editable element type
|
|
201
|
+
var tag = this.tagName;
|
|
202
|
+
var isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ||
|
|
203
|
+
this.isContentEditable;
|
|
204
|
+
if (!isEditable) {
|
|
205
|
+
return { actionable: false, reason: 'Element is not an editable type (<' + tag.toLowerCase() + '>). Target an <input>, <textarea>, <select>, or [contenteditable] element instead.' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check disabled
|
|
209
|
+
var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
|
|
210
|
+
if (disableable.indexOf(tag) !== -1 && this.disabled) {
|
|
211
|
+
return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check ancestor FIELDSET[disabled]
|
|
215
|
+
var parent = this.parentElement;
|
|
216
|
+
while (parent) {
|
|
217
|
+
if (parent.tagName === 'FIELDSET' && parent.disabled) {
|
|
218
|
+
var legend = parent.querySelector(':scope > legend');
|
|
219
|
+
if (!legend || !legend.contains(this)) {
|
|
220
|
+
return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
parent = parent.parentElement;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// aria-disabled walking up (crosses shadow DOM)
|
|
227
|
+
var node = this;
|
|
228
|
+
while (node) {
|
|
229
|
+
if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
|
|
230
|
+
return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
|
|
231
|
+
}
|
|
232
|
+
if (node.parentElement) {
|
|
233
|
+
node = node.parentElement;
|
|
234
|
+
} else if (node.getRootNode && node.getRootNode() !== node) {
|
|
235
|
+
var root = node.getRootNode();
|
|
236
|
+
node = root.host || null;
|
|
237
|
+
} else {
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Check readonly
|
|
243
|
+
if (this.hasAttribute && this.hasAttribute('readonly')) {
|
|
244
|
+
return { actionable: false, reason: 'Cannot fill a readonly input. Remove the readonly attribute or target a different element.' };
|
|
245
|
+
}
|
|
246
|
+
if (this.getAttribute && this.getAttribute('aria-readonly') === 'true') {
|
|
247
|
+
return { actionable: false, reason: 'Cannot fill a readonly input (aria-readonly="true"). Remove the attribute or target a different element.' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { actionable: true };
|
|
251
|
+
}`;
|
|
252
|
+
function sleep(ms) {
|
|
253
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
|
+
}
|
|
255
|
+
var BACKOFF = [0, 20, 100, 100];
|
|
256
|
+
async function runCheck(cdp, objectId, check, options) {
|
|
257
|
+
let script;
|
|
258
|
+
let awaitPromise = false;
|
|
259
|
+
const args = [];
|
|
260
|
+
switch (check) {
|
|
261
|
+
case "visible":
|
|
262
|
+
script = CHECK_VISIBLE;
|
|
263
|
+
break;
|
|
264
|
+
case "enabled":
|
|
265
|
+
script = CHECK_ENABLED;
|
|
266
|
+
break;
|
|
267
|
+
case "stable":
|
|
268
|
+
script = CHECK_STABLE;
|
|
269
|
+
awaitPromise = true;
|
|
270
|
+
break;
|
|
271
|
+
case "hitTarget":
|
|
272
|
+
script = CHECK_HIT_TARGET;
|
|
273
|
+
if (options?.coordinates) {
|
|
274
|
+
args.push({ value: options.coordinates.x });
|
|
275
|
+
args.push({ value: options.coordinates.y });
|
|
276
|
+
} else {
|
|
277
|
+
args.push({ value: void 0 });
|
|
278
|
+
args.push({ value: void 0 });
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
case "editable":
|
|
282
|
+
script = CHECK_EDITABLE;
|
|
283
|
+
break;
|
|
284
|
+
default: {
|
|
285
|
+
const _exhaustive = check;
|
|
286
|
+
throw new Error(`Unknown actionability check: ${_exhaustive}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const params = {
|
|
290
|
+
functionDeclaration: script,
|
|
291
|
+
objectId,
|
|
292
|
+
returnByValue: true,
|
|
293
|
+
arguments: args
|
|
294
|
+
};
|
|
295
|
+
if (awaitPromise) {
|
|
296
|
+
params["awaitPromise"] = true;
|
|
297
|
+
}
|
|
298
|
+
const response = await cdp.send("Runtime.callFunctionOn", params);
|
|
299
|
+
if (response.exceptionDetails) {
|
|
300
|
+
return {
|
|
301
|
+
actionable: false,
|
|
302
|
+
reason: `Check "${check}" threw: ${response.exceptionDetails.text}`,
|
|
303
|
+
failureType: check
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const result = response.result.value;
|
|
307
|
+
if (!result.actionable) {
|
|
308
|
+
result.failureType = check;
|
|
309
|
+
}
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
async function runChecks(cdp, objectId, checks, options) {
|
|
313
|
+
for (const check of checks) {
|
|
314
|
+
const result = await runCheck(cdp, objectId, check, options);
|
|
315
|
+
if (!result.actionable) {
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { actionable: true };
|
|
320
|
+
}
|
|
321
|
+
async function ensureActionable(cdp, objectId, checks, options) {
|
|
322
|
+
const timeout = options?.timeout ?? 3e4;
|
|
323
|
+
const start = Date.now();
|
|
324
|
+
let attempt = 0;
|
|
325
|
+
while (true) {
|
|
326
|
+
const result = await runChecks(cdp, objectId, checks, options);
|
|
327
|
+
if (result.actionable) return;
|
|
328
|
+
if (Date.now() - start >= timeout) {
|
|
329
|
+
throw new ActionabilityError(
|
|
330
|
+
`Element not actionable: ${result.reason}`,
|
|
331
|
+
result.failureType,
|
|
332
|
+
result.coveringElement
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const delay = attempt < BACKOFF.length ? BACKOFF[attempt] ?? 0 : 500;
|
|
336
|
+
if (delay > 0) await sleep(delay);
|
|
337
|
+
attempt++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/browser/fuzzy-match.ts
|
|
342
|
+
function jaroWinkler(a, b) {
|
|
343
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
344
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
345
|
+
if (a === b) return 1;
|
|
346
|
+
const s1 = a.toLowerCase();
|
|
347
|
+
const s2 = b.toLowerCase();
|
|
348
|
+
const matchWindow = Math.max(0, Math.floor(Math.max(s1.length, s2.length) / 2) - 1);
|
|
349
|
+
const s1Matches = Array.from({ length: s1.length }, () => false);
|
|
350
|
+
const s2Matches = Array.from({ length: s2.length }, () => false);
|
|
351
|
+
let matches = 0;
|
|
352
|
+
let transpositions = 0;
|
|
353
|
+
for (let i = 0; i < s1.length; i++) {
|
|
354
|
+
const start = Math.max(0, i - matchWindow);
|
|
355
|
+
const end = Math.min(i + matchWindow + 1, s2.length);
|
|
356
|
+
for (let j = start; j < end; j++) {
|
|
357
|
+
if (s2Matches[j] || s1[i] !== s2[j]) continue;
|
|
358
|
+
s1Matches[i] = true;
|
|
359
|
+
s2Matches[j] = true;
|
|
360
|
+
matches++;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (matches === 0) return 0;
|
|
365
|
+
let k = 0;
|
|
366
|
+
for (let i = 0; i < s1.length; i++) {
|
|
367
|
+
if (!s1Matches[i]) continue;
|
|
368
|
+
while (!s2Matches[k]) k++;
|
|
369
|
+
if (s1[i] !== s2[k]) transpositions++;
|
|
370
|
+
k++;
|
|
371
|
+
}
|
|
372
|
+
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
|
|
373
|
+
let prefix = 0;
|
|
374
|
+
for (let i = 0; i < Math.min(4, Math.min(s1.length, s2.length)); i++) {
|
|
375
|
+
if (s1[i] === s2[i]) {
|
|
376
|
+
prefix++;
|
|
377
|
+
} else {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const WINKLER_SCALING = 0.1;
|
|
382
|
+
return jaro + prefix * WINKLER_SCALING * (1 - jaro);
|
|
383
|
+
}
|
|
384
|
+
function stringSimilarity(a, b) {
|
|
385
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
386
|
+
const lowerA = a.toLowerCase();
|
|
387
|
+
const lowerB = b.toLowerCase();
|
|
388
|
+
if (lowerA === lowerB) return 1;
|
|
389
|
+
const jw = jaroWinkler(a, b);
|
|
390
|
+
let containsBonus = 0;
|
|
391
|
+
if (lowerB.includes(lowerA)) {
|
|
392
|
+
containsBonus = 0.2;
|
|
393
|
+
} else if (lowerA.includes(lowerB)) {
|
|
394
|
+
containsBonus = 0.1;
|
|
395
|
+
}
|
|
396
|
+
return Math.min(1, jw + containsBonus);
|
|
397
|
+
}
|
|
398
|
+
function scoreElement(query, element) {
|
|
399
|
+
const lowerQuery = query.toLowerCase();
|
|
400
|
+
const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
|
|
401
|
+
let nameScore = 0;
|
|
402
|
+
if (element.name) {
|
|
403
|
+
const lowerName = element.name.toLowerCase();
|
|
404
|
+
if (lowerName === lowerQuery) {
|
|
405
|
+
nameScore = 1;
|
|
406
|
+
} else if (lowerName.includes(lowerQuery)) {
|
|
407
|
+
nameScore = 0.8;
|
|
408
|
+
} else if (words.length > 0) {
|
|
409
|
+
const matchedWords = words.filter((w) => lowerName.includes(w));
|
|
410
|
+
nameScore = matchedWords.length / words.length * 0.7;
|
|
411
|
+
} else {
|
|
412
|
+
nameScore = stringSimilarity(query, element.name) * 0.6;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
let roleScore = 0;
|
|
416
|
+
const lowerRole = element.role.toLowerCase();
|
|
417
|
+
if (lowerRole === lowerQuery || lowerQuery.includes(lowerRole)) {
|
|
418
|
+
roleScore = 0.3;
|
|
419
|
+
} else if (words.some((w) => lowerRole.includes(w))) {
|
|
420
|
+
roleScore = 0.2;
|
|
421
|
+
}
|
|
422
|
+
let selectorScore = 0;
|
|
423
|
+
const lowerSelector = element.selector.toLowerCase();
|
|
424
|
+
if (words.some((w) => lowerSelector.includes(w))) {
|
|
425
|
+
selectorScore = 0.2;
|
|
426
|
+
}
|
|
427
|
+
const totalScore = nameScore * 0.6 + roleScore * 0.25 + selectorScore * 0.15;
|
|
428
|
+
return totalScore;
|
|
429
|
+
}
|
|
430
|
+
function explainMatch(query, element, score) {
|
|
431
|
+
const reasons = [];
|
|
432
|
+
const lowerQuery = query.toLowerCase();
|
|
433
|
+
const words = lowerQuery.split(/\s+/).filter((w) => w.length > 0);
|
|
434
|
+
if (element.name) {
|
|
435
|
+
const lowerName = element.name.toLowerCase();
|
|
436
|
+
if (lowerName === lowerQuery) {
|
|
437
|
+
reasons.push("exact name match");
|
|
438
|
+
} else if (lowerName.includes(lowerQuery)) {
|
|
439
|
+
reasons.push("name contains query");
|
|
440
|
+
} else if (words.some((w) => lowerName.includes(w))) {
|
|
441
|
+
const matchedWords = words.filter((w) => lowerName.includes(w));
|
|
442
|
+
reasons.push(`name contains: ${matchedWords.join(", ")}`);
|
|
443
|
+
} else if (stringSimilarity(query, element.name) > 0.5) {
|
|
444
|
+
reasons.push("similar name");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const lowerRole = element.role.toLowerCase();
|
|
448
|
+
if (lowerRole === lowerQuery || words.some((w) => w === lowerRole)) {
|
|
449
|
+
reasons.push(`role: ${element.role}`);
|
|
450
|
+
}
|
|
451
|
+
if (words.some((w) => element.selector.toLowerCase().includes(w))) {
|
|
452
|
+
reasons.push("selector match");
|
|
453
|
+
}
|
|
454
|
+
if (reasons.length === 0) {
|
|
455
|
+
reasons.push(`fuzzy match (score: ${score.toFixed(2)})`);
|
|
456
|
+
}
|
|
457
|
+
return reasons.join(", ");
|
|
458
|
+
}
|
|
459
|
+
function fuzzyMatchElements(query, elements, maxResults = 5) {
|
|
460
|
+
if (!query || query.length === 0) {
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
const THRESHOLD = 0.3;
|
|
464
|
+
const scored = elements.map((element) => ({
|
|
465
|
+
element,
|
|
466
|
+
score: scoreElement(query, element)
|
|
467
|
+
}));
|
|
468
|
+
return scored.filter((s) => s.score >= THRESHOLD).sort((a, b) => b.score - a.score).slice(0, maxResults).map((s) => ({
|
|
469
|
+
element: s.element,
|
|
470
|
+
score: s.score,
|
|
471
|
+
matchReason: explainMatch(query, s.element, s.score)
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/browser/hint-generator.ts
|
|
476
|
+
var ACTION_ROLE_MAP = {
|
|
477
|
+
click: ["button", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "option"],
|
|
478
|
+
fill: ["textbox", "searchbox", "textarea"],
|
|
479
|
+
type: ["textbox", "searchbox", "textarea"],
|
|
480
|
+
submit: ["button", "form"],
|
|
481
|
+
select: ["combobox", "listbox", "option"],
|
|
482
|
+
check: ["checkbox", "radio", "switch"],
|
|
483
|
+
uncheck: ["checkbox", "switch"],
|
|
484
|
+
focus: [],
|
|
485
|
+
// Any focusable element
|
|
486
|
+
hover: [],
|
|
487
|
+
// Any element
|
|
488
|
+
clear: ["textbox", "searchbox", "textarea"]
|
|
489
|
+
};
|
|
490
|
+
function extractIntent(selectors) {
|
|
491
|
+
const patterns = [];
|
|
492
|
+
let text = "";
|
|
493
|
+
for (const selector of selectors) {
|
|
494
|
+
if (selector.startsWith("ref:")) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
|
|
498
|
+
if (idMatch) {
|
|
499
|
+
patterns.push(idMatch[1]);
|
|
500
|
+
}
|
|
501
|
+
const ariaMatch = selector.match(/\[aria-label=["']([^"']+)["']\]/);
|
|
502
|
+
if (ariaMatch) {
|
|
503
|
+
patterns.push(ariaMatch[1]);
|
|
504
|
+
}
|
|
505
|
+
const testidMatch = selector.match(/\[data-testid=["']([^"']+)["']\]/);
|
|
506
|
+
if (testidMatch) {
|
|
507
|
+
patterns.push(testidMatch[1]);
|
|
508
|
+
}
|
|
509
|
+
const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
|
|
510
|
+
if (classMatch) {
|
|
511
|
+
patterns.push(classMatch[1]);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
patterns.sort((a, b) => b.length - a.length);
|
|
515
|
+
text = patterns[0] ?? selectors[0] ?? "";
|
|
516
|
+
return { text, patterns };
|
|
517
|
+
}
|
|
518
|
+
function getHintType(selector) {
|
|
519
|
+
if (selector.startsWith("ref:")) return "ref";
|
|
520
|
+
if (selector.includes("data-testid")) return "testid";
|
|
521
|
+
if (selector.includes("aria-label")) return "aria";
|
|
522
|
+
if (selector.startsWith("#")) return "id";
|
|
523
|
+
return "css";
|
|
524
|
+
}
|
|
525
|
+
function getConfidence(score) {
|
|
526
|
+
if (score >= 0.8) return "high";
|
|
527
|
+
if (score >= 0.5) return "medium";
|
|
528
|
+
return "low";
|
|
529
|
+
}
|
|
530
|
+
function diversifyHints(candidates, maxHints) {
|
|
531
|
+
const hints = [];
|
|
532
|
+
const usedTypes = /* @__PURE__ */ new Set();
|
|
533
|
+
for (const candidate of candidates) {
|
|
534
|
+
if (hints.length >= maxHints) break;
|
|
535
|
+
const refSelector = `ref:${candidate.element.ref}`;
|
|
536
|
+
const hintType = getHintType(refSelector);
|
|
537
|
+
if (!usedTypes.has(hintType)) {
|
|
538
|
+
hints.push({
|
|
539
|
+
selector: refSelector,
|
|
540
|
+
reason: candidate.matchReason,
|
|
541
|
+
confidence: getConfidence(candidate.score),
|
|
542
|
+
element: {
|
|
543
|
+
ref: candidate.element.ref,
|
|
544
|
+
role: candidate.element.role,
|
|
545
|
+
name: candidate.element.name,
|
|
546
|
+
disabled: candidate.element.disabled
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
usedTypes.add(hintType);
|
|
550
|
+
} else if (hints.length < maxHints) {
|
|
551
|
+
hints.push({
|
|
552
|
+
selector: refSelector,
|
|
553
|
+
reason: candidate.matchReason,
|
|
554
|
+
confidence: getConfidence(candidate.score),
|
|
555
|
+
element: {
|
|
556
|
+
ref: candidate.element.ref,
|
|
557
|
+
role: candidate.element.role,
|
|
558
|
+
name: candidate.element.name,
|
|
559
|
+
disabled: candidate.element.disabled
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return hints;
|
|
565
|
+
}
|
|
566
|
+
async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
|
|
567
|
+
let snapshot;
|
|
568
|
+
try {
|
|
569
|
+
snapshot = await page.snapshot();
|
|
570
|
+
} catch {
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
const intent = extractIntent(failedSelectors);
|
|
574
|
+
const roleFilter = ACTION_ROLE_MAP[actionType] ?? [];
|
|
575
|
+
let candidates = snapshot.interactiveElements;
|
|
576
|
+
if (roleFilter.length > 0) {
|
|
577
|
+
candidates = candidates.filter((el) => roleFilter.includes(el.role));
|
|
578
|
+
}
|
|
579
|
+
const matches = fuzzyMatchElements(intent.text, candidates, maxHints * 2);
|
|
580
|
+
if (matches.length === 0) {
|
|
581
|
+
return [];
|
|
582
|
+
}
|
|
583
|
+
return diversifyHints(matches, maxHints);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/browser/types.ts
|
|
587
|
+
var ElementNotFoundError = class extends Error {
|
|
588
|
+
selectors;
|
|
589
|
+
hints;
|
|
590
|
+
constructor(selectors, hints) {
|
|
591
|
+
const selectorList = Array.isArray(selectors) ? selectors : [selectors];
|
|
592
|
+
let msg = `Element not found: ${selectorList.join(", ")}`;
|
|
593
|
+
if (hints?.length) {
|
|
594
|
+
msg += `. Did you mean: ${hints.slice(0, 3).map((h) => `${h.element.ref} (${h.element.role} "${h.element.name}")`).join(", ")}`;
|
|
595
|
+
}
|
|
596
|
+
msg += `. Run 'bp snapshot' to see available elements.`;
|
|
597
|
+
super(msg);
|
|
598
|
+
this.name = "ElementNotFoundError";
|
|
599
|
+
this.selectors = selectorList;
|
|
600
|
+
this.hints = hints;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
var TimeoutError = class extends Error {
|
|
604
|
+
constructor(message = "Operation timed out") {
|
|
605
|
+
const msg = message.includes("bp snapshot") ? message : `${message}. Run 'bp snapshot' to check current page state.`;
|
|
606
|
+
super(msg);
|
|
607
|
+
this.name = "TimeoutError";
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
var NavigationError = class extends Error {
|
|
611
|
+
constructor(message) {
|
|
612
|
+
super(message);
|
|
613
|
+
this.name = "NavigationError";
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// src/actions/executor.ts
|
|
618
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
619
|
+
function classifyFailure(error) {
|
|
620
|
+
if (error instanceof ElementNotFoundError) {
|
|
621
|
+
return { reason: "missing" };
|
|
622
|
+
}
|
|
623
|
+
if (error instanceof ActionabilityError) {
|
|
624
|
+
switch (error.failureType) {
|
|
625
|
+
case "visible":
|
|
626
|
+
return { reason: "hidden" };
|
|
627
|
+
case "hitTarget":
|
|
628
|
+
return { reason: "covered", coveringElement: error.coveringElement };
|
|
629
|
+
case "enabled":
|
|
630
|
+
return { reason: "disabled" };
|
|
631
|
+
case "editable":
|
|
632
|
+
return { reason: error.message?.includes("readonly") ? "readonly" : "notEditable" };
|
|
633
|
+
case "stable":
|
|
634
|
+
return { reason: "replaced" };
|
|
635
|
+
default:
|
|
636
|
+
return { reason: "unknown" };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (error instanceof TimeoutError) {
|
|
640
|
+
return { reason: "timeout" };
|
|
641
|
+
}
|
|
642
|
+
if (error instanceof NavigationError) {
|
|
643
|
+
return { reason: "navigation" };
|
|
644
|
+
}
|
|
645
|
+
if (error instanceof CDPError) {
|
|
646
|
+
return { reason: "cdpError" };
|
|
647
|
+
}
|
|
648
|
+
const msg = String(error?.message ?? error);
|
|
649
|
+
if (msg.includes("Could not find node") || msg.includes("does not belong to the document")) {
|
|
650
|
+
return { reason: "detached" };
|
|
651
|
+
}
|
|
652
|
+
return { reason: "unknown" };
|
|
653
|
+
}
|
|
654
|
+
function getSuggestion(reason) {
|
|
655
|
+
switch (reason) {
|
|
656
|
+
case "missing":
|
|
657
|
+
return "Element not found. Run 'snapshot' to see available elements, or try alternative selectors.";
|
|
658
|
+
case "hidden":
|
|
659
|
+
return "Element exists but is not visible. Try 'scroll' or wait for it to appear.";
|
|
660
|
+
case "covered":
|
|
661
|
+
return "Element is blocked by another element. Dismiss the covering element first.";
|
|
662
|
+
case "disabled":
|
|
663
|
+
return "Element is disabled. Complete prerequisite steps to enable it.";
|
|
664
|
+
case "readonly":
|
|
665
|
+
return "Element is readonly and cannot be edited directly.";
|
|
666
|
+
case "detached":
|
|
667
|
+
return "Element was removed from the DOM. Run 'snapshot' for fresh element refs.";
|
|
668
|
+
case "replaced":
|
|
669
|
+
return "Element was replaced in the DOM. Run 'snapshot' to get updated refs.";
|
|
670
|
+
case "notEditable":
|
|
671
|
+
return "Element is not an editable field. Try a different selector targeting an input or textarea.";
|
|
672
|
+
case "timeout":
|
|
673
|
+
return "Timed out waiting. The page may still be loading. Try increasing timeout.";
|
|
674
|
+
case "navigation":
|
|
675
|
+
return "Navigation failed. Check the URL and network connectivity.";
|
|
676
|
+
case "cdpError":
|
|
677
|
+
return "Browser connection error. Try 'bp connect' again.";
|
|
678
|
+
case "unknown":
|
|
679
|
+
return "Unexpected error. Run 'snapshot' to check page state.";
|
|
680
|
+
default: {
|
|
681
|
+
const _exhaustive = reason;
|
|
682
|
+
return `Unknown failure: ${_exhaustive}`;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
var BatchExecutor = class {
|
|
687
|
+
page;
|
|
688
|
+
constructor(page) {
|
|
689
|
+
this.page = page;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Execute a batch of steps
|
|
693
|
+
*/
|
|
694
|
+
async execute(steps, options = {}) {
|
|
695
|
+
const { timeout = DEFAULT_TIMEOUT, onFail = "stop" } = options;
|
|
696
|
+
const results = [];
|
|
697
|
+
const startTime = Date.now();
|
|
698
|
+
for (let i = 0; i < steps.length; i++) {
|
|
699
|
+
const step = steps[i];
|
|
700
|
+
const stepStart = Date.now();
|
|
701
|
+
const maxAttempts = (step.retry ?? 0) + 1;
|
|
702
|
+
const retryDelay = step.retryDelay ?? 500;
|
|
703
|
+
let lastError;
|
|
704
|
+
let succeeded = false;
|
|
705
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
706
|
+
if (attempt > 0) {
|
|
707
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
const result = await this.executeStep(step, timeout);
|
|
711
|
+
results.push({
|
|
712
|
+
index: i,
|
|
713
|
+
action: step.action,
|
|
714
|
+
selector: step.selector,
|
|
715
|
+
selectorUsed: result.selectorUsed,
|
|
716
|
+
success: true,
|
|
717
|
+
durationMs: Date.now() - stepStart,
|
|
718
|
+
result: result.value,
|
|
719
|
+
text: result.text
|
|
720
|
+
});
|
|
721
|
+
succeeded = true;
|
|
722
|
+
break;
|
|
723
|
+
} catch (error) {
|
|
724
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (!succeeded) {
|
|
728
|
+
const errorMessage = lastError?.message ?? "Unknown error";
|
|
729
|
+
let hints = lastError instanceof ElementNotFoundError ? lastError.hints : void 0;
|
|
730
|
+
const { reason, coveringElement } = classifyFailure(lastError);
|
|
731
|
+
if (step.selector && !step.optional && ["missing", "hidden", "covered", "disabled", "detached", "replaced"].includes(reason)) {
|
|
732
|
+
try {
|
|
733
|
+
const selectors = Array.isArray(step.selector) ? step.selector : [step.selector];
|
|
734
|
+
const autoHints = await generateHints(this.page, selectors, step.action, 3);
|
|
735
|
+
if (autoHints.length > 0) {
|
|
736
|
+
hints = autoHints;
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
results.push({
|
|
742
|
+
index: i,
|
|
743
|
+
action: step.action,
|
|
744
|
+
selector: step.selector,
|
|
745
|
+
success: false,
|
|
746
|
+
durationMs: Date.now() - stepStart,
|
|
747
|
+
error: errorMessage,
|
|
748
|
+
hints,
|
|
749
|
+
failureReason: reason,
|
|
750
|
+
coveringElement,
|
|
751
|
+
suggestion: getSuggestion(reason)
|
|
752
|
+
});
|
|
753
|
+
if (onFail === "stop" && !step.optional) {
|
|
754
|
+
return {
|
|
755
|
+
success: false,
|
|
756
|
+
stoppedAtIndex: i,
|
|
757
|
+
steps: results,
|
|
758
|
+
totalDurationMs: Date.now() - startTime
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const allSuccess = results.every((r) => r.success || steps[r.index]?.optional);
|
|
764
|
+
return {
|
|
765
|
+
success: allSuccess,
|
|
766
|
+
steps: results,
|
|
767
|
+
totalDurationMs: Date.now() - startTime
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Execute a single step
|
|
772
|
+
*/
|
|
773
|
+
async executeStep(step, defaultTimeout) {
|
|
774
|
+
const timeout = step.timeout ?? defaultTimeout;
|
|
775
|
+
const optional = step.optional ?? false;
|
|
776
|
+
switch (step.action) {
|
|
777
|
+
case "goto": {
|
|
778
|
+
if (!step.url) throw new Error("goto requires url");
|
|
779
|
+
await this.page.goto(step.url, { timeout, optional });
|
|
780
|
+
return {};
|
|
781
|
+
}
|
|
782
|
+
case "click": {
|
|
783
|
+
if (!step.selector) throw new Error("click requires selector");
|
|
784
|
+
if (step.waitForNavigation === true) {
|
|
785
|
+
const navPromise = this.page.waitForNavigation({ timeout, optional });
|
|
786
|
+
await this.page.click(step.selector, { timeout, optional });
|
|
787
|
+
await navPromise;
|
|
788
|
+
} else {
|
|
789
|
+
await this.page.click(step.selector, { timeout, optional });
|
|
790
|
+
}
|
|
791
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
792
|
+
}
|
|
793
|
+
case "fill": {
|
|
794
|
+
if (!step.selector) throw new Error("fill requires selector");
|
|
795
|
+
if (typeof step.value !== "string") throw new Error("fill requires string value");
|
|
796
|
+
await this.page.fill(step.selector, step.value, {
|
|
797
|
+
timeout,
|
|
798
|
+
optional,
|
|
799
|
+
blur: step.blur
|
|
800
|
+
});
|
|
801
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
802
|
+
}
|
|
803
|
+
case "type": {
|
|
804
|
+
if (!step.selector) throw new Error("type requires selector");
|
|
805
|
+
if (typeof step.value !== "string") throw new Error("type requires string value");
|
|
806
|
+
await this.page.type(step.selector, step.value, {
|
|
807
|
+
timeout,
|
|
808
|
+
optional,
|
|
809
|
+
delay: step.delay ?? 50
|
|
810
|
+
});
|
|
811
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
812
|
+
}
|
|
813
|
+
case "select": {
|
|
814
|
+
if (step.trigger && step.option && typeof step.value === "string") {
|
|
815
|
+
await this.page.select(
|
|
816
|
+
{
|
|
817
|
+
trigger: step.trigger,
|
|
818
|
+
option: step.option,
|
|
819
|
+
value: step.value,
|
|
820
|
+
match: step.match
|
|
821
|
+
},
|
|
822
|
+
{ timeout, optional }
|
|
823
|
+
);
|
|
824
|
+
return { selectorUsed: this.getUsedSelector(step.trigger) };
|
|
825
|
+
}
|
|
826
|
+
if (!step.selector) throw new Error("select requires selector");
|
|
827
|
+
if (!step.value) throw new Error("select requires value");
|
|
828
|
+
await this.page.select(step.selector, step.value, { timeout, optional });
|
|
829
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
830
|
+
}
|
|
831
|
+
case "check": {
|
|
832
|
+
if (!step.selector) throw new Error("check requires selector");
|
|
833
|
+
await this.page.check(step.selector, { timeout, optional });
|
|
834
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
835
|
+
}
|
|
836
|
+
case "uncheck": {
|
|
837
|
+
if (!step.selector) throw new Error("uncheck requires selector");
|
|
838
|
+
await this.page.uncheck(step.selector, { timeout, optional });
|
|
839
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
840
|
+
}
|
|
841
|
+
case "submit": {
|
|
842
|
+
if (!step.selector) throw new Error("submit requires selector");
|
|
843
|
+
await this.page.submit(step.selector, {
|
|
844
|
+
timeout,
|
|
845
|
+
optional,
|
|
846
|
+
method: step.method ?? "enter+click",
|
|
847
|
+
waitForNavigation: step.waitForNavigation
|
|
848
|
+
});
|
|
849
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
850
|
+
}
|
|
851
|
+
case "press": {
|
|
852
|
+
if (!step.key) throw new Error("press requires key");
|
|
853
|
+
try {
|
|
854
|
+
await this.page.press(step.key, {
|
|
855
|
+
modifiers: step.modifiers
|
|
856
|
+
});
|
|
857
|
+
} catch (e) {
|
|
858
|
+
if (optional) return {};
|
|
859
|
+
throw e;
|
|
860
|
+
}
|
|
861
|
+
return {};
|
|
862
|
+
}
|
|
863
|
+
case "shortcut": {
|
|
864
|
+
if (!step.combo) throw new Error("shortcut requires combo");
|
|
865
|
+
try {
|
|
866
|
+
await this.page.shortcut(step.combo);
|
|
867
|
+
} catch (e) {
|
|
868
|
+
if (optional) return {};
|
|
869
|
+
throw e;
|
|
870
|
+
}
|
|
871
|
+
return {};
|
|
872
|
+
}
|
|
873
|
+
case "focus": {
|
|
874
|
+
if (!step.selector) throw new Error("focus requires selector");
|
|
875
|
+
await this.page.focus(step.selector, { timeout, optional });
|
|
876
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
877
|
+
}
|
|
878
|
+
case "hover": {
|
|
879
|
+
if (!step.selector) throw new Error("hover requires selector");
|
|
880
|
+
await this.page.hover(step.selector, { timeout, optional });
|
|
881
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
882
|
+
}
|
|
883
|
+
case "scroll": {
|
|
884
|
+
if (step.x !== void 0 || step.y !== void 0) {
|
|
885
|
+
await this.page.scroll("body", { x: step.x, y: step.y, timeout, optional });
|
|
886
|
+
return {};
|
|
887
|
+
}
|
|
888
|
+
if (!step.selector && (step.direction || step.amount !== void 0)) {
|
|
889
|
+
const amount = step.amount ?? 500;
|
|
890
|
+
const direction = step.direction ?? "down";
|
|
891
|
+
const deltaY = direction === "down" ? amount : direction === "up" ? -amount : 0;
|
|
892
|
+
const deltaX = direction === "right" ? amount : direction === "left" ? -amount : 0;
|
|
893
|
+
await this.page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
|
|
894
|
+
return {};
|
|
895
|
+
}
|
|
896
|
+
if (!step.selector) throw new Error("scroll requires selector, coordinates, or direction");
|
|
897
|
+
await this.page.scroll(step.selector, { timeout, optional });
|
|
898
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
899
|
+
}
|
|
900
|
+
case "wait": {
|
|
901
|
+
if (!step.selector && !step.waitFor) {
|
|
902
|
+
const delay = step.timeout ?? 1e3;
|
|
903
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
904
|
+
return {};
|
|
905
|
+
}
|
|
906
|
+
if (step.waitFor === "navigation") {
|
|
907
|
+
await this.page.waitForNavigation({ timeout, optional });
|
|
908
|
+
return {};
|
|
909
|
+
}
|
|
910
|
+
if (step.waitFor === "networkIdle") {
|
|
911
|
+
await this.page.waitForNetworkIdle({ timeout, optional });
|
|
912
|
+
return {};
|
|
913
|
+
}
|
|
914
|
+
if (!step.selector)
|
|
915
|
+
throw new Error(
|
|
916
|
+
"wait requires selector (or waitFor: navigation/networkIdle, or timeout for simple delay)"
|
|
917
|
+
);
|
|
918
|
+
await this.page.waitFor(step.selector, {
|
|
919
|
+
timeout,
|
|
920
|
+
optional,
|
|
921
|
+
state: step.waitFor ?? "visible"
|
|
922
|
+
});
|
|
923
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
924
|
+
}
|
|
925
|
+
case "snapshot": {
|
|
926
|
+
const snapshot = await this.page.snapshot();
|
|
927
|
+
return { value: snapshot };
|
|
928
|
+
}
|
|
929
|
+
case "forms": {
|
|
930
|
+
return { value: await this.page.forms() };
|
|
931
|
+
}
|
|
932
|
+
case "screenshot": {
|
|
933
|
+
const data = await this.page.screenshot({
|
|
934
|
+
format: step.format,
|
|
935
|
+
quality: step.quality,
|
|
936
|
+
fullPage: step.fullPage
|
|
937
|
+
});
|
|
938
|
+
return { value: data };
|
|
939
|
+
}
|
|
940
|
+
case "evaluate": {
|
|
941
|
+
if (typeof step.value !== "string")
|
|
942
|
+
throw new Error("evaluate requires string value (expression)");
|
|
943
|
+
const result = await this.page.evaluate(step.value);
|
|
944
|
+
return { value: result };
|
|
945
|
+
}
|
|
946
|
+
case "text": {
|
|
947
|
+
const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
|
|
948
|
+
const text = await this.page.text(selector);
|
|
949
|
+
return { text, selectorUsed: selector };
|
|
950
|
+
}
|
|
951
|
+
case "newTab": {
|
|
952
|
+
const { targetId } = await this.page.cdpClient.send(
|
|
953
|
+
"Target.createTarget",
|
|
954
|
+
{
|
|
955
|
+
url: step.url ?? "about:blank"
|
|
956
|
+
},
|
|
957
|
+
null
|
|
958
|
+
);
|
|
959
|
+
return { value: { targetId } };
|
|
960
|
+
}
|
|
961
|
+
case "closeTab": {
|
|
962
|
+
const targetId = step.targetId ?? this.page.targetId;
|
|
963
|
+
await this.page.cdpClient.send("Target.closeTarget", { targetId }, null);
|
|
964
|
+
return { value: { targetId, closedCurrent: targetId === this.page.targetId } };
|
|
965
|
+
}
|
|
966
|
+
case "switchFrame": {
|
|
967
|
+
if (!step.selector) throw new Error("switchFrame requires selector");
|
|
968
|
+
await this.page.switchToFrame(step.selector, { timeout, optional });
|
|
969
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
970
|
+
}
|
|
971
|
+
case "switchToMain": {
|
|
972
|
+
await this.page.switchToMain();
|
|
973
|
+
return {};
|
|
974
|
+
}
|
|
975
|
+
case "assertVisible": {
|
|
976
|
+
if (!step.selector) throw new Error("assertVisible requires selector");
|
|
977
|
+
const el = await this.page.waitFor(step.selector, {
|
|
978
|
+
timeout,
|
|
979
|
+
optional: true,
|
|
980
|
+
state: "visible"
|
|
981
|
+
});
|
|
982
|
+
if (!el) {
|
|
983
|
+
throw new Error(
|
|
984
|
+
`Assertion failed: selector ${JSON.stringify(step.selector)} is not visible`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
988
|
+
}
|
|
989
|
+
case "assertExists": {
|
|
990
|
+
if (!step.selector) throw new Error("assertExists requires selector");
|
|
991
|
+
const el = await this.page.waitFor(step.selector, {
|
|
992
|
+
timeout,
|
|
993
|
+
optional: true,
|
|
994
|
+
state: "attached"
|
|
995
|
+
});
|
|
996
|
+
if (!el) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Assertion failed: selector ${JSON.stringify(step.selector)} does not exist`
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
return { selectorUsed: this.getUsedSelector(step.selector) };
|
|
1002
|
+
}
|
|
1003
|
+
case "assertText": {
|
|
1004
|
+
const selector = Array.isArray(step.selector) ? step.selector[0] : step.selector;
|
|
1005
|
+
const text = await this.page.text(selector);
|
|
1006
|
+
const expected = step.expect ?? step.value;
|
|
1007
|
+
if (typeof expected !== "string") throw new Error("assertText requires expect or value");
|
|
1008
|
+
if (!text.includes(expected)) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`Assertion failed: text does not contain ${JSON.stringify(expected)}. Got: ${JSON.stringify(text.slice(0, 200))}`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return { selectorUsed: selector, text };
|
|
1014
|
+
}
|
|
1015
|
+
case "assertUrl": {
|
|
1016
|
+
const currentUrl = await this.page.url();
|
|
1017
|
+
const expected = step.expect ?? step.url;
|
|
1018
|
+
if (typeof expected !== "string") throw new Error("assertUrl requires expect or url");
|
|
1019
|
+
if (!currentUrl.includes(expected)) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
`Assertion failed: URL does not contain ${JSON.stringify(expected)}. Got: ${JSON.stringify(currentUrl)}`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return { value: currentUrl };
|
|
1025
|
+
}
|
|
1026
|
+
case "assertValue": {
|
|
1027
|
+
if (!step.selector) throw new Error("assertValue requires selector");
|
|
1028
|
+
const expected = step.expect ?? step.value;
|
|
1029
|
+
if (typeof expected !== "string") throw new Error("assertValue requires expect or value");
|
|
1030
|
+
const found = await this.page.waitFor(step.selector, {
|
|
1031
|
+
timeout,
|
|
1032
|
+
optional: true,
|
|
1033
|
+
state: "attached"
|
|
1034
|
+
});
|
|
1035
|
+
if (!found) {
|
|
1036
|
+
throw new Error(`Assertion failed: selector ${JSON.stringify(step.selector)} not found`);
|
|
1037
|
+
}
|
|
1038
|
+
const usedSelector = this.getUsedSelector(step.selector);
|
|
1039
|
+
const actual = await this.page.evaluate(
|
|
1040
|
+
`(function() { var el = document.querySelector(${JSON.stringify(usedSelector)}); return el ? el.value : null; })()`
|
|
1041
|
+
);
|
|
1042
|
+
if (actual !== expected) {
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`Assertion failed: value of ${JSON.stringify(usedSelector)} is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return { selectorUsed: usedSelector, value: actual };
|
|
1048
|
+
}
|
|
1049
|
+
default: {
|
|
1050
|
+
const action = step.action;
|
|
1051
|
+
const aliases = {
|
|
1052
|
+
execute: "evaluate",
|
|
1053
|
+
navigate: "goto",
|
|
1054
|
+
input: "fill",
|
|
1055
|
+
tap: "click",
|
|
1056
|
+
go: "goto",
|
|
1057
|
+
run: "evaluate",
|
|
1058
|
+
capture: "screenshot",
|
|
1059
|
+
inspect: "snapshot",
|
|
1060
|
+
enter: "press",
|
|
1061
|
+
keypress: "press",
|
|
1062
|
+
hotkey: "shortcut",
|
|
1063
|
+
keybinding: "shortcut",
|
|
1064
|
+
nav: "goto",
|
|
1065
|
+
open: "goto",
|
|
1066
|
+
visit: "goto",
|
|
1067
|
+
browse: "goto",
|
|
1068
|
+
load: "goto",
|
|
1069
|
+
write: "fill",
|
|
1070
|
+
set: "fill",
|
|
1071
|
+
pick: "select",
|
|
1072
|
+
choose: "select",
|
|
1073
|
+
send: "press",
|
|
1074
|
+
eval: "evaluate",
|
|
1075
|
+
js: "evaluate",
|
|
1076
|
+
script: "evaluate",
|
|
1077
|
+
snap: "snapshot",
|
|
1078
|
+
accessibility: "snapshot",
|
|
1079
|
+
a11y: "snapshot",
|
|
1080
|
+
formslist: "forms",
|
|
1081
|
+
image: "screenshot",
|
|
1082
|
+
pic: "screenshot",
|
|
1083
|
+
frame: "switchFrame",
|
|
1084
|
+
iframe: "switchFrame",
|
|
1085
|
+
newtab: "newTab",
|
|
1086
|
+
opentab: "newTab",
|
|
1087
|
+
createtab: "newTab",
|
|
1088
|
+
closetab: "closeTab",
|
|
1089
|
+
assert_visible: "assertVisible",
|
|
1090
|
+
assert_exists: "assertExists",
|
|
1091
|
+
assert_text: "assertText",
|
|
1092
|
+
assert_url: "assertUrl",
|
|
1093
|
+
assert_value: "assertValue",
|
|
1094
|
+
checkvisible: "assertVisible",
|
|
1095
|
+
checkexists: "assertExists",
|
|
1096
|
+
checktext: "assertText",
|
|
1097
|
+
checkurl: "assertUrl",
|
|
1098
|
+
checkvalue: "assertValue"
|
|
1099
|
+
};
|
|
1100
|
+
const suggestion = aliases[action.toLowerCase()];
|
|
1101
|
+
const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
1102
|
+
const valid = "goto, click, fill, type, select, check, uncheck, submit, press, shortcut, focus, hover, scroll, wait, snapshot, forms, screenshot, evaluate, text, newTab, closeTab, switchFrame, switchToMain, assertVisible, assertExists, assertText, assertUrl, assertValue";
|
|
1103
|
+
throw new Error(`Unknown action "${action}".${hint}
|
|
1104
|
+
|
|
1105
|
+
Valid actions: ${valid}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Get the actual selector that matched the element.
|
|
1111
|
+
* Uses the last matched selector tracked by Page, falls back to first selector if unavailable.
|
|
1112
|
+
*/
|
|
1113
|
+
getUsedSelector(selector) {
|
|
1114
|
+
const matched = this.page.getLastMatchedSelector();
|
|
1115
|
+
if (matched) return matched;
|
|
1116
|
+
return Array.isArray(selector) ? selector[0] : selector;
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
function addBatchToPage(page) {
|
|
1120
|
+
const executor = new BatchExecutor(page);
|
|
1121
|
+
return Object.assign(page, {
|
|
1122
|
+
batch: (steps, options) => executor.execute(steps, options)
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/actions/validate.ts
|
|
1127
|
+
function levenshtein(a, b) {
|
|
1128
|
+
const m = a.length;
|
|
1129
|
+
const n = b.length;
|
|
1130
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
1131
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
1132
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
1133
|
+
for (let i = 1; i <= m; i++) {
|
|
1134
|
+
for (let j = 1; j <= n; j++) {
|
|
1135
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return dp[m][n];
|
|
1139
|
+
}
|
|
1140
|
+
var ACTION_ALIASES = {
|
|
1141
|
+
execute: "evaluate",
|
|
1142
|
+
navigate: "goto",
|
|
1143
|
+
input: "fill",
|
|
1144
|
+
tap: "click",
|
|
1145
|
+
go: "goto",
|
|
1146
|
+
run: "evaluate",
|
|
1147
|
+
capture: "screenshot",
|
|
1148
|
+
inspect: "snapshot",
|
|
1149
|
+
enter: "press",
|
|
1150
|
+
keypress: "press",
|
|
1151
|
+
hotkey: "shortcut",
|
|
1152
|
+
keybinding: "shortcut",
|
|
1153
|
+
nav: "goto",
|
|
1154
|
+
open: "goto",
|
|
1155
|
+
visit: "goto",
|
|
1156
|
+
browse: "goto",
|
|
1157
|
+
load: "goto",
|
|
1158
|
+
write: "fill",
|
|
1159
|
+
set: "fill",
|
|
1160
|
+
pick: "select",
|
|
1161
|
+
choose: "select",
|
|
1162
|
+
send: "press",
|
|
1163
|
+
eval: "evaluate",
|
|
1164
|
+
js: "evaluate",
|
|
1165
|
+
script: "evaluate",
|
|
1166
|
+
snap: "snapshot",
|
|
1167
|
+
accessibility: "snapshot",
|
|
1168
|
+
a11y: "snapshot",
|
|
1169
|
+
image: "screenshot",
|
|
1170
|
+
pic: "screenshot",
|
|
1171
|
+
frame: "switchFrame",
|
|
1172
|
+
iframe: "switchFrame",
|
|
1173
|
+
formslist: "forms",
|
|
1174
|
+
newtab: "newTab",
|
|
1175
|
+
opentab: "newTab",
|
|
1176
|
+
createtab: "newTab",
|
|
1177
|
+
closetab: "closeTab",
|
|
1178
|
+
assert_visible: "assertVisible",
|
|
1179
|
+
assert_exists: "assertExists",
|
|
1180
|
+
assert_text: "assertText",
|
|
1181
|
+
assert_url: "assertUrl",
|
|
1182
|
+
assert_value: "assertValue",
|
|
1183
|
+
checkvisible: "assertVisible",
|
|
1184
|
+
checkexists: "assertExists",
|
|
1185
|
+
checktext: "assertText",
|
|
1186
|
+
checkurl: "assertUrl",
|
|
1187
|
+
checkvalue: "assertValue"
|
|
1188
|
+
};
|
|
1189
|
+
var PROPERTY_ALIASES = {
|
|
1190
|
+
expression: "value",
|
|
1191
|
+
href: "url",
|
|
1192
|
+
target: "selector",
|
|
1193
|
+
element: "selector",
|
|
1194
|
+
code: "value",
|
|
1195
|
+
script: "value",
|
|
1196
|
+
src: "url",
|
|
1197
|
+
link: "url",
|
|
1198
|
+
char: "key",
|
|
1199
|
+
text: "value",
|
|
1200
|
+
query: "selector",
|
|
1201
|
+
el: "selector",
|
|
1202
|
+
elem: "selector",
|
|
1203
|
+
css: "selector",
|
|
1204
|
+
xpath: "selector",
|
|
1205
|
+
input: "value",
|
|
1206
|
+
content: "value",
|
|
1207
|
+
keys: "key",
|
|
1208
|
+
shortcutKey: "combo",
|
|
1209
|
+
hotkey: "combo",
|
|
1210
|
+
keybinding: "combo",
|
|
1211
|
+
button: "key",
|
|
1212
|
+
address: "url",
|
|
1213
|
+
page: "url",
|
|
1214
|
+
path: "url",
|
|
1215
|
+
tabId: "targetId"
|
|
1216
|
+
};
|
|
1217
|
+
var ACTION_RULES = {
|
|
1218
|
+
goto: {
|
|
1219
|
+
required: { url: { type: "string" } },
|
|
1220
|
+
optional: {}
|
|
1221
|
+
},
|
|
1222
|
+
click: {
|
|
1223
|
+
required: { selector: { type: "string|string[]" } },
|
|
1224
|
+
optional: {
|
|
1225
|
+
waitForNavigation: { type: "boolean|auto" }
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
fill: {
|
|
1229
|
+
required: { selector: { type: "string|string[]" }, value: { type: "string" } },
|
|
1230
|
+
optional: {
|
|
1231
|
+
blur: { type: "boolean" }
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
type: {
|
|
1235
|
+
required: { selector: { type: "string|string[]" }, value: { type: "string" } },
|
|
1236
|
+
optional: {
|
|
1237
|
+
delay: { type: "number" },
|
|
1238
|
+
blur: { type: "boolean" }
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
select: {
|
|
1242
|
+
required: {},
|
|
1243
|
+
optional: {
|
|
1244
|
+
selector: { type: "string|string[]" },
|
|
1245
|
+
value: { type: "string|string[]" },
|
|
1246
|
+
trigger: { type: "string|string[]" },
|
|
1247
|
+
option: { type: "string|string[]" },
|
|
1248
|
+
match: { type: "string", enum: ["text", "value", "contains"] }
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
check: {
|
|
1252
|
+
required: { selector: { type: "string|string[]" } },
|
|
1253
|
+
optional: {}
|
|
1254
|
+
},
|
|
1255
|
+
uncheck: {
|
|
1256
|
+
required: { selector: { type: "string|string[]" } },
|
|
1257
|
+
optional: {}
|
|
1258
|
+
},
|
|
1259
|
+
submit: {
|
|
1260
|
+
required: { selector: { type: "string|string[]" } },
|
|
1261
|
+
optional: {
|
|
1262
|
+
method: { type: "string", enum: ["enter", "click", "enter+click"] },
|
|
1263
|
+
waitForNavigation: { type: "boolean|auto" }
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1266
|
+
press: {
|
|
1267
|
+
required: { key: { type: "string" } },
|
|
1268
|
+
optional: {
|
|
1269
|
+
modifiers: { type: "string|string[]" }
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
shortcut: {
|
|
1273
|
+
required: { combo: { type: "string" } },
|
|
1274
|
+
optional: {}
|
|
1275
|
+
},
|
|
1276
|
+
focus: {
|
|
1277
|
+
required: { selector: { type: "string|string[]" } },
|
|
1278
|
+
optional: {}
|
|
1279
|
+
},
|
|
1280
|
+
hover: {
|
|
1281
|
+
required: { selector: { type: "string|string[]" } },
|
|
1282
|
+
optional: {}
|
|
1283
|
+
},
|
|
1284
|
+
scroll: {
|
|
1285
|
+
required: {},
|
|
1286
|
+
optional: {
|
|
1287
|
+
selector: { type: "string|string[]" },
|
|
1288
|
+
x: { type: "number" },
|
|
1289
|
+
y: { type: "number" },
|
|
1290
|
+
direction: { type: "string", enum: ["up", "down", "left", "right"] },
|
|
1291
|
+
amount: { type: "number" }
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
wait: {
|
|
1295
|
+
required: {},
|
|
1296
|
+
optional: {
|
|
1297
|
+
selector: { type: "string|string[]" },
|
|
1298
|
+
waitFor: {
|
|
1299
|
+
type: "string",
|
|
1300
|
+
enum: ["visible", "hidden", "attached", "detached", "navigation", "networkIdle"]
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
snapshot: {
|
|
1305
|
+
required: {},
|
|
1306
|
+
optional: {}
|
|
1307
|
+
},
|
|
1308
|
+
screenshot: {
|
|
1309
|
+
required: {},
|
|
1310
|
+
optional: {
|
|
1311
|
+
format: { type: "string", enum: ["png", "jpeg", "webp"] },
|
|
1312
|
+
quality: { type: "number" },
|
|
1313
|
+
fullPage: { type: "boolean" }
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
forms: {
|
|
1317
|
+
required: {},
|
|
1318
|
+
optional: {}
|
|
1319
|
+
},
|
|
1320
|
+
evaluate: {
|
|
1321
|
+
required: { value: { type: "string" } },
|
|
1322
|
+
optional: {}
|
|
1323
|
+
},
|
|
1324
|
+
text: {
|
|
1325
|
+
required: {},
|
|
1326
|
+
optional: {
|
|
1327
|
+
selector: { type: "string|string[]" }
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
switchFrame: {
|
|
1331
|
+
required: { selector: { type: "string|string[]" } },
|
|
1332
|
+
optional: {}
|
|
1333
|
+
},
|
|
1334
|
+
newTab: {
|
|
1335
|
+
required: {},
|
|
1336
|
+
optional: {
|
|
1337
|
+
url: { type: "string" }
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
closeTab: {
|
|
1341
|
+
required: {},
|
|
1342
|
+
optional: {
|
|
1343
|
+
targetId: { type: "string" }
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
switchToMain: {
|
|
1347
|
+
required: {},
|
|
1348
|
+
optional: {}
|
|
1349
|
+
},
|
|
1350
|
+
assertVisible: {
|
|
1351
|
+
required: { selector: { type: "string|string[]" } },
|
|
1352
|
+
optional: {}
|
|
1353
|
+
},
|
|
1354
|
+
assertExists: {
|
|
1355
|
+
required: { selector: { type: "string|string[]" } },
|
|
1356
|
+
optional: {}
|
|
1357
|
+
},
|
|
1358
|
+
assertText: {
|
|
1359
|
+
required: {},
|
|
1360
|
+
optional: {
|
|
1361
|
+
selector: { type: "string|string[]" },
|
|
1362
|
+
expect: { type: "string" },
|
|
1363
|
+
value: { type: "string" }
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
assertUrl: {
|
|
1367
|
+
required: {},
|
|
1368
|
+
optional: {
|
|
1369
|
+
expect: { type: "string" },
|
|
1370
|
+
url: { type: "string" }
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
assertValue: {
|
|
1374
|
+
required: { selector: { type: "string|string[]" } },
|
|
1375
|
+
optional: {
|
|
1376
|
+
expect: { type: "string" },
|
|
1377
|
+
value: { type: "string" }
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
var VALID_ACTIONS = Object.keys(ACTION_RULES);
|
|
1382
|
+
var VALID_ACTIONS_LIST = VALID_ACTIONS.join(", ");
|
|
1383
|
+
var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
|
|
1384
|
+
"action",
|
|
1385
|
+
"selector",
|
|
1386
|
+
"url",
|
|
1387
|
+
"value",
|
|
1388
|
+
"targetId",
|
|
1389
|
+
"key",
|
|
1390
|
+
"combo",
|
|
1391
|
+
"modifiers",
|
|
1392
|
+
"waitFor",
|
|
1393
|
+
"timeout",
|
|
1394
|
+
"optional",
|
|
1395
|
+
"method",
|
|
1396
|
+
"blur",
|
|
1397
|
+
"delay",
|
|
1398
|
+
"waitForNavigation",
|
|
1399
|
+
"trigger",
|
|
1400
|
+
"option",
|
|
1401
|
+
"match",
|
|
1402
|
+
"x",
|
|
1403
|
+
"y",
|
|
1404
|
+
"direction",
|
|
1405
|
+
"amount",
|
|
1406
|
+
"format",
|
|
1407
|
+
"quality",
|
|
1408
|
+
"fullPage",
|
|
1409
|
+
"expect",
|
|
1410
|
+
"retry",
|
|
1411
|
+
"retryDelay"
|
|
1412
|
+
]);
|
|
1413
|
+
function resolveAction(name) {
|
|
1414
|
+
if (VALID_ACTIONS.includes(name)) {
|
|
1415
|
+
return { action: name };
|
|
1416
|
+
}
|
|
1417
|
+
const lower = name.toLowerCase();
|
|
1418
|
+
if (ACTION_ALIASES[lower]) {
|
|
1419
|
+
return {
|
|
1420
|
+
action: ACTION_ALIASES[lower],
|
|
1421
|
+
suggestion: `Did you mean "${ACTION_ALIASES[lower]}"?`
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
let best = null;
|
|
1425
|
+
let bestDist = Infinity;
|
|
1426
|
+
for (const valid of VALID_ACTIONS) {
|
|
1427
|
+
const dist = levenshtein(lower, valid);
|
|
1428
|
+
if (dist < bestDist) {
|
|
1429
|
+
bestDist = dist;
|
|
1430
|
+
best = valid;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (best && bestDist <= 2) {
|
|
1434
|
+
return { action: best, suggestion: `Did you mean "${best}"?` };
|
|
1435
|
+
}
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
function suggestProperty(name) {
|
|
1439
|
+
if (PROPERTY_ALIASES[name]) {
|
|
1440
|
+
return PROPERTY_ALIASES[name];
|
|
1441
|
+
}
|
|
1442
|
+
let best = null;
|
|
1443
|
+
let bestDist = Infinity;
|
|
1444
|
+
for (const known of KNOWN_STEP_FIELDS) {
|
|
1445
|
+
if (known === "action") continue;
|
|
1446
|
+
const dist = levenshtein(name, known);
|
|
1447
|
+
if (dist < bestDist) {
|
|
1448
|
+
bestDist = dist;
|
|
1449
|
+
best = known;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
if (best && bestDist <= 2) {
|
|
1453
|
+
return best;
|
|
1454
|
+
}
|
|
1455
|
+
return void 0;
|
|
1456
|
+
}
|
|
1457
|
+
function checkFieldType(value, rule) {
|
|
1458
|
+
switch (rule.type) {
|
|
1459
|
+
case "string":
|
|
1460
|
+
if (typeof value !== "string") return `expected string, got ${typeof value}`;
|
|
1461
|
+
if (rule.enum && !rule.enum.includes(value)) {
|
|
1462
|
+
return `must be one of: ${rule.enum.join(", ")}`;
|
|
1463
|
+
}
|
|
1464
|
+
return null;
|
|
1465
|
+
case "string|string[]":
|
|
1466
|
+
if (typeof value !== "string" && !Array.isArray(value)) {
|
|
1467
|
+
return `expected string or string[], got ${typeof value}`;
|
|
1468
|
+
}
|
|
1469
|
+
if (Array.isArray(value) && value.some((v) => typeof v !== "string")) {
|
|
1470
|
+
return "array elements must be strings";
|
|
1471
|
+
}
|
|
1472
|
+
return null;
|
|
1473
|
+
case "number":
|
|
1474
|
+
if (typeof value !== "number") return `expected number, got ${typeof value}`;
|
|
1475
|
+
return null;
|
|
1476
|
+
case "boolean":
|
|
1477
|
+
if (typeof value !== "boolean") return `expected boolean, got ${typeof value}`;
|
|
1478
|
+
return null;
|
|
1479
|
+
case "boolean|auto":
|
|
1480
|
+
if (typeof value !== "boolean" && value !== "auto") {
|
|
1481
|
+
return `expected boolean or "auto", got ${typeof value}`;
|
|
1482
|
+
}
|
|
1483
|
+
return null;
|
|
1484
|
+
default: {
|
|
1485
|
+
const _exhaustive = rule.type;
|
|
1486
|
+
return `unknown type: ${_exhaustive}`;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
function validateSteps(steps) {
|
|
1491
|
+
const errors = [];
|
|
1492
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1493
|
+
const step = steps[i];
|
|
1494
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
1495
|
+
errors.push({
|
|
1496
|
+
stepIndex: i,
|
|
1497
|
+
field: "step",
|
|
1498
|
+
message: "step must be a JSON object."
|
|
1499
|
+
});
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
const obj = step;
|
|
1503
|
+
if (!("action" in obj)) {
|
|
1504
|
+
errors.push({
|
|
1505
|
+
stepIndex: i,
|
|
1506
|
+
field: "action",
|
|
1507
|
+
message: 'missing required "action" field.'
|
|
1508
|
+
});
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
const actionName = obj["action"];
|
|
1512
|
+
if (typeof actionName !== "string") {
|
|
1513
|
+
errors.push({
|
|
1514
|
+
stepIndex: i,
|
|
1515
|
+
field: "action",
|
|
1516
|
+
message: `"action" must be a string, got ${typeof actionName}.`
|
|
1517
|
+
});
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
const resolved = resolveAction(actionName);
|
|
1521
|
+
if (!resolved) {
|
|
1522
|
+
errors.push({
|
|
1523
|
+
stepIndex: i,
|
|
1524
|
+
field: "action",
|
|
1525
|
+
message: `unknown action "${actionName}".`,
|
|
1526
|
+
suggestion: `Valid actions: ${VALID_ACTIONS_LIST}`
|
|
1527
|
+
});
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (resolved.suggestion) {
|
|
1531
|
+
errors.push({
|
|
1532
|
+
stepIndex: i,
|
|
1533
|
+
field: "action",
|
|
1534
|
+
message: `unknown action "${actionName}". ${resolved.suggestion}`,
|
|
1535
|
+
suggestion: resolved.suggestion
|
|
1536
|
+
});
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const action = resolved.action;
|
|
1540
|
+
const rule = ACTION_RULES[action];
|
|
1541
|
+
for (const key of Object.keys(obj)) {
|
|
1542
|
+
if (key === "action") continue;
|
|
1543
|
+
if (KNOWN_STEP_FIELDS.has(key)) continue;
|
|
1544
|
+
const canonical = PROPERTY_ALIASES[key];
|
|
1545
|
+
if (canonical) {
|
|
1546
|
+
if (!(canonical in obj)) {
|
|
1547
|
+
obj[canonical] = obj[key];
|
|
1548
|
+
}
|
|
1549
|
+
delete obj[key];
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
const suggestion = suggestProperty(key);
|
|
1553
|
+
errors.push({
|
|
1554
|
+
stepIndex: i,
|
|
1555
|
+
field: key,
|
|
1556
|
+
message: suggestion ? `unknown property "${key}". Did you mean "${suggestion}"?` : `unknown property "${key}".`,
|
|
1557
|
+
suggestion: suggestion ? `Did you mean "${suggestion}"?` : void 0
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
for (const [field, fieldRule] of Object.entries(rule.required)) {
|
|
1561
|
+
if (!(field in obj) || obj[field] === void 0) {
|
|
1562
|
+
errors.push({
|
|
1563
|
+
stepIndex: i,
|
|
1564
|
+
field,
|
|
1565
|
+
message: `missing required "${field}" (${fieldRule.type}).`
|
|
1566
|
+
});
|
|
1567
|
+
} else {
|
|
1568
|
+
const typeErr = checkFieldType(obj[field], fieldRule);
|
|
1569
|
+
if (typeErr) {
|
|
1570
|
+
errors.push({
|
|
1571
|
+
stepIndex: i,
|
|
1572
|
+
field,
|
|
1573
|
+
message: `"${field}" ${typeErr}.`
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
for (const [field, fieldRule] of Object.entries(rule.optional)) {
|
|
1579
|
+
if (field in obj && obj[field] !== void 0) {
|
|
1580
|
+
const typeErr = checkFieldType(obj[field], fieldRule);
|
|
1581
|
+
if (typeErr) {
|
|
1582
|
+
errors.push({
|
|
1583
|
+
stepIndex: i,
|
|
1584
|
+
field,
|
|
1585
|
+
message: `"${field}" ${typeErr}.`
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if ("timeout" in obj && obj["timeout"] !== void 0) {
|
|
1591
|
+
if (typeof obj["timeout"] !== "number") {
|
|
1592
|
+
errors.push({
|
|
1593
|
+
stepIndex: i,
|
|
1594
|
+
field: "timeout",
|
|
1595
|
+
message: `"timeout" expected number, got ${typeof obj["timeout"]}.`
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if ("optional" in obj && obj["optional"] !== void 0) {
|
|
1600
|
+
if (typeof obj["optional"] !== "boolean") {
|
|
1601
|
+
errors.push({
|
|
1602
|
+
stepIndex: i,
|
|
1603
|
+
field: "optional",
|
|
1604
|
+
message: `"optional" expected boolean, got ${typeof obj["optional"]}.`
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if ("retry" in obj && obj["retry"] !== void 0) {
|
|
1609
|
+
if (typeof obj["retry"] !== "number") {
|
|
1610
|
+
errors.push({
|
|
1611
|
+
stepIndex: i,
|
|
1612
|
+
field: "retry",
|
|
1613
|
+
message: `"retry" expected number, got ${typeof obj["retry"]}.`
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
if ("retryDelay" in obj && obj["retryDelay"] !== void 0) {
|
|
1618
|
+
if (typeof obj["retryDelay"] !== "number") {
|
|
1619
|
+
errors.push({
|
|
1620
|
+
stepIndex: i,
|
|
1621
|
+
field: "retryDelay",
|
|
1622
|
+
message: `"retryDelay" expected number, got ${typeof obj["retryDelay"]}.`
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (action === "assertText") {
|
|
1627
|
+
if (!("expect" in obj) && !("value" in obj)) {
|
|
1628
|
+
errors.push({
|
|
1629
|
+
stepIndex: i,
|
|
1630
|
+
field: "expect",
|
|
1631
|
+
message: 'assertText requires "expect" or "value" containing the expected text.'
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (action === "assertUrl") {
|
|
1636
|
+
if (!("expect" in obj) && !("url" in obj)) {
|
|
1637
|
+
errors.push({
|
|
1638
|
+
stepIndex: i,
|
|
1639
|
+
field: "expect",
|
|
1640
|
+
message: 'assertUrl requires "expect" or "url" containing the expected URL substring.'
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (action === "assertValue") {
|
|
1645
|
+
if (!("expect" in obj) && !("value" in obj)) {
|
|
1646
|
+
errors.push({
|
|
1647
|
+
stepIndex: i,
|
|
1648
|
+
field: "expect",
|
|
1649
|
+
message: 'assertValue requires "expect" or "value" containing the expected value.'
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
if (action === "select") {
|
|
1654
|
+
const hasNative = "selector" in obj && "value" in obj;
|
|
1655
|
+
const hasCustom = "trigger" in obj && "option" in obj && "value" in obj;
|
|
1656
|
+
if (!hasNative && !hasCustom) {
|
|
1657
|
+
errors.push({
|
|
1658
|
+
stepIndex: i,
|
|
1659
|
+
field: "selector",
|
|
1660
|
+
message: "select requires either (selector + value) for native select, or (trigger + option + value) for custom select."
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return {
|
|
1666
|
+
valid: errors.length === 0,
|
|
1667
|
+
errors,
|
|
1668
|
+
formatted() {
|
|
1669
|
+
if (errors.length === 0) return "";
|
|
1670
|
+
const lines = [`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):`];
|
|
1671
|
+
for (const err of errors) {
|
|
1672
|
+
const stepLabel = err.field === "action" || err.field === "step" ? `Step ${err.stepIndex}` : `Step ${err.stepIndex}`;
|
|
1673
|
+
lines.push("");
|
|
1674
|
+
lines.push(` ${stepLabel}: ${err.message}`);
|
|
1675
|
+
if (err.suggestion && !err.message.includes(err.suggestion)) {
|
|
1676
|
+
lines.push(` ${err.suggestion}`);
|
|
1677
|
+
}
|
|
1678
|
+
const step = steps[err.stepIndex];
|
|
1679
|
+
if (step && typeof step === "object") {
|
|
1680
|
+
lines.push(` Got: ${JSON.stringify(step)}`);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const hasEvaluateError = errors.some((err) => {
|
|
1684
|
+
const step = steps[err.stepIndex];
|
|
1685
|
+
return step && typeof step === "object" && step["action"] === "evaluate";
|
|
1686
|
+
});
|
|
1687
|
+
if (hasEvaluateError) {
|
|
1688
|
+
lines.push("");
|
|
1689
|
+
lines.push(
|
|
1690
|
+
"Tip: For JavaScript evaluation, use 'bp eval' instead \u2014 no JSON wrapping needed:"
|
|
1691
|
+
);
|
|
1692
|
+
lines.push(" bp eval 'your.expression.here'");
|
|
1693
|
+
}
|
|
1694
|
+
lines.push("");
|
|
1695
|
+
lines.push(`Valid actions: ${VALID_ACTIONS_LIST}`);
|
|
1696
|
+
return lines.join("\n");
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
export {
|
|
1702
|
+
ActionabilityError,
|
|
1703
|
+
ensureActionable,
|
|
1704
|
+
generateHints,
|
|
1705
|
+
ElementNotFoundError,
|
|
1706
|
+
TimeoutError,
|
|
1707
|
+
NavigationError,
|
|
1708
|
+
BatchExecutor,
|
|
1709
|
+
addBatchToPage,
|
|
1710
|
+
validateSteps
|
|
1711
|
+
};
|