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.
@@ -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 };