aether-mcp-server 2.0.2 → 2.1.1
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/dist/bridge/debugging.js +133 -0
- package/dist/bridge/inspection.js +302 -0
- package/dist/bridge/interaction.js +586 -0
- package/dist/bridge/navigation.js +146 -0
- package/dist/bridge/session.js +287 -0
- package/dist/cdp-bridge.js +598 -1981
- package/dist/cdp-client.js +232 -366
- package/dist/element-collector.js +198 -0
- package/dist/eval-scripts.js +1024 -0
- package/dist/index.js +16 -28
- package/dist/locator-engine.js +21 -259
- package/dist/logger.js +105 -0
- package/dist/mcp-server.js +59 -0
- package/dist/page-snapshot-cache.js +17 -2
- package/dist/types.js +267 -0
- package/package.json +1 -1
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Browser interaction functions extracted from cdp-bridge.ts.
|
|
4
|
+
*
|
|
5
|
+
* Each function takes a CdpClient, LocatorEngine, PageSnapshotCache, params,
|
|
6
|
+
* and Logger and returns a result. This module is designed to be consumed by
|
|
7
|
+
* the bridge layer and can also be used directly by other parts of the server.
|
|
8
|
+
*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
21
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
22
|
+
}) : function(o, v) {
|
|
23
|
+
o["default"] = v;
|
|
24
|
+
});
|
|
25
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
26
|
+
var ownKeys = function(o) {
|
|
27
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
28
|
+
var ar = [];
|
|
29
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
30
|
+
return ar;
|
|
31
|
+
};
|
|
32
|
+
return ownKeys(o);
|
|
33
|
+
};
|
|
34
|
+
return function (mod) {
|
|
35
|
+
if (mod && mod.__esModule) return mod;
|
|
36
|
+
var result = {};
|
|
37
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
38
|
+
__setModuleDefault(result, mod);
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.captureActionFacts = captureActionFacts;
|
|
44
|
+
exports.diffActionFacts = diffActionFacts;
|
|
45
|
+
exports.clickResolvedLocator = clickResolvedLocator;
|
|
46
|
+
exports.click = click;
|
|
47
|
+
exports.clickElement = clickElement;
|
|
48
|
+
exports.clickElementBySelector = clickElementBySelector;
|
|
49
|
+
exports.clickElementByText = clickElementByText;
|
|
50
|
+
exports.type = type;
|
|
51
|
+
exports.fillInput = fillInput;
|
|
52
|
+
exports.selectOption = selectOption;
|
|
53
|
+
exports.checkElement = checkElement;
|
|
54
|
+
exports.setChecked = setChecked;
|
|
55
|
+
exports.hover = hover;
|
|
56
|
+
exports.dragAndDrop = dragAndDrop;
|
|
57
|
+
exports.pressKey = pressKey;
|
|
58
|
+
exports.scroll = scroll;
|
|
59
|
+
exports.wait = wait;
|
|
60
|
+
exports.elementAtPoint = elementAtPoint;
|
|
61
|
+
exports.clickByRef = clickByRef;
|
|
62
|
+
exports.clickBySelector = clickBySelector;
|
|
63
|
+
exports.fillBySelector = fillBySelector;
|
|
64
|
+
exports.clickText = clickText;
|
|
65
|
+
exports.clickRole = clickRole;
|
|
66
|
+
exports.fillLabel = fillLabel;
|
|
67
|
+
const Eval = __importStar(require("../eval-scripts"));
|
|
68
|
+
// ─── Action Facts Helpers ─────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Capture the current state of the active element (and optional target selector)
|
|
71
|
+
* immediately before or after an action. Returns a facts object with URL, title,
|
|
72
|
+
* focused element details, target element details, and any visible error messages.
|
|
73
|
+
*/
|
|
74
|
+
async function captureActionFacts(client, selector, _logger) {
|
|
75
|
+
return await client.evaluate(Eval.makeActionFactsScript(selector)).catch(() => ({}));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Diff before/after action facts to surface what changed.
|
|
79
|
+
*/
|
|
80
|
+
function diffActionFacts(before, after) {
|
|
81
|
+
return {
|
|
82
|
+
urlChanged: before?.url !== after?.url,
|
|
83
|
+
titleChanged: before?.title !== after?.title,
|
|
84
|
+
focused: after?.focused,
|
|
85
|
+
target: after?.target,
|
|
86
|
+
valueChanged: before?.target?.value !== after?.target?.value,
|
|
87
|
+
checkedChanged: before?.target?.checked !== after?.target?.checked,
|
|
88
|
+
selectedIndexChanged: before?.target?.selectedIndex !== after?.target?.selectedIndex,
|
|
89
|
+
visibleErrors: after?.visibleErrors || [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ─── Resolve Actionable Point ─────────────────────────────────────────────
|
|
93
|
+
// ─── Click Resolved Locator ───────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Click a {@link LocatorCandidate} resolved by the locator engine.
|
|
96
|
+
* Uses selector-based click for in-document elements; falls back to
|
|
97
|
+
* coordinate click for frame/shadow elements.
|
|
98
|
+
*/
|
|
99
|
+
async function clickResolvedLocator(client, locator, snapshotCache, candidate, logger) {
|
|
100
|
+
if (!candidate)
|
|
101
|
+
throw new Error('Resolved locator missing candidate details');
|
|
102
|
+
if (candidate.scope === 'document' &&
|
|
103
|
+
candidate.framePath.length === 0 &&
|
|
104
|
+
candidate.shadowDepth === 0 &&
|
|
105
|
+
candidate.selector) {
|
|
106
|
+
await clickElementBySelector(client, locator, snapshotCache, { selector: candidate.selector }, logger);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await locator.click(candidate);
|
|
110
|
+
}
|
|
111
|
+
// ─── click ────────────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Click at absolute coordinates.
|
|
114
|
+
*/
|
|
115
|
+
async function click(client, _locator, snapshotCache, params, _logger) {
|
|
116
|
+
const x = params.x ?? (Number(params.coordinate?.split(',')[0]) || 100);
|
|
117
|
+
const y = params.y ?? (Number(params.coordinate?.split(',')[1]) || 100);
|
|
118
|
+
await client.click(x, y);
|
|
119
|
+
snapshotCache.invalidate('click');
|
|
120
|
+
return 'Clicked';
|
|
121
|
+
}
|
|
122
|
+
// ─── clickElement ─────────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Click an element by its Set-of-Marks ID.
|
|
125
|
+
* Falls back to selector, text, or coordinate click if ID resolution fails.
|
|
126
|
+
*/
|
|
127
|
+
async function clickElement(client, locator, snapshotCache, params, logger) {
|
|
128
|
+
// Click by element ID (from SoM) — resolves ID to coordinates
|
|
129
|
+
if (params.id !== undefined) {
|
|
130
|
+
const result = await client.evaluate(Eval.makeClickByIdScript(String(params.id)));
|
|
131
|
+
if (result) {
|
|
132
|
+
await client.click(result.x, result.y, params.button, result.w);
|
|
133
|
+
snapshotCache.invalidate('click_element');
|
|
134
|
+
return `Clicked element @${params.id}`;
|
|
135
|
+
}
|
|
136
|
+
// Fallback: try to find element by selector or text
|
|
137
|
+
if (params.selector) {
|
|
138
|
+
return clickElementBySelector(client, locator, snapshotCache, { selector: params.selector }, logger);
|
|
139
|
+
}
|
|
140
|
+
if (params.text) {
|
|
141
|
+
return clickElementByText(client, locator, snapshotCache, { text: params.text }, logger);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Fallback to coordinate click
|
|
145
|
+
if (params.x !== undefined && params.y !== undefined) {
|
|
146
|
+
await client.click(params.x, params.y);
|
|
147
|
+
snapshotCache.invalidate('click_element');
|
|
148
|
+
return 'Clicked at coordinates';
|
|
149
|
+
}
|
|
150
|
+
throw new Error('Element not found: no valid id, selector, text, or coordinates provided');
|
|
151
|
+
}
|
|
152
|
+
// ─── clickElementBySelector ───────────────────────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* Click an element by CSS selector — fast path. Gets element center via CDP
|
|
155
|
+
* DOM.getBoxModel and clicks directly. No obscurity/polling/actionability gate.
|
|
156
|
+
*/
|
|
157
|
+
async function clickElementBySelector(client, _locator, snapshotCache, params, _logger) {
|
|
158
|
+
const selector = params.selector;
|
|
159
|
+
if (!selector)
|
|
160
|
+
throw new Error('Selector required');
|
|
161
|
+
if (String(selector).startsWith('point:')) {
|
|
162
|
+
const [x, y] = String(selector).slice(6).split(',').map(Number);
|
|
163
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
164
|
+
throw new Error(`Invalid point selector: ${selector}`);
|
|
165
|
+
await client.click(x, y);
|
|
166
|
+
snapshotCache.invalidate('click_element_by_selector');
|
|
167
|
+
return 'Clicked element by point';
|
|
168
|
+
}
|
|
169
|
+
const center = await client.getElementCenter(selector);
|
|
170
|
+
if (!center)
|
|
171
|
+
throw new Error(`Element not found or not visible: ${selector}`);
|
|
172
|
+
await client.click(center.x, center.y, params.button, center.width);
|
|
173
|
+
snapshotCache.invalidate('click_element_by_selector');
|
|
174
|
+
return 'Clicked element by selector';
|
|
175
|
+
}
|
|
176
|
+
// ─── clickElementByText ───────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Resolve an element by visible text and click it.
|
|
179
|
+
*/
|
|
180
|
+
async function clickElementByText(client, locator, snapshotCache, params, logger) {
|
|
181
|
+
const result = await locator.resolve({
|
|
182
|
+
target: params.text,
|
|
183
|
+
timeout: params.timeout ?? 5000,
|
|
184
|
+
});
|
|
185
|
+
if (result.success && result.candidate) {
|
|
186
|
+
await clickResolvedLocator(client, locator, snapshotCache, result.candidate, logger);
|
|
187
|
+
snapshotCache.invalidate('click_element_by_text');
|
|
188
|
+
return `Clicked element with text: ${params.text}`;
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`Element with text not found: ${params.text}`);
|
|
191
|
+
}
|
|
192
|
+
// ─── type ─────────────────────────────────────────────────────────────────
|
|
193
|
+
/**
|
|
194
|
+
* Type text at the current focus location.
|
|
195
|
+
*/
|
|
196
|
+
async function type(client, _locator, _snapshotCache, params, _logger) {
|
|
197
|
+
const text = params.text || params.value || '';
|
|
198
|
+
await client.typeText(text);
|
|
199
|
+
return 'Typed text';
|
|
200
|
+
}
|
|
201
|
+
// ─── fillInput ────────────────────────────────────────────────────────────
|
|
202
|
+
/**
|
|
203
|
+
* Fill an input element identified by CSS selector. Waits for the element,
|
|
204
|
+
* clears any existing content, then types the provided value.
|
|
205
|
+
*/
|
|
206
|
+
async function fillInput(client, _locator, snapshotCache, params, logger) {
|
|
207
|
+
const selector = params.selector;
|
|
208
|
+
const text = params.value || params.text || '';
|
|
209
|
+
if (selector) {
|
|
210
|
+
logger.debug('fillInput: waiting for selector', { selector });
|
|
211
|
+
await client.waitForSelector(selector);
|
|
212
|
+
await client.moveMouseToSelector(selector).catch(() => { });
|
|
213
|
+
// Focus via native CDP
|
|
214
|
+
const nodeId = await client.querySelectorNodeId(selector).catch(() => null);
|
|
215
|
+
if (nodeId) {
|
|
216
|
+
await client.focusNode(nodeId).catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
// Clear value + dispatch events
|
|
219
|
+
await client.evaluate(`(function(){var el=document.querySelector(${JSON.stringify(selector)});if(el){el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));return true;}return false;})()`);
|
|
220
|
+
}
|
|
221
|
+
await client.typeText(text);
|
|
222
|
+
snapshotCache.invalidate('fill');
|
|
223
|
+
return `Filled with: ${text}`;
|
|
224
|
+
}
|
|
225
|
+
// ─── selectOption ─────────────────────────────────────────────────────────
|
|
226
|
+
/**
|
|
227
|
+
* Select an option within a <select> element. Inspects the select to find
|
|
228
|
+
* the target option, then uses keyboard navigation (for short option lists)
|
|
229
|
+
* or a JavaScript fallback to set the value.
|
|
230
|
+
*/
|
|
231
|
+
async function selectOption(client, locator, snapshotCache, params, logger) {
|
|
232
|
+
const selector = params.selector;
|
|
233
|
+
const value = params.value || '';
|
|
234
|
+
if (!selector)
|
|
235
|
+
throw new Error('Selector required for select action');
|
|
236
|
+
await client.waitForSelector(selector);
|
|
237
|
+
const selectInfo = await client.evaluate(Eval.makeSelectInspectScript(selector, value));
|
|
238
|
+
if (!selectInfo?.success)
|
|
239
|
+
throw new Error(selectInfo?.error || 'Failed to inspect select');
|
|
240
|
+
// Already at the wanted value — nothing to do
|
|
241
|
+
if (selectInfo.selectedValue === selectInfo.wantedValue) {
|
|
242
|
+
return `Selected option: ${value}`;
|
|
243
|
+
}
|
|
244
|
+
// Try keyboard navigation for reasonable-size selects
|
|
245
|
+
if (selectInfo.index >= 0 && selectInfo.index <= 40) {
|
|
246
|
+
try {
|
|
247
|
+
await clickElementBySelector(client, locator, snapshotCache, { selector }, logger);
|
|
248
|
+
await client.pressKey('Home');
|
|
249
|
+
for (let i = 0; i < selectInfo.index; i++) {
|
|
250
|
+
await client.pressKey('ArrowDown');
|
|
251
|
+
}
|
|
252
|
+
await client.pressKey('Enter');
|
|
253
|
+
const verified = await client.evaluate(`
|
|
254
|
+
(function() {
|
|
255
|
+
const select = document.querySelector(${JSON.stringify(selector)});
|
|
256
|
+
return select ? select.value : null;
|
|
257
|
+
})()
|
|
258
|
+
`);
|
|
259
|
+
if (verified === selectInfo.wantedValue) {
|
|
260
|
+
return `Selected option: ${value}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Fall back to direct value setting below
|
|
265
|
+
logger.debug('selectOption: keyboard nav failed, using JS fallback', {
|
|
266
|
+
selector,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// JS fallback: set value and fire events
|
|
271
|
+
const result = await client.evaluate(Eval.makeSetValueScript(selector, selectInfo.wantedValue));
|
|
272
|
+
if (result?.success)
|
|
273
|
+
return `Selected option: ${value}`;
|
|
274
|
+
throw new Error(result?.error || 'Failed to select option');
|
|
275
|
+
}
|
|
276
|
+
// ─── checkElement / setChecked ────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* Check a checkbox or radio input. Delegates to {@link setChecked}.
|
|
279
|
+
*/
|
|
280
|
+
async function checkElement(client, locator, snapshotCache, params, logger) {
|
|
281
|
+
if (!params.selector)
|
|
282
|
+
throw new Error('Selector required for check action');
|
|
283
|
+
return setChecked(client, locator, snapshotCache, { selector: params.selector, checked: true }, logger);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Set the checked state of a checkbox or radio input.
|
|
287
|
+
* Inspects current state first, then tries a real click; falls back to
|
|
288
|
+
* direct property assignment with event dispatch.
|
|
289
|
+
*/
|
|
290
|
+
async function setChecked(client, locator, snapshotCache, params, logger) {
|
|
291
|
+
const selector = params.selector;
|
|
292
|
+
if (!selector)
|
|
293
|
+
throw new Error('Selector required for checked state');
|
|
294
|
+
await client.waitForSelector(selector);
|
|
295
|
+
const before = await client.evaluate(Eval.makeCheckedStateScript(selector));
|
|
296
|
+
if (!before?.success)
|
|
297
|
+
throw new Error(before?.error || 'Failed to inspect checked state');
|
|
298
|
+
const wanted = !!params.checked;
|
|
299
|
+
if (before.checked === wanted)
|
|
300
|
+
return `Checked state set to ${wanted}`;
|
|
301
|
+
// Don't try clicking a radio to uncheck (radios can't be unchecked by click)
|
|
302
|
+
if (!(before.type === 'radio' && !wanted)) {
|
|
303
|
+
try {
|
|
304
|
+
await clickElementBySelector(client, locator, snapshotCache, { selector }, logger);
|
|
305
|
+
const afterClick = await client.evaluate(`
|
|
306
|
+
(function() {
|
|
307
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
308
|
+
return el ? !!el.checked : null;
|
|
309
|
+
})()
|
|
310
|
+
`);
|
|
311
|
+
if (afterClick === wanted)
|
|
312
|
+
return `Checked state set to ${wanted}`;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
logger.debug('setChecked: click failed, using JS fallback', {
|
|
316
|
+
selector,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// JS fallback
|
|
321
|
+
const result = await client.evaluate(Eval.makeSetCheckedScript(selector, wanted));
|
|
322
|
+
if (result?.success)
|
|
323
|
+
return `Checked state set to ${wanted}`;
|
|
324
|
+
throw new Error(result?.error || 'Failed to set checked state');
|
|
325
|
+
}
|
|
326
|
+
// ─── hover ────────────────────────────────────────────────────────────────
|
|
327
|
+
/**
|
|
328
|
+
* Move the mouse cursor to the given coordinates without clicking.
|
|
329
|
+
*/
|
|
330
|
+
async function hover(client, _locator, _snapshotCache, params, _logger) {
|
|
331
|
+
const x = params.x ??
|
|
332
|
+
(params.coordinate ? Number(params.coordinate.split(',')[0]) : 100);
|
|
333
|
+
const y = params.y ??
|
|
334
|
+
(params.coordinate ? Number(params.coordinate.split(',')[1]) : 100);
|
|
335
|
+
await client.moveMouse(x, y);
|
|
336
|
+
return 'Hovered';
|
|
337
|
+
}
|
|
338
|
+
// ─── dragAndDrop ──────────────────────────────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Perform a drag-and-drop operation from (startX, startY) to (endX, endY).
|
|
341
|
+
*/
|
|
342
|
+
async function dragAndDrop(client, _locator, _snapshotCache, params, _logger) {
|
|
343
|
+
const startX = params.startX ?? 0;
|
|
344
|
+
const startY = params.startY ?? 0;
|
|
345
|
+
const endX = params.endX ?? 0;
|
|
346
|
+
const endY = params.endY ?? 0;
|
|
347
|
+
await client.moveMouse(startX, startY);
|
|
348
|
+
await client.sendCommand('Input.dispatchMouseEvent', {
|
|
349
|
+
type: 'mousePressed',
|
|
350
|
+
x: startX,
|
|
351
|
+
y: startY,
|
|
352
|
+
button: 'left',
|
|
353
|
+
clickCount: 1,
|
|
354
|
+
});
|
|
355
|
+
await client.moveMouse(endX, endY);
|
|
356
|
+
await client.sendCommand('Input.dispatchMouseEvent', {
|
|
357
|
+
type: 'mouseReleased',
|
|
358
|
+
x: endX,
|
|
359
|
+
y: endY,
|
|
360
|
+
button: 'left',
|
|
361
|
+
clickCount: 1,
|
|
362
|
+
});
|
|
363
|
+
return 'Dragged and dropped';
|
|
364
|
+
}
|
|
365
|
+
// ─── pressKey ─────────────────────────────────────────────────────────────
|
|
366
|
+
/**
|
|
367
|
+
* Press a single key with optional modifiers. Invalidates the snapshot cache
|
|
368
|
+
* since key presses may change page state.
|
|
369
|
+
*/
|
|
370
|
+
async function pressKey(client, _locator, snapshotCache, params, _logger) {
|
|
371
|
+
const key = String(params.key || params.value || '');
|
|
372
|
+
if (!key)
|
|
373
|
+
throw new Error('key required');
|
|
374
|
+
const modifiers = Array.isArray(params.modifiers)
|
|
375
|
+
? params.modifiers.map(String)
|
|
376
|
+
: [];
|
|
377
|
+
await client.pressKey(key, modifiers);
|
|
378
|
+
snapshotCache.invalidate('press_key');
|
|
379
|
+
return { success: true, key, modifiers };
|
|
380
|
+
}
|
|
381
|
+
// ─── scroll ───────────────────────────────────────────────────────────────
|
|
382
|
+
/**
|
|
383
|
+
* Scroll the page by the given delta amounts. Optionally at a specific origin.
|
|
384
|
+
*/
|
|
385
|
+
async function scroll(client, _locator, _snapshotCache, params, _logger) {
|
|
386
|
+
const deltaX = params.x ?? 0;
|
|
387
|
+
const deltaY = params.y ?? 0;
|
|
388
|
+
const originX = params.originX ?? params.mouseX ?? params.options?.originX;
|
|
389
|
+
const originY = params.originY ?? params.mouseY ?? params.options?.originY;
|
|
390
|
+
await client.wheel(deltaX, deltaY, originX, originY);
|
|
391
|
+
return 'Scrolled';
|
|
392
|
+
}
|
|
393
|
+
// ─── wait ─────────────────────────────────────────────────────────────────
|
|
394
|
+
/**
|
|
395
|
+
* Pause execution for a specified number of milliseconds.
|
|
396
|
+
*/
|
|
397
|
+
async function wait(_client, _locator, _snapshotCache, params, _logger) {
|
|
398
|
+
const ms = params.ms ?? params.timeout ?? 1000;
|
|
399
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
400
|
+
return 'Waited';
|
|
401
|
+
}
|
|
402
|
+
// ─── elementAtPoint ───────────────────────────────────────────────────────
|
|
403
|
+
/**
|
|
404
|
+
* Inspect the DOM element at absolute viewport coordinates (x, y).
|
|
405
|
+
*/
|
|
406
|
+
async function elementAtPoint(client, _locator, _snapshotCache, params, _logger) {
|
|
407
|
+
const x = params.x ?? Number(String(params.coordinate || '').split(',')[0]);
|
|
408
|
+
const y = params.y ?? Number(String(params.coordinate || '').split(',')[1]);
|
|
409
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
410
|
+
throw new Error('x/y or coordinate required');
|
|
411
|
+
return await client.evaluate(Eval.makeElementAtPointScript(x, y));
|
|
412
|
+
}
|
|
413
|
+
// ─── clickByRef ───────────────────────────────────────────────────────────
|
|
414
|
+
/**
|
|
415
|
+
* Click an element by its canonical ref string (css:, point:, or @id).
|
|
416
|
+
*/
|
|
417
|
+
async function clickByRef(client, locator, snapshotCache, params, logger) {
|
|
418
|
+
const ref = String(params.ref || '');
|
|
419
|
+
if (!ref)
|
|
420
|
+
throw new Error('ref required');
|
|
421
|
+
if (ref.startsWith('css:')) {
|
|
422
|
+
return clickBySelector(client, locator, snapshotCache, { selector: ref.slice(4), timeout: params.timeout }, logger);
|
|
423
|
+
}
|
|
424
|
+
if (ref.startsWith('point:')) {
|
|
425
|
+
const [x, y] = ref
|
|
426
|
+
.slice(6)
|
|
427
|
+
.split(',')
|
|
428
|
+
.map(Number);
|
|
429
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
430
|
+
throw new Error(`Invalid point ref: ${ref}`);
|
|
431
|
+
await client.click(x, y);
|
|
432
|
+
snapshotCache.invalidate('click_by_ref');
|
|
433
|
+
return { success: true, ref };
|
|
434
|
+
}
|
|
435
|
+
if (ref.startsWith('@') || ref.startsWith('som:')) {
|
|
436
|
+
const id = ref.replace(/^som:/, '').replace(/^@/, '');
|
|
437
|
+
await clickElement(client, locator, snapshotCache, { id }, logger);
|
|
438
|
+
return { success: true, ref };
|
|
439
|
+
}
|
|
440
|
+
throw new Error(`Unsupported element ref: ${ref}`);
|
|
441
|
+
}
|
|
442
|
+
// ─── clickBySelector ──────────────────────────────────────────────────────
|
|
443
|
+
/**
|
|
444
|
+
* Wait for a CSS selector, then click it. Wraps {@link clickElementBySelector}
|
|
445
|
+
* with a wait-for-selector step and before/after action facts.
|
|
446
|
+
*/
|
|
447
|
+
async function clickBySelector(client, locator, snapshotCache, params, logger) {
|
|
448
|
+
const selector = params.selector;
|
|
449
|
+
if (!selector)
|
|
450
|
+
throw new Error('selector required');
|
|
451
|
+
const found = await client.waitForSelector(selector, params.timeout ?? 5000, {
|
|
452
|
+
visible: params.visible !== false,
|
|
453
|
+
stable: params.stable === true,
|
|
454
|
+
});
|
|
455
|
+
if (!found)
|
|
456
|
+
return {
|
|
457
|
+
success: false,
|
|
458
|
+
selector,
|
|
459
|
+
message: 'Selector not found before timeout',
|
|
460
|
+
};
|
|
461
|
+
await clickElementBySelector(client, locator, snapshotCache, { selector }, logger);
|
|
462
|
+
snapshotCache.invalidate('click_by_selector');
|
|
463
|
+
return { success: true, selector };
|
|
464
|
+
}
|
|
465
|
+
// ─── fillBySelector ───────────────────────────────────────────────────────
|
|
466
|
+
/**
|
|
467
|
+
* Wait for a CSS selector targeting a form input, clear any existing
|
|
468
|
+
* content, and type the provided value. Invalidates the snapshot cache.
|
|
469
|
+
*/
|
|
470
|
+
async function fillBySelector(client, _locator, snapshotCache, params, logger) {
|
|
471
|
+
const selector = params.selector;
|
|
472
|
+
const value = params.value ?? '';
|
|
473
|
+
if (!selector)
|
|
474
|
+
throw new Error('selector required');
|
|
475
|
+
const found = await client.waitForSelector(selector, params.timeout ?? 5000, {
|
|
476
|
+
visible: params.visible !== false,
|
|
477
|
+
stable: params.stable === true,
|
|
478
|
+
});
|
|
479
|
+
if (!found)
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
selector,
|
|
483
|
+
message: 'Selector not found before timeout',
|
|
484
|
+
};
|
|
485
|
+
await client.moveMouseToSelector(selector).catch(() => { });
|
|
486
|
+
// Focus via native CDP (much faster than JS injection)
|
|
487
|
+
const nodeId = await client.querySelectorNodeId(selector).catch(() => null);
|
|
488
|
+
if (nodeId) {
|
|
489
|
+
await client.focusNode(nodeId).catch(() => { });
|
|
490
|
+
}
|
|
491
|
+
// Clear value + dispatch events (still requires page context for React/SPA compatibility)
|
|
492
|
+
const focused = await client.evaluate(`(function(){var el=document.querySelector(${JSON.stringify(selector)});if(!el)return false;if('value'in el){el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));}return true;})()`);
|
|
493
|
+
if (!focused)
|
|
494
|
+
return {
|
|
495
|
+
success: false,
|
|
496
|
+
selector,
|
|
497
|
+
message: 'Selector could not be focused',
|
|
498
|
+
};
|
|
499
|
+
await client.typeText(String(value));
|
|
500
|
+
snapshotCache.invalidate('fill_by_selector');
|
|
501
|
+
return { success: true, selector, length: String(value).length };
|
|
502
|
+
}
|
|
503
|
+
// ─── clickText ────────────────────────────────────────────────────────────
|
|
504
|
+
/**
|
|
505
|
+
* Resolve an element by visible text content and click it.
|
|
506
|
+
* Uses the locator engine for fuzzy text matching.
|
|
507
|
+
*/
|
|
508
|
+
async function clickText(client, locator, snapshotCache, params, logger) {
|
|
509
|
+
const resolved = await locator.resolve({
|
|
510
|
+
target: params.text || params.value || params.target,
|
|
511
|
+
role: params.role,
|
|
512
|
+
timeout: params.timeout ?? 5000,
|
|
513
|
+
includeCandidates: params.includeCandidates,
|
|
514
|
+
});
|
|
515
|
+
if (!resolved.success)
|
|
516
|
+
return resolved;
|
|
517
|
+
await clickResolvedLocator(client, locator, snapshotCache, resolved.candidate, logger);
|
|
518
|
+
snapshotCache.invalidate('click_text');
|
|
519
|
+
return {
|
|
520
|
+
success: true,
|
|
521
|
+
selector: resolved.selector,
|
|
522
|
+
ref: resolved.ref,
|
|
523
|
+
matchedBy: resolved.matchedBy,
|
|
524
|
+
confidence: resolved.confidence,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
// ─── clickRole ────────────────────────────────────────────────────────────
|
|
528
|
+
/**
|
|
529
|
+
* Resolve an element by ARIA role (and optional name) and click it.
|
|
530
|
+
*/
|
|
531
|
+
async function clickRole(client, locator, snapshotCache, params, logger) {
|
|
532
|
+
const resolved = await locator.resolve({
|
|
533
|
+
target: params.name || params.text || params.target || '',
|
|
534
|
+
role: params.role,
|
|
535
|
+
timeout: params.timeout ?? 5000,
|
|
536
|
+
includeCandidates: params.includeCandidates,
|
|
537
|
+
});
|
|
538
|
+
if (!resolved.success)
|
|
539
|
+
return resolved;
|
|
540
|
+
await clickResolvedLocator(client, locator, snapshotCache, resolved.candidate, logger);
|
|
541
|
+
snapshotCache.invalidate('click_role');
|
|
542
|
+
return {
|
|
543
|
+
success: true,
|
|
544
|
+
selector: resolved.selector,
|
|
545
|
+
ref: resolved.ref,
|
|
546
|
+
matchedBy: resolved.matchedBy,
|
|
547
|
+
confidence: resolved.confidence,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
// ─── fillLabel ────────────────────────────────────────────────────────────
|
|
551
|
+
/**
|
|
552
|
+
* Resolve a form field by its associated label text (defaults to role='textbox')
|
|
553
|
+
* and fill it with the given value.
|
|
554
|
+
*/
|
|
555
|
+
async function fillLabel(client, locator, snapshotCache, params, logger) {
|
|
556
|
+
const resolved = await locator.resolve({
|
|
557
|
+
target: params.label || params.target,
|
|
558
|
+
role: params.role || 'textbox',
|
|
559
|
+
timeout: params.timeout ?? 5000,
|
|
560
|
+
includeCandidates: params.includeCandidates,
|
|
561
|
+
});
|
|
562
|
+
if (!resolved.success)
|
|
563
|
+
return resolved;
|
|
564
|
+
// In-document elements can use the fast fillBySelector path
|
|
565
|
+
if (resolved.candidate?.scope === 'document' &&
|
|
566
|
+
resolved.candidate.framePath.length === 0 &&
|
|
567
|
+
resolved.candidate.shadowDepth === 0) {
|
|
568
|
+
return fillBySelector(client, locator, snapshotCache, {
|
|
569
|
+
selector: resolved.selector,
|
|
570
|
+
value: params.value ?? '',
|
|
571
|
+
timeout: params.timeout ?? 5000,
|
|
572
|
+
}, logger);
|
|
573
|
+
}
|
|
574
|
+
// Frame/shadow elements use coordinate focus + clear + type
|
|
575
|
+
await locator.focusAndClear(resolved.candidate);
|
|
576
|
+
await client.typeText(String(params.value ?? ''));
|
|
577
|
+
snapshotCache.invalidate('fill_label');
|
|
578
|
+
return {
|
|
579
|
+
success: true,
|
|
580
|
+
selector: resolved.selector,
|
|
581
|
+
ref: resolved.ref,
|
|
582
|
+
matchedBy: resolved.matchedBy,
|
|
583
|
+
confidence: resolved.confidence,
|
|
584
|
+
length: String(params.value ?? '').length,
|
|
585
|
+
};
|
|
586
|
+
}
|