playwright-e2e-mcp 0.1.3 → 0.2.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.
Files changed (64) hide show
  1. package/README.md +208 -54
  2. package/dist/http.d.ts +1 -1
  3. package/dist/http.js +1 -1
  4. package/dist/prompts.d.ts +6 -0
  5. package/dist/prompts.js +39 -0
  6. package/dist/prompts.js.map +1 -0
  7. package/dist/server.d.ts +4 -3
  8. package/dist/server.js +63 -5
  9. package/dist/server.js.map +1 -1
  10. package/dist/tools/analyze-history.d.ts +48 -0
  11. package/dist/tools/analyze-history.js +132 -0
  12. package/dist/tools/analyze-history.js.map +1 -0
  13. package/dist/tools/compare-visual-state.d.ts +89 -17
  14. package/dist/tools/compare-visual-state.js +10 -3
  15. package/dist/tools/compare-visual-state.js.map +1 -1
  16. package/dist/tools/diagnose-flaky.js +5 -2
  17. package/dist/tools/diagnose-flaky.js.map +1 -1
  18. package/dist/tools/generate-e2e-test.d.ts +11 -0
  19. package/dist/tools/generate-e2e-test.js +50 -3
  20. package/dist/tools/generate-e2e-test.js.map +1 -1
  21. package/dist/tools/get-failure.d.ts +6 -1
  22. package/dist/tools/get-failure.js +17 -11
  23. package/dist/tools/get-failure.js.map +1 -1
  24. package/dist/tools/get-run-status.d.ts +28 -0
  25. package/dist/tools/get-run-status.js +75 -0
  26. package/dist/tools/get-run-status.js.map +1 -0
  27. package/dist/tools/inspect-page.d.ts +102 -14
  28. package/dist/tools/inspect-page.js +50 -10
  29. package/dist/tools/inspect-page.js.map +1 -1
  30. package/dist/tools/probe-script.d.ts +20 -0
  31. package/dist/tools/probe-script.js +641 -0
  32. package/dist/tools/probe-script.js.map +1 -0
  33. package/dist/tools/run-test.d.ts +5 -1
  34. package/dist/tools/run-test.js +30 -3
  35. package/dist/tools/run-test.js.map +1 -1
  36. package/dist/tools/shared.d.ts +117 -3
  37. package/dist/tools/shared.js +154 -285
  38. package/dist/tools/shared.js.map +1 -1
  39. package/dist/tools/suggest-fix.d.ts +89 -0
  40. package/dist/tools/suggest-fix.js +437 -0
  41. package/dist/tools/suggest-fix.js.map +1 -0
  42. package/dist/tools/validate-selector.d.ts +83 -6
  43. package/dist/tools/validate-selector.js +71 -32
  44. package/dist/tools/validate-selector.js.map +1 -1
  45. package/dist/types/index.d.ts +40 -0
  46. package/dist/utils/locator-expr.d.ts +38 -0
  47. package/dist/utils/locator-expr.js +280 -0
  48. package/dist/utils/locator-expr.js.map +1 -0
  49. package/dist/utils/playwright-runner.d.ts +9 -1
  50. package/dist/utils/playwright-runner.js +60 -4
  51. package/dist/utils/playwright-runner.js.map +1 -1
  52. package/dist/utils/report-parser.d.ts +3 -1
  53. package/dist/utils/report-parser.js +22 -0
  54. package/dist/utils/report-parser.js.map +1 -1
  55. package/dist/utils/run-history.d.ts +103 -0
  56. package/dist/utils/run-history.js +219 -0
  57. package/dist/utils/run-history.js.map +1 -0
  58. package/dist/utils/run-registry.d.ts +34 -0
  59. package/dist/utils/run-registry.js +86 -0
  60. package/dist/utils/run-registry.js.map +1 -0
  61. package/dist/utils/trace-reader.d.ts +23 -2
  62. package/dist/utils/trace-reader.js +86 -15
  63. package/dist/utils/trace-reader.js.map +1 -1
  64. package/package.json +2 -2
@@ -0,0 +1,641 @@
1
+ /**
2
+ * The browser child script behind every page-facing tool (inspect-page,
3
+ * validate-selector, compare-visual-state, generate-e2e-test, suggest-fix).
4
+ *
5
+ * It runs inside a child `node` process with cwd = project root so that
6
+ * `createRequire(projectRoot/package.json)` resolves the *user's* Playwright
7
+ * install (browsers included). It writes one JSON document to stdout and
8
+ * always exits, so it can never wedge the server.
9
+ *
10
+ * Modes:
11
+ * - inspect element inventory (or a compact locator map with view: 'locators')
12
+ * - validate resolve a selector or locator chain, count and describe matches
13
+ * - screenshot capture the page or one element
14
+ * - heal rank elements that look like a broken locator's target, either
15
+ * on the live page or on a DOM snapshot taken from a trace
16
+ *
17
+ * Every element the script reports carries Playwright locators that were
18
+ * proven, in this same page, to resolve to exactly that one element.
19
+ */
20
+ export const PROBE_SCRIPT = String.raw `
21
+ 'use strict';
22
+ const cfg = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
23
+
24
+ function send(payload) {
25
+ try { process.stdout.write(JSON.stringify(payload)); } catch (err) { /* ignore */ }
26
+ }
27
+
28
+ function classifyError(message) {
29
+ const m = String(message);
30
+ if (/Executable doesn't exist|Please run the following command to install/i.test(m)) {
31
+ return { kind: 'NO_PLAYWRIGHT', hint: 'Playwright browsers are not installed for this project. Run: npx playwright install chromium' };
32
+ }
33
+ if (/ECONNREFUSED|ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_EMPTY_RESPONSE|getaddrinfo|net::ERR_/i.test(m)) {
34
+ return { kind: 'SERVER_NOT_RUNNING', hint: 'The URL is not reachable. Start your dev server (e.g. npm run dev / npm start) and retry.' };
35
+ }
36
+ if (/Timeout \d+ms exceeded|timed out/i.test(m)) {
37
+ return { kind: 'TIMEOUT', hint: 'Raise timeoutMs, or make the page load faster.' };
38
+ }
39
+ if (/Target closed|browser has been closed|browser has crashed|Page crashed/i.test(m)) {
40
+ return { kind: 'BROWSER_CRASH', hint: 'The browser crashed during inspection. Retry; if it persists run: npx playwright install --force' };
41
+ }
42
+ if (/Unknown selector|error evaluating selector|Unexpected token|is not a valid selector|Unknown engine/i.test(m)) {
43
+ return { kind: 'INVALID_PATH', hint: 'The selector could not be evaluated.' };
44
+ }
45
+ return { kind: 'UNKNOWN', hint: undefined };
46
+ }
47
+
48
+ async function startGuardProxy(ranges) {
49
+ const http = require('node:http');
50
+ const net = require('node:net');
51
+ const dns = require('node:dns').promises;
52
+ const blocked = new net.BlockList();
53
+ for (const r of ranges) blocked.addSubnet(r.address, r.prefix, r.family);
54
+ const isBlocked = (a) => {
55
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(a);
56
+ if (m) return blocked.check(m[1], 'ipv4');
57
+ const f = net.isIP(a);
58
+ return f === 4 ? blocked.check(a, 'ipv4') : f === 6 ? blocked.check(a, 'ipv6') : true;
59
+ };
60
+ // Resolve once and connect to that exact address, so DNS cannot change
61
+ // between the check and the connection.
62
+ const allowedAddress = async (host) => {
63
+ const bare = host.replace(/^\[|\]$/g, '');
64
+ const addrs = net.isIP(bare) ? [bare] : (await dns.lookup(bare, { all: true, verbatim: true })).map((x) => x.address);
65
+ if (addrs.length === 0 || addrs.some(isBlocked)) return null;
66
+ return addrs[0];
67
+ };
68
+ const server = http.createServer(async (req, res) => {
69
+ let target;
70
+ try {
71
+ target = new URL(req.url);
72
+ } catch (err) {
73
+ res.writeHead(400);
74
+ res.end();
75
+ return;
76
+ }
77
+ const ip = await allowedAddress(target.hostname).catch(() => null);
78
+ if (!ip) {
79
+ res.writeHead(403, { 'content-type': 'text/plain' });
80
+ res.end('Blocked by playwright-e2e-mcp: private or reserved address');
81
+ return;
82
+ }
83
+ const upstream = http.request(
84
+ { host: ip, port: target.port || 80, method: req.method, path: target.pathname + target.search, headers: { ...req.headers, host: target.host } },
85
+ (up) => {
86
+ res.writeHead(up.statusCode || 502, up.headers);
87
+ up.pipe(res);
88
+ },
89
+ );
90
+ upstream.on('error', () => {
91
+ if (!res.headersSent) res.writeHead(502);
92
+ res.end();
93
+ });
94
+ req.pipe(upstream);
95
+ });
96
+ server.on('connect', async (req, socket, head) => {
97
+ socket.on('error', () => undefined);
98
+ let target;
99
+ try {
100
+ target = new URL('http://' + req.url);
101
+ } catch (err) {
102
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
103
+ return;
104
+ }
105
+ const ip = await allowedAddress(target.hostname).catch(() => null);
106
+ if (!ip) {
107
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
108
+ return;
109
+ }
110
+ const upstream = net.connect(Number(target.port) || 443, ip, () => {
111
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
112
+ if (head && head.length) upstream.write(head);
113
+ upstream.pipe(socket);
114
+ socket.pipe(upstream);
115
+ });
116
+ upstream.on('error', () => socket.destroy());
117
+ });
118
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
119
+ server.unref();
120
+ return server.address().port;
121
+ }
122
+
123
+ /* ---------------- locator chains (JSON, never evaluated as code) ---------------- */
124
+
125
+ function reviveArg(arg) {
126
+ if (arg && typeof arg === 'object') {
127
+ if (typeof arg.$regex === 'string') return new RegExp(arg.$regex, arg.flags || '');
128
+ const out = {};
129
+ for (const key of Object.keys(arg)) out[key] = reviveArg(arg[key]);
130
+ return out;
131
+ }
132
+ return arg;
133
+ }
134
+
135
+ const CHAIN_METHODS = new Set(['getByRole', 'getByText', 'getByLabel', 'getByPlaceholder', 'getByAltText', 'getByTitle', 'getByTestId', 'locator', 'first', 'last', 'nth', 'filter']);
136
+
137
+ function resolveChain(page, chain) {
138
+ let target = page;
139
+ for (const call of chain) {
140
+ if (!CHAIN_METHODS.has(call.method)) throw new Error('Unsupported locator method ' + call.method);
141
+ target = target[call.method](...(call.args || []).map(reviveArg));
142
+ }
143
+ return target;
144
+ }
145
+
146
+ /* ---------------- in-page helpers, installed once per page ---------------- */
147
+
148
+ function installHelpers() {
149
+ if (window.__pwmcp) return;
150
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
151
+
152
+ function uniqueSelector(el) {
153
+ if (el.id) {
154
+ const css = '#' + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id);
155
+ try { if (document.querySelectorAll(css).length === 1) return css; } catch (err) { /* fall through */ }
156
+ }
157
+ const parts = [];
158
+ let node = el;
159
+ while (node && node.nodeType === 1 && parts.length < 6) {
160
+ if (node === document.documentElement) { parts.unshift('html'); break; }
161
+ let part = node.tagName.toLowerCase();
162
+ const parent = node.parentElement;
163
+ if (parent) {
164
+ const siblings = Array.prototype.filter.call(parent.children, (child) => child.tagName === node.tagName);
165
+ if (siblings.length > 1) part = part + ':nth-of-type(' + (Array.prototype.indexOf.call(siblings, node) + 1) + ')';
166
+ }
167
+ parts.unshift(part);
168
+ const candidate = parts.join(' > ');
169
+ try { if (document.querySelectorAll(candidate).length === 1) return candidate; } catch (err) { /* keep walking */ }
170
+ node = node.parentElement;
171
+ }
172
+ return parts.join(' > ');
173
+ }
174
+
175
+ function implicitRole(el) {
176
+ const explicit = el.getAttribute('role');
177
+ if (explicit) return explicit.trim().split(/\s+/)[0];
178
+ const tag = el.tagName.toLowerCase();
179
+ const type = (el.getAttribute('type') || '').toLowerCase();
180
+ switch (tag) {
181
+ case 'button': return 'button';
182
+ case 'a': case 'area': return el.hasAttribute('href') ? 'link' : null;
183
+ case 'input':
184
+ if (type === 'hidden') return null;
185
+ if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button';
186
+ if (type === 'checkbox') return 'checkbox';
187
+ if (type === 'radio') return 'radio';
188
+ if (type === 'range') return 'slider';
189
+ if (type === 'number') return 'spinbutton';
190
+ if (type === 'search') return el.hasAttribute('list') ? 'combobox' : 'searchbox';
191
+ if (['', 'text', 'email', 'tel', 'url'].includes(type)) return el.hasAttribute('list') ? 'combobox' : 'textbox';
192
+ return null;
193
+ case 'textarea': return 'textbox';
194
+ case 'select': return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
195
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading';
196
+ case 'img': return el.getAttribute('alt') === '' ? null : 'img';
197
+ case 'nav': return 'navigation';
198
+ case 'main': return 'main';
199
+ case 'aside': return 'complementary';
200
+ case 'ul': case 'ol': return 'list';
201
+ case 'li': return 'listitem';
202
+ case 'table': return 'table';
203
+ case 'tr': return 'row';
204
+ case 'td': return 'cell';
205
+ case 'th': return 'columnheader';
206
+ case 'option': return 'option';
207
+ case 'dialog': return 'dialog';
208
+ case 'progress': return 'progressbar';
209
+ case 'article': return 'article';
210
+ case 'form': return el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') ? 'form' : null;
211
+ default: return null;
212
+ }
213
+ }
214
+
215
+ function labelText(el) {
216
+ if (el.id) {
217
+ try {
218
+ const lab = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + '"]');
219
+ if (lab) return norm(lab.textContent);
220
+ } catch (err) { /* ignore */ }
221
+ }
222
+ const wrap = el.closest('label');
223
+ if (wrap) return norm(wrap.textContent);
224
+ return '';
225
+ }
226
+
227
+ function accessibleName(el, role) {
228
+ const by = el.getAttribute('aria-labelledby');
229
+ if (by) {
230
+ const text = by.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => norm(n.textContent)).join(' ');
231
+ if (text) return text;
232
+ }
233
+ const aria = norm(el.getAttribute('aria-label'));
234
+ if (aria) return aria;
235
+ const tag = el.tagName.toLowerCase();
236
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
237
+ const type = (el.getAttribute('type') || '').toLowerCase();
238
+ if (['button', 'submit', 'reset'].includes(type)) return norm(el.value || el.getAttribute('value'));
239
+ if (type === 'image') return norm(el.getAttribute('alt'));
240
+ const label = labelText(el);
241
+ if (label) return label;
242
+ return norm(el.getAttribute('title') || el.getAttribute('placeholder'));
243
+ }
244
+ if (tag === 'img') return norm(el.getAttribute('alt') || el.getAttribute('title'));
245
+ const fromContent = ['button', 'link', 'heading', 'cell', 'columnheader', 'option', 'tab', 'menuitem', 'listitem', 'checkbox', 'radio', 'switch', 'treeitem'];
246
+ if (fromContent.includes(role)) return norm(el.innerText !== undefined && el.innerText !== '' ? el.innerText : el.textContent);
247
+ return norm(el.getAttribute('title'));
248
+ }
249
+
250
+ function isVisible(el) {
251
+ let visible = !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
252
+ try {
253
+ const style = getComputedStyle(el);
254
+ if (style.visibility === 'hidden' || style.display === 'none') visible = false;
255
+ } catch (err) { /* ignore */ }
256
+ return visible;
257
+ }
258
+
259
+ // Ordered candidate locators, best first. The Node side keeps only those
260
+ // that resolve to exactly this element.
261
+ function candidates(el) {
262
+ const out = [];
263
+ const role = implicitRole(el);
264
+ const name = role ? accessibleName(el, role) : '';
265
+ const testId = el.getAttribute('data-testid') || el.getAttribute('data-test-id') || el.getAttribute('data-test');
266
+ const testAttr = el.hasAttribute('data-testid') ? 'data-testid' : null;
267
+ if (role && name && name.length <= 80) {
268
+ out.push([{ method: 'getByRole', args: [role, { name: name }] }]);
269
+ out.push([{ method: 'getByRole', args: [role, { name: name, exact: true }] }]);
270
+ }
271
+ if (testId && testAttr) out.push([{ method: 'getByTestId', args: [testId] }]);
272
+ else if (testId) out.push([{ method: 'locator', args: ['[' + (el.hasAttribute('data-test-id') ? 'data-test-id' : 'data-test') + '="' + testId + '"]'] }]);
273
+ const tag = el.tagName.toLowerCase();
274
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
275
+ const label = labelText(el);
276
+ if (label && label.length <= 80) out.push([{ method: 'getByLabel', args: [label] }]);
277
+ const placeholder = norm(el.getAttribute('placeholder'));
278
+ if (placeholder) out.push([{ method: 'getByPlaceholder', args: [placeholder] }]);
279
+ }
280
+ const alt = norm(el.getAttribute('alt'));
281
+ if (alt) out.push([{ method: 'getByAltText', args: [alt] }]);
282
+ const text = norm(el.textContent);
283
+ if (text && text.length <= 50 && el.children.length === 0 && !['html', 'head', 'body', 'script', 'style', 'title'].includes(tag)) {
284
+ out.push([{ method: 'getByText', args: [text] }]);
285
+ out.push([{ method: 'getByText', args: [text, { exact: true }] }]);
286
+ }
287
+ if (role && !name) out.push([{ method: 'getByRole', args: [role] }]);
288
+ const title = norm(el.getAttribute('title'));
289
+ if (title) out.push([{ method: 'getByTitle', args: [title] }]);
290
+ out.push([{ method: 'locator', args: [uniqueSelector(el)] }]);
291
+ return { chains: out, role: role || undefined, name: name || undefined };
292
+ }
293
+
294
+ function describe(el) {
295
+ const rect = el.getBoundingClientRect();
296
+ const attributes = {};
297
+ const attrCount = Math.min(el.attributes.length, 30);
298
+ for (let i = 0; i < attrCount; i += 1) {
299
+ const attr = el.attributes[i];
300
+ attributes[attr.name] = String(attr.value).slice(0, 200);
301
+ }
302
+ const info = candidates(el);
303
+ const text = norm(el.textContent).slice(0, 160);
304
+ return {
305
+ selector: uniqueSelector(el),
306
+ tag: el.tagName.toLowerCase(),
307
+ id: el.id || undefined,
308
+ classes: Array.prototype.slice.call(el.classList).slice(0, 10),
309
+ role: info.role,
310
+ name: info.name ? info.name.slice(0, 120) : undefined,
311
+ text: text || undefined,
312
+ attributes: attributes,
313
+ visible: isVisible(el),
314
+ box: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
315
+ candidates: info.chains,
316
+ };
317
+ }
318
+
319
+ const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'tab', 'menuitem', 'option', 'heading', 'img', 'dialog', 'navigation', 'main', 'form']);
320
+
321
+ function isMeaningful(el) {
322
+ if (!isVisible(el)) return false;
323
+ const role = implicitRole(el);
324
+ if (role && INTERACTIVE.has(role)) return true;
325
+ return el.hasAttribute('data-testid') || el.hasAttribute('data-test-id') || el.hasAttribute('data-test');
326
+ }
327
+
328
+ // Token overlap between a broken locator's words and an element.
329
+ function healScore(el, tokens) {
330
+ if (tokens.length === 0) return 0;
331
+ const info = candidates(el);
332
+ const hay = [
333
+ el.id, el.getAttribute('data-testid'), el.getAttribute('data-test-id'), el.getAttribute('data-test'),
334
+ el.getAttribute('name'), el.getAttribute('aria-label'), el.getAttribute('placeholder'), el.getAttribute('title'),
335
+ el.getAttribute('alt'), info.name, el.children.length === 0 ? el.textContent : '', el.className && typeof el.className === 'string' ? el.className : '',
336
+ ].filter(Boolean).join(' ').toLowerCase().replace(/([a-z])([A-Z])/g, '$1 $2');
337
+ const words = new Set(hay.split(/[^a-z0-9]+/).filter((w) => w.length > 1));
338
+ let score = 0;
339
+ for (const token of tokens) {
340
+ if (words.has(token)) score += 3;
341
+ else if (Array.from(words).some((w) => w.startsWith(token) || token.startsWith(w))) score += 2;
342
+ else if (hay.includes(token)) score += 1;
343
+ }
344
+ if (cfgRole() && info.role === cfgRole()) score += 2;
345
+ if (info.role && INTERACTIVE.has(info.role)) score += 1;
346
+ return score;
347
+ }
348
+ let roleHint = null;
349
+ const cfgRole = () => roleHint;
350
+
351
+ window.__pwmcp = {
352
+ describe: describe,
353
+ uniqueSelector: uniqueSelector,
354
+ isMeaningful: isMeaningful,
355
+ healScore: healScore,
356
+ setRoleHint: (r) => { roleHint = r || null; },
357
+ els: [],
358
+ };
359
+ }
360
+
361
+ /* ---------------- locator verification ---------------- */
362
+
363
+ async function verifyCandidates(page, index, candidates, keep) {
364
+ const verified = [];
365
+ for (const chain of candidates || []) {
366
+ if (verified.length >= keep) break;
367
+ try {
368
+ const loc = resolveChain(page, chain);
369
+ if ((await loc.count()) !== 1) continue;
370
+ const same = await loc.evaluate((el, i) => el === window.__pwmcp.els[i], index);
371
+ if (!same) continue;
372
+ // Skip the exact:true twin when the plain form already passed.
373
+ const key = JSON.stringify(chain.map((c) => [c.method, (c.args || []).map((a) => {
374
+ if (!a || typeof a !== 'object' || a.$regex) return a;
375
+ const copy = Object.assign({}, a);
376
+ delete copy.exact;
377
+ return copy;
378
+ })]));
379
+ if (verified.some((v) => v.key === key)) continue;
380
+ verified.push({ key: key, chain: chain });
381
+ } catch (err) { /* candidate does not resolve; skip */ }
382
+ }
383
+ return verified.map((v) => v.chain);
384
+ }
385
+
386
+ async function attachLocators(page, elements, budget, keep) {
387
+ for (let i = 0; i < elements.length; i += 1) {
388
+ const el = elements[i];
389
+ if (i < budget) el.locators = await verifyCandidates(page, i, el.candidates, keep);
390
+ delete el.candidates;
391
+ }
392
+ }
393
+
394
+ /* ---------------- page actions (reach a state before inspecting) ---------------- */
395
+
396
+ async function runActions(page, actions, timeout) {
397
+ const done = [];
398
+ for (let i = 0; i < actions.length; i += 1) {
399
+ const a = actions[i];
400
+ const label = 'step ' + (i + 1) + ' (' + (a.label || a.type) + ')';
401
+ try {
402
+ const loc = a.locator ? resolveChain(page, a.locator) : null;
403
+ switch (a.type) {
404
+ case 'goto': await page.goto(a.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: timeout }); break;
405
+ case 'click': await loc.click({ timeout: timeout }); break;
406
+ case 'dblclick': await loc.dblclick({ timeout: timeout }); break;
407
+ case 'hover': await loc.hover({ timeout: timeout }); break;
408
+ case 'fill': await loc.fill(a.value || '', { timeout: timeout }); break;
409
+ case 'press': if (loc) await loc.press(a.value, { timeout: timeout }); else await page.keyboard.press(a.value); break;
410
+ case 'check': await loc.check({ timeout: timeout }); break;
411
+ case 'uncheck': await loc.uncheck({ timeout: timeout }); break;
412
+ case 'select': await loc.selectOption(a.value, { timeout: timeout }); break;
413
+ case 'wait':
414
+ if (loc) await loc.first().waitFor({ state: 'visible', timeout: timeout });
415
+ else await page.waitForTimeout(Math.min(a.ms || 500, 10000));
416
+ break;
417
+ default: throw new Error('Unknown action type ' + a.type);
418
+ }
419
+ done.push(label);
420
+ } catch (err) {
421
+ const message = err && err.message ? err.message : String(err);
422
+ const wrapped = new Error('Action ' + label + ' failed: ' + message.split('\n')[0]);
423
+ wrapped.actionsDone = done;
424
+ throw wrapped;
425
+ }
426
+ }
427
+ return done;
428
+ }
429
+
430
+ (async () => {
431
+ const { createRequire } = require('node:module');
432
+ const req = createRequire(cfg.projectRoot + '/package.json');
433
+ let pw = null;
434
+ try {
435
+ pw = req('playwright');
436
+ } catch (errA) {
437
+ try {
438
+ pw = req('@playwright/test');
439
+ } catch (errB) {
440
+ send({
441
+ ok: false,
442
+ kind: 'NO_PLAYWRIGHT',
443
+ error: 'Playwright is not installed in ' + cfg.projectRoot,
444
+ hint: 'Run: npm install -D @playwright/test && npx playwright install',
445
+ });
446
+ process.exit(1);
447
+ }
448
+ }
449
+
450
+ let browser = null;
451
+ let actionsDone = [];
452
+ try {
453
+ const launchOptions = { headless: true };
454
+ if (cfg.blockPrivate) {
455
+ // SSRF guard: every browser connection (each redirect hop and
456
+ // subresource too) goes through this local proxy, which resolves
457
+ // the host itself and refuses blocked ranges before connecting.
458
+ const proxyPort = await startGuardProxy(cfg.blockedRanges || []);
459
+ launchOptions.proxy = { server: 'http://127.0.0.1:' + proxyPort };
460
+ // WebRTC STUN/TURN over UDP does not go through the proxy; keep it off
461
+ // so a page cannot send UDP to (or scan) the private network.
462
+ launchOptions.args = ['--proxy-bypass-list=<-loopback>', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'];
463
+ }
464
+ browser = await pw.chromium.launch(launchOptions);
465
+ const contextOptions = { viewport: cfg.viewport || { width: 1280, height: 720 } };
466
+ if (cfg.storageState) contextOptions.storageState = cfg.storageState;
467
+ if (cfg.headers) contextOptions.extraHTTPHeaders = cfg.headers;
468
+ if (cfg.testIdAttribute) pw.selectors.setTestIdAttribute(cfg.testIdAttribute);
469
+ const context = await browser.newContext(contextOptions);
470
+ const page = await context.newPage();
471
+
472
+ const consoleMessages = [];
473
+ page.on('console', (msg) => {
474
+ if (consoleMessages.length < 50) consoleMessages.push({ type: msg.type(), text: String(msg.text()).slice(0, 300) });
475
+ });
476
+ page.on('pageerror', (err) => {
477
+ if (consoleMessages.length < 50) consoleMessages.push({ type: 'pageerror', text: String(err).slice(0, 300) });
478
+ });
479
+
480
+ if (cfg.snapshotHtmlPath) {
481
+ // A DOM snapshot from a trace: render it with every request blocked,
482
+ // so nothing it references is fetched and no script can run.
483
+ await context.route('**/*', (route) => route.abort());
484
+ await page.setContent(require('node:fs').readFileSync(cfg.snapshotHtmlPath, 'utf8'), { waitUntil: 'domcontentloaded', timeout: cfg.gotoTimeout || 15000 });
485
+ } else {
486
+ await page.goto(cfg.url, { waitUntil: cfg.waitUntil || 'domcontentloaded', timeout: cfg.gotoTimeout || 15000 });
487
+ if (cfg.waitFor) await page.locator(cfg.waitFor).first().waitFor({ state: 'visible', timeout: cfg.waitTimeout || 5000 });
488
+ if (cfg.actions && cfg.actions.length) actionsDone = await runActions(page, cfg.actions, cfg.actionTimeout || 10000);
489
+ }
490
+
491
+ if (cfg.mode === 'screenshot') {
492
+ if (cfg.selectorChain) {
493
+ await resolveChain(page, cfg.selectorChain).first().screenshot({ path: cfg.screenshotPath });
494
+ } else {
495
+ await page.screenshot({ path: cfg.screenshotPath, fullPage: !!cfg.fullPage });
496
+ }
497
+ send({
498
+ ok: true,
499
+ data: {
500
+ screenshotPath: cfg.screenshotPath,
501
+ title: await page.title(),
502
+ finalUrl: page.url(),
503
+ viewport: page.viewportSize() || { width: 1280, height: 720 },
504
+ elementCount: 0,
505
+ matchCount: 0,
506
+ elements: [],
507
+ actionsDone: actionsDone,
508
+ durationMs: Date.now() - cfg.startedAt,
509
+ },
510
+ });
511
+ await browser.close();
512
+ process.exit(0);
513
+ }
514
+
515
+ await page.evaluate(installHelpers);
516
+ let data;
517
+
518
+ if (cfg.mode === 'validate') {
519
+ data = { matchCount: 0, elementCount: 0, elements: [] };
520
+ try {
521
+ const loc = resolveChain(page, cfg.selectorChain);
522
+ data.matchCount = await loc.count();
523
+ data.elementCount = data.matchCount;
524
+ data.elements = await loc.evaluateAll((els) => {
525
+ window.__pwmcp.els = els.slice(0, 5);
526
+ return window.__pwmcp.els.map((el) => window.__pwmcp.describe(el));
527
+ });
528
+ } catch (err) {
529
+ const message = err && err.message ? err.message : String(err);
530
+ if (classifyError(message).kind === 'INVALID_PATH' || /Unsupported locator/.test(message)) {
531
+ data.parseError = message.split('\n')[0];
532
+ } else {
533
+ throw err;
534
+ }
535
+ }
536
+ if (!data.parseError) await attachLocators(page, data.elements, 5, 3);
537
+ } else if (cfg.mode === 'heal') {
538
+ data = await page.evaluate((input) => {
539
+ const h = window.__pwmcp;
540
+ h.setRoleHint(input.role);
541
+ const scored = [];
542
+ for (const el of Array.prototype.slice.call(document.querySelectorAll('body *'))) {
543
+ if (['script', 'style', 'noscript', 'template', 'svg', 'path'].includes(el.tagName.toLowerCase())) continue;
544
+ const score = h.healScore(el, input.tokens);
545
+ if (score > 0) scored.push({ el: el, score: score });
546
+ }
547
+ scored.sort((a, b) => b.score - a.score);
548
+ // Prefer the innermost element when a parent scores the same.
549
+ const picked = [];
550
+ for (const item of scored) {
551
+ if (picked.length >= 5) break;
552
+ if (picked.some((p) => p.score === item.score && p.el.contains(item.el))) {
553
+ const idx = picked.findIndex((p) => p.score === item.score && p.el.contains(item.el));
554
+ picked[idx] = item;
555
+ continue;
556
+ }
557
+ if (picked.some((p) => item.el.contains(p.el) && p.score >= item.score)) continue;
558
+ picked.push(item);
559
+ }
560
+ h.els = picked.map((p) => p.el);
561
+ const elements = picked.map((p) => Object.assign(h.describe(p.el), { score: p.score }));
562
+ return { matchCount: elements.length, elementCount: document.querySelectorAll('*').length, elements: elements, title: document.title };
563
+ }, { tokens: cfg.healTokens || [], role: cfg.healRole });
564
+ await attachLocators(page, data.elements, 5, 3);
565
+ } else {
566
+ data = await page.evaluate((input) => {
567
+ const h = window.__pwmcp;
568
+ const result = { matchCount: 0, elementCount: 0, elements: [], parseError: undefined };
569
+ let source = [];
570
+ if (input.selector) {
571
+ try {
572
+ source = Array.prototype.slice.call(document.querySelectorAll(input.selector));
573
+ } catch (err) {
574
+ result.parseError = err && err.message ? err.message : String(err);
575
+ }
576
+ } else {
577
+ source = Array.prototype.slice.call(document.querySelectorAll('*'));
578
+ if (input.view === 'locators') source = source.filter((el) => h.isMeaningful(el));
579
+ }
580
+ if (result.parseError) return result;
581
+
582
+ result.matchCount = source.length;
583
+ result.elementCount = input.selector || input.view === 'locators' ? source.length : document.querySelectorAll('*').length;
584
+ h.els = source.slice(0, input.limit);
585
+ result.elements = h.els.map((el) => h.describe(el));
586
+ result.title = document.title;
587
+ result.finalUrl = location.href;
588
+ result.viewport = { width: window.innerWidth, height: window.innerHeight };
589
+ if (input.includeHtml) {
590
+ const html = document.documentElement.outerHTML;
591
+ result.html = html.slice(0, input.maxHtml);
592
+ result.htmlTruncated = html.length > input.maxHtml;
593
+ }
594
+ return result;
595
+ }, {
596
+ selector: cfg.selector,
597
+ view: cfg.view,
598
+ limit: cfg.view === 'locators' ? 80 : 100,
599
+ includeHtml: !!cfg.includeHtml,
600
+ maxHtml: cfg.maxHtmlChars || 20000,
601
+ });
602
+ if (!data.parseError) {
603
+ // Verify locators for the elements an agent is likely to act on;
604
+ // the full inventory would cost a browser round trip per candidate.
605
+ if (cfg.view === 'locators' || cfg.selector) {
606
+ await attachLocators(page, data.elements, 60, 2);
607
+ } else {
608
+ const meaningful = await page.evaluate(() => window.__pwmcp.els.map((el) => window.__pwmcp.isMeaningful(el)));
609
+ let budget = 30;
610
+ for (let i = 0; i < data.elements.length; i += 1) {
611
+ const el = data.elements[i];
612
+ if (meaningful[i] && budget > 0) {
613
+ budget -= 1;
614
+ el.locators = await verifyCandidates(page, i, el.candidates, 2);
615
+ }
616
+ delete el.candidates;
617
+ }
618
+ }
619
+ }
620
+ }
621
+
622
+ data.consoleMessages = consoleMessages;
623
+ data.actionsDone = actionsDone;
624
+ data.finalUrl = data.finalUrl || page.url();
625
+ data.durationMs = Date.now() - cfg.startedAt;
626
+ send({ ok: true, data: data });
627
+ await browser.close();
628
+ process.exit(0);
629
+ } catch (err) {
630
+ const message = err && err.message ? err.message : String(err);
631
+ const classified = classifyError(message);
632
+ send({ ok: false, kind: classified.kind, error: message, hint: classified.hint, actionsDone: err && err.actionsDone });
633
+ try { if (browser) await browser.close(); } catch (closeErr) { /* ignore */ }
634
+ process.exit(1);
635
+ }
636
+ })().catch((err) => {
637
+ send({ ok: false, kind: 'UNKNOWN', error: err && err.message ? err.message : String(err) });
638
+ process.exit(1);
639
+ });
640
+ `;
641
+ //# sourceMappingURL=probe-script.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe-script.js","sourceRoot":"","sources":["../../src/tools/probe-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4mBrC,CAAC"}
@@ -3,7 +3,7 @@
3
3
  * results (stats, failures, diagnostics, hints).
4
4
  */
5
5
  import { z } from 'zod';
6
- import type { ToolContext, ToolResponse } from '../types/index.js';
6
+ import type { RunTestResult, ToolContext, ToolResponse } from '../types/index.js';
7
7
  declare const runTestInput: z.ZodObject<{
8
8
  projectRoot: z.ZodOptional<z.ZodString>;
9
9
  testFiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -22,6 +22,7 @@ declare const runTestInput: z.ZodObject<{
22
22
  lastFailed: z.ZodOptional<z.ZodBoolean>;
23
23
  config: z.ZodOptional<z.ZodString>;
24
24
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
+ background: z.ZodOptional<z.ZodBoolean>;
25
26
  }, z.core.$strip>;
26
27
  export type RunTestInput = z.infer<typeof runTestInput>;
27
28
  export declare const runTestSchema: z.ZodObject<{
@@ -42,7 +43,9 @@ export declare const runTestSchema: z.ZodObject<{
42
43
  lastFailed: z.ZodOptional<z.ZodBoolean>;
43
44
  config: z.ZodOptional<z.ZodString>;
44
45
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
+ background: z.ZodOptional<z.ZodBoolean>;
45
47
  }, z.core.$strip>;
48
+ export declare function renderRunResult(result: RunTestResult, root: string): string;
46
49
  export declare const runTestTool: {
47
50
  name: string;
48
51
  description: string;
@@ -64,6 +67,7 @@ export declare const runTestTool: {
64
67
  lastFailed: z.ZodOptional<z.ZodBoolean>;
65
68
  config: z.ZodOptional<z.ZodString>;
66
69
  args: z.ZodOptional<z.ZodArray<z.ZodString>>;
70
+ background: z.ZodOptional<z.ZodBoolean>;
67
71
  }, z.core.$strip>;
68
72
  handler: (args: RunTestInput, ctx: ToolContext) => Promise<ToolResponse>;
69
73
  };