moltbrowser-mcp-server 1.0.0
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/LICENSE +202 -0
- package/README.md +857 -0
- package/cli.js +24 -0
- package/config.d.ts +241 -0
- package/hub-cli.js +72 -0
- package/index.d.ts +23 -0
- package/index.js +19 -0
- package/package.json +52 -0
- package/src/execution-translator.js +590 -0
- package/src/hub-client.js +246 -0
- package/src/hub-tools.js +968 -0
- package/src/proxy-server.js +660 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution Translator
|
|
3
|
+
*
|
|
4
|
+
* Converts WebMCP Hub execution metadata into Playwright code strings.
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* - Text/textarea/number/date fields → page.locator().fill() (Playwright-native,
|
|
8
|
+
* required for React and other framework-controlled inputs)
|
|
9
|
+
* - Click/submit → page.locator().click() (Playwright-native, required for React)
|
|
10
|
+
* - Select/checkbox/radio with pure CSS selectors → batched into page.evaluate()
|
|
11
|
+
* (these don't need framework-level event simulation)
|
|
12
|
+
* - Playwright-specific selectors (:has-text, :text, >> chains, etc.)
|
|
13
|
+
* → always use page.locator() regardless of field type
|
|
14
|
+
* - Waits → page.waitForSelector() (single CDP call each)
|
|
15
|
+
* - Result extraction → page.evaluate() for CSS selectors, page.locator() for Playwright selectors
|
|
16
|
+
*
|
|
17
|
+
* Supports two modes:
|
|
18
|
+
* 1. Simple mode: fields + submit + result extraction
|
|
19
|
+
* 2. Multi-step mode: steps[] array with action sequences
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// --- Shadow DOM helpers injected into page.evaluate() batches ---
|
|
23
|
+
|
|
24
|
+
// Minified on one line so it can be safely embedded inside any page.evaluate() body,
|
|
25
|
+
// including inline condition-check expressions.
|
|
26
|
+
//
|
|
27
|
+
// Source (before minification):
|
|
28
|
+
// function deepQuery(sel, root = document) {
|
|
29
|
+
// const el = root.querySelector(sel);
|
|
30
|
+
// if (el) return el;
|
|
31
|
+
// for (const h of root.querySelectorAll('*')) {
|
|
32
|
+
// if (h.shadowRoot) { const f = deepQuery(sel, h.shadowRoot); if (f) return f; }
|
|
33
|
+
// }
|
|
34
|
+
// return null;
|
|
35
|
+
// }
|
|
36
|
+
// function deepQueryAll(sel, root = document) {
|
|
37
|
+
// const r = [...root.querySelectorAll(sel)];
|
|
38
|
+
// for (const h of root.querySelectorAll('*')) {
|
|
39
|
+
// if (h.shadowRoot) r.push(...deepQueryAll(sel, h.shadowRoot));
|
|
40
|
+
// }
|
|
41
|
+
// return r;
|
|
42
|
+
// }
|
|
43
|
+
const DEEP_QUERY_FNS = 'function deepQuery(sel,root=document){const el=root.querySelector(sel);if(el)return el;for(const h of root.querySelectorAll(\'*\')){if(h.shadowRoot){const f=deepQuery(sel,h.shadowRoot);if(f)return f;}}return null;}function deepQueryAll(sel,root=document){const r=[...root.querySelectorAll(sel)];for(const h of root.querySelectorAll(\'*\')){if(h.shadowRoot)r.push(...deepQueryAll(sel,h.shadowRoot));}return r;}';
|
|
44
|
+
|
|
45
|
+
// Minified visibility check injected alongside DEEP_QUERY_FNS for condition steps.
|
|
46
|
+
//
|
|
47
|
+
// Source (before minification):
|
|
48
|
+
// function isVisible(el) {
|
|
49
|
+
// if (!el) return false;
|
|
50
|
+
// const s = getComputedStyle(el);
|
|
51
|
+
// if (s.display === 'none') return false;
|
|
52
|
+
// if (s.visibility === 'hidden') return false;
|
|
53
|
+
// if (s.opacity === '0') return false;
|
|
54
|
+
// const r = el.getBoundingClientRect();
|
|
55
|
+
// return !(r.width === 0 && r.height === 0);
|
|
56
|
+
// }
|
|
57
|
+
const IS_VISIBLE_FN = 'function isVisible(el){if(!el)return false;const s=getComputedStyle(el);if(s.display===\'none\')return false;if(s.visibility===\'hidden\')return false;if(s.opacity===\'0\')return false;const r=el.getBoundingClientRect();return!(r.width===0&&r.height===0);}';
|
|
58
|
+
|
|
59
|
+
// --- Playwright selector detection ---
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a selector uses Playwright-specific syntax that won't work
|
|
63
|
+
* with document.querySelector(). These must use page.locator() instead.
|
|
64
|
+
*/
|
|
65
|
+
const PW_SELECTOR_RE = /:has-text\(|:text\(|:text-is\(|:text-matches\(|>> |:visible|:nth-match\(|^role=|^text=|^css=|^xpath=/;
|
|
66
|
+
|
|
67
|
+
function isPlaywrightSelector(sel) {
|
|
68
|
+
return PW_SELECTOR_RE.test(sel);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns true for field types that require native Playwright .fill() to work correctly.
|
|
73
|
+
* DOM value manipulation (page.evaluate) breaks React and other framework-controlled inputs
|
|
74
|
+
* because it bypasses their synthetic event systems. Native Playwright simulates real keyboard
|
|
75
|
+
* input at the browser level, which frameworks respond to correctly.
|
|
76
|
+
*/
|
|
77
|
+
function isNativeFillType(type) {
|
|
78
|
+
return !type || type === 'text' || type === 'textarea' || type === 'number' || type === 'date';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Main entry point ---
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Translate execution metadata + user-provided arguments into a Playwright code string.
|
|
85
|
+
* Returns a complete `async (page) => { ... }` function string that browser_run_code expects.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} execution - The execution metadata from the hub config tool
|
|
88
|
+
* @param {object} args - The arguments the agent provided when calling the tool
|
|
89
|
+
* @returns {string} Playwright code function to execute via browser_run_code
|
|
90
|
+
*/
|
|
91
|
+
function translate(execution, args) {
|
|
92
|
+
let body;
|
|
93
|
+
if (execution.steps && execution.steps.length > 0) {
|
|
94
|
+
body = translateSteps(execution, args);
|
|
95
|
+
} else {
|
|
96
|
+
body = translateSimple(execution, args);
|
|
97
|
+
}
|
|
98
|
+
// Wrap in the async (page) => { ... } format that browser_run_code expects
|
|
99
|
+
return `async (page) => {\n ${body.replace(/\n/g, '\n ')}\n}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Simple mode: batch field fills + submit, wait, then extract.
|
|
104
|
+
* Playwright selectors get individual Playwright API calls;
|
|
105
|
+
* CSS selectors get batched into page.evaluate().
|
|
106
|
+
*/
|
|
107
|
+
function translateSimple(execution, args) {
|
|
108
|
+
const phases = [];
|
|
109
|
+
const batch = [];
|
|
110
|
+
|
|
111
|
+
function flushBatch() {
|
|
112
|
+
if (batch.length > 0) {
|
|
113
|
+
phases.push(`await page.evaluate(() => {\n ${DEEP_QUERY_FNS}\n ${batch.join('\n ')}\n});`);
|
|
114
|
+
batch.length = 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Phase 1: Fill fields
|
|
119
|
+
if (execution.fields) {
|
|
120
|
+
for (const field of execution.fields) {
|
|
121
|
+
const value = args[field.name];
|
|
122
|
+
const resolved = value !== undefined ? value : field.defaultValue;
|
|
123
|
+
if (resolved === undefined) continue;
|
|
124
|
+
|
|
125
|
+
if (isPlaywrightSelector(field.selector) || isNativeFillType(field.type)) {
|
|
126
|
+
flushBatch();
|
|
127
|
+
phases.push(...playwrightFieldAction(field, resolved));
|
|
128
|
+
} else {
|
|
129
|
+
batch.push(...domFieldAction(field, resolved));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Phase 1b: Submit
|
|
135
|
+
if (execution.autosubmit) {
|
|
136
|
+
if (execution.submitAction === 'enter') {
|
|
137
|
+
const lastField = execution.fields && execution.fields.length > 0
|
|
138
|
+
? execution.fields[execution.fields.length - 1]
|
|
139
|
+
: null;
|
|
140
|
+
const sel = lastField ? lastField.selector : execution.selector;
|
|
141
|
+
|
|
142
|
+
if (isPlaywrightSelector(sel)) {
|
|
143
|
+
flushBatch();
|
|
144
|
+
phases.push(`await page.locator(${quote(sel)}).press('Enter');`);
|
|
145
|
+
} else {
|
|
146
|
+
batch.push(
|
|
147
|
+
`{ const _el = deepQuery(${qs(sel)});`,
|
|
148
|
+
` if (_el) {`,
|
|
149
|
+
` _el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));`,
|
|
150
|
+
` _el.dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter', code: 'Enter', bubbles: true }));`,
|
|
151
|
+
` _el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true }));`,
|
|
152
|
+
` const _form = _el.closest('form');`,
|
|
153
|
+
` if (_form) { _form.requestSubmit ? _form.requestSubmit() : _form.submit(); }`,
|
|
154
|
+
` }`,
|
|
155
|
+
`}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
const submitSel = execution.submitSelector || `${execution.selector} [type="submit"], ${execution.selector} button`;
|
|
160
|
+
flushBatch();
|
|
161
|
+
phases.push(`await page.locator(${quote(submitSel)}).first().click();`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
flushBatch();
|
|
166
|
+
|
|
167
|
+
// Phase 2: Wait for results
|
|
168
|
+
addResultWait(phases, execution);
|
|
169
|
+
|
|
170
|
+
// Phase 3: Extract results
|
|
171
|
+
addExtraction(phases, execution.resultSelector, execution.resultExtract || 'text', execution.resultAttribute);
|
|
172
|
+
|
|
173
|
+
return phases.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Multi-step mode: walk through steps, batching consecutive DOM operations
|
|
178
|
+
* into single page.evaluate() calls. Playwright selectors, waits, navigations,
|
|
179
|
+
* extracts, and conditions break the batch.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} opts.noExtraction - Skip result extraction (used for recursive condition branches)
|
|
182
|
+
*/
|
|
183
|
+
function translateSteps(execution, args, opts = {}) {
|
|
184
|
+
const phases = [];
|
|
185
|
+
let batch = [];
|
|
186
|
+
|
|
187
|
+
function flushBatch() {
|
|
188
|
+
if (batch.length > 0) {
|
|
189
|
+
phases.push(`await page.evaluate(() => {\n ${DEEP_QUERY_FNS}\n ${batch.join('\n ')}\n});`);
|
|
190
|
+
batch = [];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const step of execution.steps) {
|
|
195
|
+
const selector = step.selector ? interpolate(step.selector, args) : null;
|
|
196
|
+
const value = step.value ? interpolate(step.value, args) : null;
|
|
197
|
+
|
|
198
|
+
switch (step.action) {
|
|
199
|
+
case 'navigate':
|
|
200
|
+
flushBatch();
|
|
201
|
+
phases.push(`await page.goto(${quote(interpolate(step.url || '', args))});`);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 'click':
|
|
205
|
+
if (selector) {
|
|
206
|
+
flushBatch();
|
|
207
|
+
phases.push(`await page.locator(${quote(selector)}).first().click();`);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
|
|
211
|
+
case 'fill':
|
|
212
|
+
if (selector && value !== null) {
|
|
213
|
+
flushBatch();
|
|
214
|
+
phases.push(`await page.locator(${quote(selector)}).first().fill(${quote(value)});`);
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case 'select':
|
|
219
|
+
if (selector && value !== null) {
|
|
220
|
+
if (isPlaywrightSelector(selector)) {
|
|
221
|
+
flushBatch();
|
|
222
|
+
phases.push(`await page.locator(${quote(selector)}).first().selectOption(${quote(value)});`);
|
|
223
|
+
} else {
|
|
224
|
+
batch.push(
|
|
225
|
+
`{ const _el = deepQuery(${qs(selector)});`,
|
|
226
|
+
` if (_el) { _el.value = ${qs(value)}; _el.dispatchEvent(new Event('change', { bubbles: true })); }`,
|
|
227
|
+
`}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
|
|
233
|
+
case 'scroll':
|
|
234
|
+
if (selector) {
|
|
235
|
+
if (isPlaywrightSelector(selector)) {
|
|
236
|
+
flushBatch();
|
|
237
|
+
phases.push(`await page.locator(${quote(selector)}).first().scrollIntoViewIfNeeded();`);
|
|
238
|
+
} else {
|
|
239
|
+
batch.push(`deepQuery(${qs(selector)})?.scrollIntoView({ behavior: 'instant' });`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
case 'wait':
|
|
245
|
+
flushBatch();
|
|
246
|
+
if (selector) {
|
|
247
|
+
const timeout = step.timeout || 30000;
|
|
248
|
+
if (step.state === 'hidden') {
|
|
249
|
+
phases.push(`await page.waitForSelector(${quote(selector)}, { state: 'hidden', timeout: ${timeout} });`);
|
|
250
|
+
} else {
|
|
251
|
+
phases.push(`await page.waitForSelector(${quote(selector)}, { timeout: ${timeout} });`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'extract':
|
|
257
|
+
flushBatch();
|
|
258
|
+
if (selector) {
|
|
259
|
+
addStepExtraction(phases, selector, step.extract || 'text', step.attribute);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case 'evaluate':
|
|
264
|
+
flushBatch();
|
|
265
|
+
if (step.value) {
|
|
266
|
+
phases.push(`await page.evaluate(async () => { ${interpolate(step.value, args)} });`);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case 'condition':
|
|
271
|
+
flushBatch();
|
|
272
|
+
if (selector) {
|
|
273
|
+
const state = step.state || 'visible';
|
|
274
|
+
|
|
275
|
+
if (isPlaywrightSelector(selector)) {
|
|
276
|
+
// Use Playwright locator for condition check
|
|
277
|
+
phases.push(`{`);
|
|
278
|
+
if (state === 'visible') {
|
|
279
|
+
phases.push(` const _cond = await page.locator(${quote(selector)}).first().isVisible().catch(() => false);`);
|
|
280
|
+
} else if (state === 'hidden') {
|
|
281
|
+
phases.push(` const _cond = !(await page.locator(${quote(selector)}).first().isVisible().catch(() => false));`);
|
|
282
|
+
} else {
|
|
283
|
+
// exists
|
|
284
|
+
phases.push(` const _cond = await page.locator(${quote(selector)}).count() > 0;`);
|
|
285
|
+
}
|
|
286
|
+
phases.push(` if (_cond) {`);
|
|
287
|
+
} else {
|
|
288
|
+
let check;
|
|
289
|
+
if (state === 'exists') {
|
|
290
|
+
check = `deepQuery(${qs(selector)}) !== null`;
|
|
291
|
+
} else if (state === 'visible') {
|
|
292
|
+
check = `isVisible(deepQuery(${qs(selector)}))`;
|
|
293
|
+
} else {
|
|
294
|
+
// hidden: not in DOM OR not visible
|
|
295
|
+
check = `!isVisible(deepQuery(${qs(selector)}))`;
|
|
296
|
+
}
|
|
297
|
+
phases.push(`{`);
|
|
298
|
+
phases.push(` const _cond = await page.evaluate(() => { ${DEEP_QUERY_FNS} ${IS_VISIBLE_FN} return ${check}; });`);
|
|
299
|
+
phases.push(` if (_cond) {`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (step.then && step.then.length > 0) {
|
|
303
|
+
const inner = translateSteps({ steps: step.then }, args, { noExtraction: true });
|
|
304
|
+
phases.push(' ' + inner.replace(/\n/g, '\n '));
|
|
305
|
+
}
|
|
306
|
+
phases.push(` } else {`);
|
|
307
|
+
if (step.else && step.else.length > 0) {
|
|
308
|
+
const inner = translateSteps({ steps: step.else }, args, { noExtraction: true });
|
|
309
|
+
phases.push(' ' + inner.replace(/\n/g, '\n '));
|
|
310
|
+
}
|
|
311
|
+
phases.push(` }`);
|
|
312
|
+
phases.push(`}`);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
flushBatch();
|
|
319
|
+
|
|
320
|
+
// Only add result extraction at the top level, not in recursive condition branches
|
|
321
|
+
if (!opts.noExtraction) {
|
|
322
|
+
addResultWait(phases, execution);
|
|
323
|
+
addExtraction(phases, execution.resultSelector, execution.resultExtract || 'text', execution.resultAttribute);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return phases.join('\n');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- Field action generators ---
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Generate raw DOM JavaScript lines for filling a single field (CSS selectors only).
|
|
333
|
+
* Returns an array of code lines for page.evaluate() body.
|
|
334
|
+
*/
|
|
335
|
+
function domFieldAction(field, value) {
|
|
336
|
+
const sel = field.selector;
|
|
337
|
+
const lines = [];
|
|
338
|
+
|
|
339
|
+
switch (field.type) {
|
|
340
|
+
case 'select':
|
|
341
|
+
lines.push(
|
|
342
|
+
`{ const _el = deepQuery(${qs(sel)});`,
|
|
343
|
+
` if (_el) { _el.value = ${qs(String(value))}; _el.dispatchEvent(new Event('change', { bubbles: true })); }`,
|
|
344
|
+
`}`,
|
|
345
|
+
);
|
|
346
|
+
break;
|
|
347
|
+
|
|
348
|
+
case 'checkbox': {
|
|
349
|
+
const checked = value === true || value === 'true' || value === 'on';
|
|
350
|
+
lines.push(
|
|
351
|
+
`{ const _el = deepQuery(${qs(sel)});`,
|
|
352
|
+
` if (_el) { _el.checked = ${checked}; _el.dispatchEvent(new Event('change', { bubbles: true })); }`,
|
|
353
|
+
`}`,
|
|
354
|
+
);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case 'radio': {
|
|
359
|
+
let radioSel = sel + `[value="${value}"]`;
|
|
360
|
+
if (field.options) {
|
|
361
|
+
const option = field.options.find(o => o.value === String(value));
|
|
362
|
+
if (option && option.selector) radioSel = option.selector;
|
|
363
|
+
}
|
|
364
|
+
lines.push(
|
|
365
|
+
`{ const _el = deepQuery(${qs(radioSel)});`,
|
|
366
|
+
` if (_el) { _el.checked = true; _el.dispatchEvent(new Event('change', { bubbles: true })); }`,
|
|
367
|
+
`}`,
|
|
368
|
+
);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
default: // text, number, textarea, date, hidden
|
|
373
|
+
lines.push(
|
|
374
|
+
`{ const _el = deepQuery(${qs(sel)});`,
|
|
375
|
+
` if (_el) { _el.focus(); _el.value = ${qs(String(value))}; _el.dispatchEvent(new Event('input', { bubbles: true })); _el.dispatchEvent(new Event('change', { bubbles: true })); }`,
|
|
376
|
+
`}`,
|
|
377
|
+
);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Generate Playwright API lines for filling a field with Playwright-specific selectors.
|
|
386
|
+
* Returns an array of code lines (each is a standalone statement).
|
|
387
|
+
*/
|
|
388
|
+
function playwrightFieldAction(field, value) {
|
|
389
|
+
const sel = field.selector;
|
|
390
|
+
|
|
391
|
+
switch (field.type) {
|
|
392
|
+
case 'select':
|
|
393
|
+
return [`await page.locator(${quote(sel)}).selectOption(${quote(String(value))});`];
|
|
394
|
+
|
|
395
|
+
case 'checkbox':
|
|
396
|
+
if (value === true || value === 'true' || value === 'on') {
|
|
397
|
+
return [`await page.locator(${quote(sel)}).check();`];
|
|
398
|
+
}
|
|
399
|
+
return [`await page.locator(${quote(sel)}).uncheck();`];
|
|
400
|
+
|
|
401
|
+
case 'radio': {
|
|
402
|
+
let radioSel = sel + `[value="${value}"]`;
|
|
403
|
+
if (field.options) {
|
|
404
|
+
const option = field.options.find(o => o.value === String(value));
|
|
405
|
+
if (option && option.selector) radioSel = option.selector;
|
|
406
|
+
}
|
|
407
|
+
return [`await page.locator(${quote(radioSel)}).click();`];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
default: // text, number, textarea, date, hidden
|
|
411
|
+
return [`await page.locator(${quote(sel)}).fill(${quote(String(value))});`];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- Wait helpers ---
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Add wait-for-results code if specified in execution metadata.
|
|
419
|
+
* page.waitForSelector supports both CSS and Playwright selectors natively.
|
|
420
|
+
*/
|
|
421
|
+
function addResultWait(phases, execution) {
|
|
422
|
+
if (execution.resultDelay) {
|
|
423
|
+
phases.push(`await new Promise(r => setTimeout(r, ${execution.resultDelay}));`);
|
|
424
|
+
}
|
|
425
|
+
const waitSel = execution.resultWaitSelector;
|
|
426
|
+
if (waitSel) {
|
|
427
|
+
if (execution.resultRequired) {
|
|
428
|
+
// Hard assertion: throws if the selector doesn't appear within 5s.
|
|
429
|
+
// Use resultRequired: true on tools where you need to confirm the action succeeded
|
|
430
|
+
// (e.g. waiting for a success toast after posting). The agent will see the timeout error.
|
|
431
|
+
phases.push(`await page.waitForSelector(${quote(waitSel)}, { timeout: 5000 });`);
|
|
432
|
+
} else {
|
|
433
|
+
// Soft wait: silently continues if the selector doesn't appear.
|
|
434
|
+
// Safe default so tools don't fail when success indicators are optional.
|
|
435
|
+
phases.push(`await page.waitForSelector(${quote(waitSel)}, { timeout: 5000 }).catch(() => {});`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Extraction generators ---
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Add top-level result extraction.
|
|
444
|
+
* If no resultSelector, returns a neutral acknowledgment without prompting the agent to snapshot.
|
|
445
|
+
*/
|
|
446
|
+
function addExtraction(phases, selector, extractMode, attribute) {
|
|
447
|
+
if (!selector) {
|
|
448
|
+
phases.push(`return '[action ran — no result selector configured]';`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
addStepExtraction(phases, selector, extractMode, attribute);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Generate extraction code. Uses page.evaluate() for CSS selectors (fast)
|
|
456
|
+
* or page.locator() for Playwright selectors (compatible).
|
|
457
|
+
*/
|
|
458
|
+
function addStepExtraction(phases, selector, extractMode, attribute) {
|
|
459
|
+
if (isPlaywrightSelector(selector)) {
|
|
460
|
+
addPlaywrightExtraction(phases, selector, extractMode, attribute);
|
|
461
|
+
} else {
|
|
462
|
+
addDomExtraction(phases, selector, extractMode, attribute);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Extraction via page.evaluate() — for pure CSS selectors.
|
|
468
|
+
* Single CDP round-trip.
|
|
469
|
+
*/
|
|
470
|
+
function addDomExtraction(phases, selector, extractMode, attribute) {
|
|
471
|
+
switch (extractMode) {
|
|
472
|
+
case 'list':
|
|
473
|
+
phases.push(`{ const _r = await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQueryAll(${qs(selector)}).map(e => e.textContent); }); return _r.length > 0 ? _r : ['[resultSelector matched no elements — the action may not have worked. Use browser_snapshot to check.]']; }`);
|
|
474
|
+
break;
|
|
475
|
+
|
|
476
|
+
case 'innerTextList':
|
|
477
|
+
phases.push(`return await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQueryAll(${qs(selector)}).map(e => e.innerText); });`);
|
|
478
|
+
break;
|
|
479
|
+
|
|
480
|
+
case 'html':
|
|
481
|
+
phases.push(`return await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQuery(${qs(selector)})?.innerHTML || ''; });`);
|
|
482
|
+
break;
|
|
483
|
+
|
|
484
|
+
case 'attribute':
|
|
485
|
+
phases.push(`return await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQuery(${qs(selector)})?.getAttribute(${qs(attribute || 'href')}) || ''; });`);
|
|
486
|
+
break;
|
|
487
|
+
|
|
488
|
+
case 'table':
|
|
489
|
+
phases.push(
|
|
490
|
+
`return await page.evaluate(() => {`,
|
|
491
|
+
` ${DEEP_QUERY_FNS}`,
|
|
492
|
+
` const _tbl = deepQuery(${qs(selector)});`,
|
|
493
|
+
` if (!_tbl) return [];`,
|
|
494
|
+
` const _headers = [..._tbl.querySelectorAll('th')].map(th => th.textContent.trim());`,
|
|
495
|
+
` return [..._tbl.querySelectorAll('tr')].slice(1).map(row => {`,
|
|
496
|
+
` const cells = [...row.querySelectorAll('td')].map(td => td.textContent.trim());`,
|
|
497
|
+
` return Object.fromEntries(_headers.map((h, i) => [h, cells[i] || '']));`,
|
|
498
|
+
` });`,
|
|
499
|
+
`});`,
|
|
500
|
+
);
|
|
501
|
+
break;
|
|
502
|
+
|
|
503
|
+
case 'innerText':
|
|
504
|
+
phases.push(`return await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQuery(${qs(selector)})?.innerText || ''; });`);
|
|
505
|
+
break;
|
|
506
|
+
|
|
507
|
+
case 'text':
|
|
508
|
+
default:
|
|
509
|
+
phases.push(`return await page.evaluate(() => { ${DEEP_QUERY_FNS} return deepQuery(${qs(selector)})?.textContent || '[resultSelector matched no elements — the action may not have worked. Use browser_snapshot to check.]'; });`);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Extraction via page.locator() — for Playwright-specific selectors.
|
|
516
|
+
* Uses Playwright APIs that understand :has-text, :text, >> chains, etc.
|
|
517
|
+
*/
|
|
518
|
+
function addPlaywrightExtraction(phases, selector, extractMode, attribute) {
|
|
519
|
+
switch (extractMode) {
|
|
520
|
+
case 'list':
|
|
521
|
+
phases.push(`{ const _r = await page.locator(${quote(selector)}).allTextContents(); return _r.length > 0 ? _r : ['[resultSelector matched no elements — the action may not have worked. Use browser_snapshot to check.]']; }`);
|
|
522
|
+
break;
|
|
523
|
+
|
|
524
|
+
case 'innerTextList':
|
|
525
|
+
phases.push(`return await page.locator(${quote(selector)}).evaluateAll(els => els.map(e => e.innerText));`);
|
|
526
|
+
break;
|
|
527
|
+
|
|
528
|
+
case 'html':
|
|
529
|
+
phases.push(`return await page.locator(${quote(selector)}).first().innerHTML();`);
|
|
530
|
+
break;
|
|
531
|
+
|
|
532
|
+
case 'attribute':
|
|
533
|
+
phases.push(`return await page.locator(${quote(selector)}).first().getAttribute(${quote(attribute || 'href')});`);
|
|
534
|
+
break;
|
|
535
|
+
|
|
536
|
+
case 'table':
|
|
537
|
+
phases.push(`return await page.locator(${quote(selector)}).evaluate(table => {`);
|
|
538
|
+
phases.push(` const headers = [...table.querySelectorAll('th')].map(th => th.textContent.trim());`);
|
|
539
|
+
phases.push(` return [...table.querySelectorAll('tr')].slice(1).map(row => {`);
|
|
540
|
+
phases.push(` const cells = [...row.querySelectorAll('td')].map(td => td.textContent.trim());`);
|
|
541
|
+
phases.push(` return Object.fromEntries(headers.map((h, i) => [h, cells[i] || '']));`);
|
|
542
|
+
phases.push(` });`);
|
|
543
|
+
phases.push(`});`);
|
|
544
|
+
break;
|
|
545
|
+
|
|
546
|
+
case 'innerText':
|
|
547
|
+
phases.push(`return await page.locator(${quote(selector)}).first().innerText();`);
|
|
548
|
+
break;
|
|
549
|
+
|
|
550
|
+
case 'text':
|
|
551
|
+
default:
|
|
552
|
+
phases.push(`{ const _r = await page.locator(${quote(selector)}).first().textContent().catch(() => null); return _r || '[resultSelector matched no elements — the action may not have worked. Use browser_snapshot to check.]'; }`);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// --- String utilities ---
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Interpolate {{paramName}} placeholders in a string with argument values.
|
|
561
|
+
*/
|
|
562
|
+
function interpolate(template, args) {
|
|
563
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
564
|
+
return args[key] !== undefined ? String(args[key]) : `{{${key}}}`;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Quote a string for Playwright-level code (backtick template literals).
|
|
570
|
+
* Used for page.waitForSelector(), page.goto(), page.locator(), etc.
|
|
571
|
+
*/
|
|
572
|
+
function quote(str) {
|
|
573
|
+
const escaped = str
|
|
574
|
+
.replace(/\\/g, '\\\\')
|
|
575
|
+
.replace(/`/g, '\\`')
|
|
576
|
+
.replace(/\$/g, '\\$')
|
|
577
|
+
.replace(/\n/g, '\\n')
|
|
578
|
+
.replace(/\r/g, '\\r');
|
|
579
|
+
return '`' + escaped + '`';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Quote a string for use inside page.evaluate() (JSON double-quoted strings).
|
|
584
|
+
* Safe at any nesting level — no conflicts with backticks or template literals.
|
|
585
|
+
*/
|
|
586
|
+
function qs(str) {
|
|
587
|
+
return JSON.stringify(str);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
module.exports = { translate };
|