@matware/e2e-runner 1.1.1 → 1.3.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
package/src/actions.js CHANGED
@@ -8,7 +8,21 @@
8
8
  */
9
9
 
10
10
  import path from 'path';
11
- import { log } from './logger.js';
11
+
12
+ /** All recognized action types — single source of truth for validation. */
13
+ export const KNOWN_ACTION_TYPES = new Set([
14
+ 'goto', 'click', 'type', 'fill', 'wait', 'screenshot',
15
+ 'assert_text', 'assert_url', 'assert_visible', 'assert_count',
16
+ 'assert_element_text', 'assert_attribute', 'assert_class',
17
+ 'assert_not_visible', 'assert_input_value', 'assert_matches',
18
+ 'assert_no_network_errors', 'assert_storage',
19
+ 'get_text', 'select', 'clear', 'clear_cookies', 'press', 'scroll', 'hover',
20
+ 'navigate', 'evaluate',
21
+ 'type_react', 'click_regex', 'click_option', 'focus_autocomplete', 'click_chip',
22
+ 'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
23
+ 'assert_text_in', 'assert_no_text',
24
+ 'gql', 'wait_network_idle',
25
+ ]);
12
26
 
13
27
  function sleep(ms) {
14
28
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -31,13 +45,14 @@ export async function executeAction(page, action, config) {
31
45
  await page.waitForSelector(selector, { timeout });
32
46
  await page.click(selector);
33
47
  } else if (text) {
48
+ const clickTextSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], div[class*="cursor"], span, li, td, th, label, p, h1, h2, h3, h4, h5, h6, dd, dt';
34
49
  await page.waitForFunction(
35
- (t) => [...document.querySelectorAll('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span')]
50
+ (t, sel) => [...document.querySelectorAll(sel)]
36
51
  .find(el => el.textContent.includes(t)),
37
52
  { timeout },
38
- text
53
+ text, clickTextSelector
39
54
  );
40
- await page.$$eval('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span', (els, t) => {
55
+ await page.$$eval(clickTextSelector, (els, t) => {
41
56
  const el = els.find(e => e.textContent.includes(t));
42
57
  if (el) el.click();
43
58
  }, text);
@@ -54,13 +69,21 @@ export async function executeAction(page, action, config) {
54
69
 
55
70
  case 'wait':
56
71
  if (selector) {
57
- await page.waitForSelector(selector, { timeout });
72
+ try {
73
+ await page.waitForSelector(selector, { timeout });
74
+ } catch (e) {
75
+ throw new Error(`wait failed: selector "${selector}" not found after ${timeout}ms`);
76
+ }
58
77
  } else if (text) {
59
- await page.waitForFunction(
60
- (t) => document.body.innerText.includes(t),
61
- { timeout },
62
- text
63
- );
78
+ try {
79
+ await page.waitForFunction(
80
+ (t) => document.body.innerText.includes(t),
81
+ { timeout },
82
+ text
83
+ );
84
+ } catch (e) {
85
+ throw new Error(`wait failed: text "${text}" not found after ${timeout}ms`);
86
+ }
64
87
  } else if (value) {
65
88
  await sleep(parseInt(value));
66
89
  }
@@ -73,6 +96,13 @@ export async function executeAction(page, action, config) {
73
96
  }
74
97
  // Sanitize: use only the basename to prevent path traversal
75
98
  filename = path.basename(filename);
99
+ // Inject timestamp before extension to make filenames unique per run
100
+ // (prevents overwriting previous runs' screenshots)
101
+ if (value) {
102
+ const ext = path.extname(filename);
103
+ const base = filename.slice(0, -ext.length);
104
+ filename = `${base}-${Date.now()}${ext}`;
105
+ }
76
106
  const filepath = path.join(screenshotsDir, filename);
77
107
  await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
78
108
  return { screenshot: filepath };
@@ -86,10 +116,38 @@ export async function executeAction(page, action, config) {
86
116
  break;
87
117
  }
88
118
 
119
+ case 'assert_no_text': {
120
+ // Assert that text does NOT appear anywhere on the page.
121
+ // text: substring to check for absence (required)
122
+ const bodyTextNo = await page.evaluate(() => document.body.innerText);
123
+ if (bodyTextNo.includes(text)) {
124
+ throw new Error(`assert_no_text failed: "${text}" was found on the page but should not be present`);
125
+ }
126
+ break;
127
+ }
128
+
89
129
  case 'assert_url': {
90
130
  const currentUrl = page.url();
91
- if (!currentUrl.includes(value)) {
92
- throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
131
+ let match = false;
132
+ if (value.startsWith('/')) {
133
+ // Path-only comparison: extract pathname (+ query if value has ?)
134
+ try {
135
+ const parsed = new URL(currentUrl);
136
+ const compareTo = value.includes('?') ? parsed.pathname + parsed.search : parsed.pathname;
137
+ match = compareTo === value || compareTo.startsWith(value);
138
+ } catch {
139
+ match = currentUrl.includes(value);
140
+ }
141
+ if (!match) {
142
+ const pathname = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
143
+ throw new Error(`assert_url failed: expected path "${value}", got "${pathname}" (full: ${currentUrl})`);
144
+ }
145
+ } else {
146
+ // Full URL comparison (backwards compatible)
147
+ match = currentUrl.includes(value);
148
+ if (!match) {
149
+ throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
150
+ }
93
151
  }
94
152
  break;
95
153
  }
@@ -111,13 +169,131 @@ export async function executeAction(page, action, config) {
111
169
 
112
170
  case 'assert_count': {
113
171
  const count = await page.$$eval(selector, els => els.length);
114
- const expected = parseInt(value);
115
- if (count !== expected) {
116
- throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
172
+ const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
173
+ if (opMatch) {
174
+ const [, op, numStr] = opMatch;
175
+ const expected = parseInt(numStr);
176
+ const passed = op === '>' ? count > expected
177
+ : op === '>=' ? count >= expected
178
+ : op === '<' ? count < expected
179
+ : count <= expected;
180
+ if (!passed) {
181
+ throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${op}${expected}`);
182
+ }
183
+ } else {
184
+ const expected = parseInt(value);
185
+ if (count !== expected) {
186
+ throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
187
+ }
188
+ }
189
+ break;
190
+ }
191
+
192
+ case 'assert_element_text': {
193
+ await page.waitForSelector(selector, { timeout });
194
+ const elText = await page.$eval(selector, el => el.textContent);
195
+ if (value === 'exact') {
196
+ if (elText.trim() !== text) {
197
+ throw new Error(`assert_element_text failed: "${selector}" text is "${elText.trim()}", expected exact "${text}"`);
198
+ }
199
+ } else {
200
+ if (!elText.includes(text)) {
201
+ throw new Error(`assert_element_text failed: "${selector}" text "${elText.trim()}" does not contain "${text}"`);
202
+ }
203
+ }
204
+ break;
205
+ }
206
+
207
+ case 'assert_attribute': {
208
+ await page.waitForSelector(selector, { timeout });
209
+ const eqIndex = value.indexOf('=');
210
+ if (eqIndex === -1) {
211
+ const hasAttr = await page.$eval(selector, (el, attr) => el.hasAttribute(attr), value);
212
+ if (!hasAttr) {
213
+ throw new Error(`assert_attribute failed: "${selector}" does not have attribute "${value}"`);
214
+ }
215
+ } else {
216
+ const attrName = value.slice(0, eqIndex);
217
+ const expectedVal = value.slice(eqIndex + 1);
218
+ const actual = await page.$eval(selector, (el, attr) => el.getAttribute(attr), attrName);
219
+ if (actual !== expectedVal) {
220
+ throw new Error(`assert_attribute failed: "${selector}" attribute "${attrName}" is "${actual}", expected "${expectedVal}"`);
221
+ }
222
+ }
223
+ break;
224
+ }
225
+
226
+ case 'assert_class': {
227
+ await page.waitForSelector(selector, { timeout });
228
+ const hasClass = await page.$eval(selector, (el, cls) => el.classList.contains(cls), value);
229
+ if (!hasClass) {
230
+ throw new Error(`assert_class failed: "${selector}" does not have class "${value}"`);
117
231
  }
118
232
  break;
119
233
  }
120
234
 
235
+ case 'assert_not_visible': {
236
+ const notVisEl = await page.$(selector);
237
+ if (notVisEl) {
238
+ const isVisible = await page.$eval(selector, (e) => {
239
+ const style = window.getComputedStyle(e);
240
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
241
+ });
242
+ if (isVisible) {
243
+ throw new Error(`assert_not_visible failed: "${selector}" is visible`);
244
+ }
245
+ }
246
+ break;
247
+ }
248
+
249
+ case 'assert_input_value': {
250
+ await page.waitForSelector(selector, { timeout });
251
+ const inputVal = await page.$eval(selector, el => el.value);
252
+ if (!inputVal.includes(value)) {
253
+ throw new Error(`assert_input_value failed: "${selector}" value is "${inputVal}", expected to contain "${value}"`);
254
+ }
255
+ break;
256
+ }
257
+
258
+ case 'assert_matches': {
259
+ await page.waitForSelector(selector, { timeout });
260
+ const matchText = await page.$eval(selector, el => el.textContent);
261
+ if (!new RegExp(value).test(matchText)) {
262
+ throw new Error(`assert_matches failed: "${selector}" text "${matchText.trim()}" does not match pattern /${value}/`);
263
+ }
264
+ break;
265
+ }
266
+
267
+ case 'assert_text_in': {
268
+ // Assert that text exists inside a scoped container element.
269
+ // selector: CSS selector for the container (required)
270
+ // text: substring or regex pattern to match against container's textContent (required)
271
+ // value: "i" for case-insensitive regex (default), "exact" for case-sensitive substring
272
+ if (!selector) throw new Error('assert_text_in requires "selector"');
273
+ if (!text) throw new Error('assert_text_in requires "text"');
274
+ await page.waitForSelector(selector, { timeout });
275
+ const containerText = await page.$$eval(selector, els => els.map(el => el.textContent).join(' '));
276
+ const flags = value === 'exact' ? '' : 'i';
277
+ if (value === 'exact') {
278
+ if (!containerText.includes(text)) {
279
+ const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
280
+ throw new Error(`assert_text_in failed: "${text}" not found in "${selector}"\n Content: ${preview}`);
281
+ }
282
+ } else {
283
+ if (!new RegExp(text, flags).test(containerText)) {
284
+ const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
285
+ throw new Error(`assert_text_in failed: /${text}/${flags} not found in "${selector}"\n Content: ${preview}`);
286
+ }
287
+ }
288
+ break;
289
+ }
290
+
291
+ case 'get_text': {
292
+ await page.waitForSelector(selector, { timeout });
293
+ const getText = await page.$eval(selector, el => el.textContent.trim());
294
+ return { value: getText };
295
+ }
296
+
121
297
  case 'select':
122
298
  await page.waitForSelector(selector, { timeout });
123
299
  await page.select(selector, value);
@@ -162,21 +338,422 @@ export async function executeAction(page, action, config) {
162
338
  break;
163
339
  }
164
340
 
341
+ case 'clear_cookies': {
342
+ const client = await page.createCDPSession();
343
+ await client.send('Network.clearBrowserCookies');
344
+ await client.send('Storage.clearDataForOrigin', {
345
+ origin: value || baseUrl || page.url(),
346
+ storageTypes: 'cookies,local_storage,session_storage',
347
+ });
348
+ await client.detach();
349
+ break;
350
+ }
351
+
352
+ case 'type_react': {
353
+ // Types into React controlled inputs using the native value setter.
354
+ // This bypasses React's synthetic event system which ignores programmatic .value changes.
355
+ await page.waitForSelector(selector, { timeout });
356
+ await page.evaluate((sel, val) => {
357
+ const input = document.querySelector(sel);
358
+ if (!input) throw new Error(`type_react: element "${sel}" not found`);
359
+ const proto = input instanceof HTMLTextAreaElement
360
+ ? window.HTMLTextAreaElement.prototype
361
+ : window.HTMLInputElement.prototype;
362
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
363
+ if (!descriptor || !descriptor.set) {
364
+ throw new Error(`type_react: element "${sel}" has no writable value property`);
365
+ }
366
+ descriptor.set.call(input, val);
367
+ input.dispatchEvent(new Event('input', { bubbles: true }));
368
+ input.dispatchEvent(new Event('change', { bubbles: true }));
369
+ input.focus();
370
+ }, selector, value);
371
+ break;
372
+ }
373
+
374
+ case 'click_regex': {
375
+ // Click an element whose textContent matches a regex pattern.
376
+ // text = regex pattern (always case-insensitive)
377
+ // selector = optional CSS scope (defaults to common clickable elements)
378
+ // value = "last" to click the last match (default: first)
379
+ const matchSelector = selector || 'button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], div[class*="cursor"], span, li, td, th, label, p, h1, h2, h3, h4, h5, h6, dd, dt';
380
+ const matchLast = value === 'last';
381
+ await page.waitForFunction(
382
+ (regex, sel) => [...document.querySelectorAll(sel)].some(el => new RegExp(regex, 'i').test(el.textContent)),
383
+ { timeout },
384
+ text, matchSelector
385
+ );
386
+ const clicked = await page.$$eval(matchSelector, (els, regex, last) => {
387
+ const matches = els.filter(el => new RegExp(regex, 'i').test(el.textContent));
388
+ if (matches.length === 0) return false;
389
+ const target = last ? matches[matches.length - 1] : matches[0];
390
+ target.click();
391
+ return true;
392
+ }, text, matchLast);
393
+ if (!clicked) {
394
+ throw new Error(`click_regex failed: no element matching /${text}/i found`);
395
+ }
396
+ break;
397
+ }
398
+
399
+ case 'click_option': {
400
+ // Click a [role="option"] element by text content — common in autocomplete dropdowns.
401
+ await page.waitForFunction(
402
+ (t) => [...document.querySelectorAll('[role="option"]')].some(el => el.textContent.includes(t)),
403
+ { timeout },
404
+ text
405
+ );
406
+ const optionClicked = await page.$$eval('[role="option"]', (els, t) => {
407
+ const match = els.find(el => el.textContent.includes(t));
408
+ if (match) { match.click(); return true; }
409
+ return false;
410
+ }, text);
411
+ if (!optionClicked) {
412
+ throw new Error(`click_option failed: no [role="option"] containing "${text}" found`);
413
+ }
414
+ break;
415
+ }
416
+
417
+ case 'focus_autocomplete': {
418
+ // Focus an autocomplete/combobox input by its label text.
419
+ // Supports MUI Autocomplete (.MuiAutocomplete-root) and generic [role="combobox"].
420
+ const focused = await page.evaluate((labelText) => {
421
+ const containers = [
422
+ ...document.querySelectorAll('.MuiAutocomplete-root'),
423
+ ...document.querySelectorAll('[role="combobox"]'),
424
+ ];
425
+ const match = containers.find(c => {
426
+ const label = c.querySelector('label');
427
+ return label && label.textContent.includes(labelText);
428
+ });
429
+ if (!match) return null;
430
+ const input = match.querySelector('input');
431
+ if (!input) return null;
432
+ input.focus();
433
+ input.click();
434
+ return input.id || 'focused';
435
+ }, text);
436
+ if (!focused) {
437
+ throw new Error(`focus_autocomplete failed: no autocomplete with label "${text}" found`);
438
+ }
439
+ break;
440
+ }
441
+
442
+ case 'click_chip': {
443
+ // Click a chip/tag element by text content.
444
+ // Searches MUI Chip classes and common chip patterns.
445
+ const chipClicked = await page.evaluate((chipText) => {
446
+ const chips = Array.from(document.querySelectorAll(
447
+ '[class*="Chip"], [class*="chip"], [data-chip], [role="option"][aria-selected]'
448
+ ));
449
+ const match = chips.find(c => c.textContent.includes(chipText));
450
+ if (!match) return false;
451
+ match.click();
452
+ return true;
453
+ }, text);
454
+ if (!chipClicked) {
455
+ throw new Error(`click_chip failed: no chip containing "${text}" found`);
456
+ }
457
+ break;
458
+ }
459
+
460
+ case 'set_storage': {
461
+ // Set a localStorage or sessionStorage key.
462
+ // value: "key=val", selector: "session" for sessionStorage (default: localStorage)
463
+ const eqIdx = value.indexOf('=');
464
+ if (eqIdx === -1) {
465
+ throw new Error(`set_storage: value must be "key=value", got "${value}"`);
466
+ }
467
+ const storageKey = value.slice(0, eqIdx);
468
+ const storageVal = value.slice(eqIdx + 1);
469
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
470
+ await page.evaluate((sType, k, v) => {
471
+ window[sType].setItem(k, v);
472
+ }, storageType, storageKey, storageVal);
473
+ break;
474
+ }
475
+
476
+ case 'assert_storage': {
477
+ // Assert a localStorage or sessionStorage key exists or has a specific value.
478
+ // value: "key" (existence) or "key=expected" (value match)
479
+ // selector: "session" for sessionStorage (default: localStorage)
480
+ const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
481
+ const eqIdx = value.indexOf('=');
482
+ if (eqIdx === -1) {
483
+ // Existence check
484
+ const exists = await page.evaluate((sType, k) => window[sType].getItem(k) !== null, storageType, value);
485
+ if (!exists) {
486
+ throw new Error(`assert_storage failed: ${storageType} key "${value}" does not exist`);
487
+ }
488
+ } else {
489
+ const storageKey = value.slice(0, eqIdx);
490
+ const expectedVal = value.slice(eqIdx + 1);
491
+ const actual = await page.evaluate((sType, k) => window[sType].getItem(k), storageType, storageKey);
492
+ if (actual === null) {
493
+ throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" does not exist`);
494
+ }
495
+ if (actual !== expectedVal) {
496
+ throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" is "${actual}", expected "${expectedVal}"`);
497
+ }
498
+ }
499
+ break;
500
+ }
501
+
502
+ case 'click_icon': {
503
+ // Click an icon element by identifier — works with MUI, FontAwesome, Heroicons, Bootstrap Icons, etc.
504
+ // value: icon identifier (data-testid fragment, class fragment, aria-label, or SVG text/title)
505
+ // selector: optional CSS scope to narrow the search
506
+ const iconId = value;
507
+ const iconScope = selector || null;
508
+ await page.waitForFunction(
509
+ (id, scope) => {
510
+ const root = scope ? document.querySelector(scope) : document;
511
+ if (!root) return false;
512
+ // Search by common icon attribute patterns
513
+ const attrSelectors = [
514
+ `[data-testid*="${id}"]`,
515
+ `[data-icon*="${id}"]`,
516
+ `[aria-label*="${id}"]`,
517
+ `svg[class*="${id}"]`,
518
+ `i[class*="${id}"]`,
519
+ `span[class*="${id}"]`,
520
+ ];
521
+ for (const sel of attrSelectors) {
522
+ if (root.querySelector(sel)) return true;
523
+ }
524
+ // Search all SVGs for matching text content or title
525
+ for (const svg of root.querySelectorAll('svg')) {
526
+ const title = svg.querySelector('title');
527
+ if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) return true;
528
+ if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) return true;
529
+ }
530
+ return false;
531
+ },
532
+ { timeout },
533
+ iconId, iconScope
534
+ );
535
+ const clicked = await page.evaluate(
536
+ (id, scope) => {
537
+ const root = scope ? document.querySelector(scope) : document;
538
+ if (!root) return false;
539
+ let icon = null;
540
+ const attrSelectors = [
541
+ `[data-testid*="${id}"]`,
542
+ `[data-icon*="${id}"]`,
543
+ `[aria-label*="${id}"]`,
544
+ `svg[class*="${id}"]`,
545
+ `i[class*="${id}"]`,
546
+ `span[class*="${id}"]`,
547
+ ];
548
+ for (const sel of attrSelectors) {
549
+ icon = root.querySelector(sel);
550
+ if (icon) break;
551
+ }
552
+ // Fallback: search SVGs by title/aria-label text
553
+ if (!icon) {
554
+ for (const svg of root.querySelectorAll('svg')) {
555
+ const title = svg.querySelector('title');
556
+ if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
557
+ if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
558
+ }
559
+ }
560
+ if (!icon) return false;
561
+ // Walk up to nearest clickable ancestor
562
+ const clickableSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"]';
563
+ const clickable = icon.closest(clickableSelector);
564
+ (clickable || icon).click();
565
+ return true;
566
+ },
567
+ iconId, iconScope
568
+ );
569
+ if (!clicked) {
570
+ throw new Error(`click_icon failed: no icon matching "${iconId}" found${iconScope ? ` in "${iconScope}"` : ''}`);
571
+ }
572
+ break;
573
+ }
574
+
575
+ case 'click_menu_item': {
576
+ // Click a menu item by text content.
577
+ // text: menu item text to match (case-sensitive, substring)
578
+ // selector: optional CSS scope
579
+ const menuSelector = [
580
+ '[role="menuitem"]',
581
+ '[role="menuitemradio"]',
582
+ '[role="menuitemcheckbox"]',
583
+ '.dropdown-item',
584
+ '.menu-item',
585
+ '[class*="MenuItem"]',
586
+ '[role="menu"] > li',
587
+ ].join(', ');
588
+ const menuScope = selector || null;
589
+ await page.waitForFunction(
590
+ (t, sel, scope) => {
591
+ const root = scope ? document.querySelector(scope) : document;
592
+ if (!root) return false;
593
+ return [...root.querySelectorAll(sel)].some(el => el.textContent.includes(t));
594
+ },
595
+ { timeout },
596
+ text, menuSelector, menuScope
597
+ );
598
+ const clicked = await page.evaluate(
599
+ (t, sel, scope) => {
600
+ const root = scope ? document.querySelector(scope) : document;
601
+ if (!root) return false;
602
+ const match = [...root.querySelectorAll(sel)].find(el => el.textContent.includes(t));
603
+ if (match) { match.click(); return true; }
604
+ return false;
605
+ },
606
+ text, menuSelector, menuScope
607
+ );
608
+ if (!clicked) {
609
+ throw new Error(`click_menu_item failed: no menu item containing "${text}" found${menuScope ? ` in "${menuScope}"` : ''}`);
610
+ }
611
+ break;
612
+ }
613
+
614
+ case 'click_in_context': {
615
+ // Click a child element within a container identified by text content.
616
+ // text: text to find the container (required)
617
+ // selector: CSS selector for the child to click within that container (required)
618
+ if (!text || !selector) {
619
+ throw new Error('click_in_context requires both "text" (container text) and "selector" (child to click)');
620
+ }
621
+ const containerSelectors = [
622
+ 'section', 'article',
623
+ '[class*="card"]', '[class*="Card"]',
624
+ '[class*="panel"]', '[class*="Panel"]',
625
+ '[class*="item"]', '[class*="Item"]',
626
+ '.MuiGrid-item', '[class*="MuiGrid2"]',
627
+ '[class*="row"]', '[class*="Row"]',
628
+ 'details', 'fieldset',
629
+ '[role="region"]', '[role="group"]', '[role="listitem"]',
630
+ 'li', 'tr', 'div[class]',
631
+ ].join(', ');
632
+ await page.waitForFunction(
633
+ (t, childSel, containerSels) => {
634
+ const containers = [...document.querySelectorAll(containerSels)]
635
+ .filter(el => el.textContent.includes(t));
636
+ // Sort by innerHTML length (smallest = most specific)
637
+ containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
638
+ for (const c of containers) {
639
+ if (c.querySelector(childSel)) return true;
640
+ }
641
+ return false;
642
+ },
643
+ { timeout },
644
+ text, selector, containerSelectors
645
+ );
646
+ const clicked = await page.evaluate(
647
+ (t, childSel, containerSels) => {
648
+ const containers = [...document.querySelectorAll(containerSels)]
649
+ .filter(el => el.textContent.includes(t));
650
+ containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
651
+ for (const c of containers) {
652
+ const child = c.querySelector(childSel);
653
+ if (child) { child.click(); return true; }
654
+ }
655
+ return false;
656
+ },
657
+ text, selector, containerSelectors
658
+ );
659
+ if (!clicked) {
660
+ throw new Error(`click_in_context failed: no "${selector}" found in container with text "${text}"`);
661
+ }
662
+ break;
663
+ }
664
+
665
+ case 'gql': {
666
+ // Execute a GraphQL query/mutation via browser fetch.
667
+ // Reads auth token from localStorage and sends it as a configurable header.
668
+ // Installs window.__e2eGql(query, vars) helper for use in subsequent evaluate actions.
669
+ //
670
+ // value: GraphQL query/mutation string (required)
671
+ // text: variables as JSON string (optional)
672
+ // selector: JS expression assertion — receives response as `r` (optional)
673
+ const gqlEndpoint = config.gqlEndpoint || '/api/graphql';
674
+ const gqlAuthHeader = config.gqlAuthHeader || 'Authorization';
675
+ const gqlAuthKey = config.gqlAuthKey || 'accessToken';
676
+ const gqlAuthPrefix = config.gqlAuthPrefix ?? 'Bearer ';
677
+ const gqlVars = text || undefined;
678
+
679
+ const gqlResult = await page.evaluate(async (query, varsJson, endpoint, authHdr, authKey, authPfx) => {
680
+ // Install reusable helper on first call
681
+ if (!window.__e2eGql) {
682
+ window.__e2eGqlConfig = { endpoint, authHeader: authHdr, authKey, authPrefix: authPfx };
683
+ window.__e2eGql = async (q, v) => {
684
+ const cfg = window.__e2eGqlConfig;
685
+ const token = localStorage.getItem(cfg.authKey);
686
+ const headers = { 'Content-Type': 'application/json' };
687
+ if (token) headers[cfg.authHeader] = cfg.authPrefix + token;
688
+ const resp = await fetch(location.origin + cfg.endpoint, {
689
+ method: 'POST', headers,
690
+ body: JSON.stringify({ query: q, variables: v }),
691
+ });
692
+ return resp.json();
693
+ };
694
+ }
695
+
696
+ const vars = varsJson ? JSON.parse(varsJson) : undefined;
697
+ const response = await window.__e2eGql(query, vars);
698
+ window.__e2eLastGql = response;
699
+ return response;
700
+ }, value, gqlVars, gqlEndpoint, gqlAuthHeader, gqlAuthKey, gqlAuthPrefix);
701
+
702
+ // Check for GraphQL errors
703
+ if (gqlResult.errors?.length) {
704
+ throw new Error(`gql failed: ${gqlResult.errors.map(e => e.message).join('; ')}`);
705
+ }
706
+
707
+ // Optional assertion via selector field (JS expression, `r` = full response)
708
+ // Intentional: runs JS in browser page context from team-authored JSON test files,
709
+ // same security model as the 'evaluate' action type.
710
+ if (selector) {
711
+ const assertResult = await page.evaluate((code, r) => {
712
+ const fn = new Function('r', `return (${code})`); // eslint-disable-line no-new-func
713
+ return fn(r);
714
+ }, selector, gqlResult);
715
+
716
+ if (typeof assertResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(assertResult)) {
717
+ throw new Error(`gql assertion: ${assertResult}`);
718
+ }
719
+ if (assertResult === false) {
720
+ throw new Error(`gql assertion returned false`);
721
+ }
722
+ }
723
+
724
+ return { value: gqlResult.data };
725
+ }
726
+
165
727
  case 'evaluate': {
166
728
  // Intentional: runs JS in browser page context (from test JSON files)
167
- const evalResult = await page.evaluate(value);
168
- // Check return value for failure signals
729
+ const jsSnippet = value.length > 120 ? value.slice(0, 120) + '...' : value;
730
+ let evalResult;
731
+ try {
732
+ evalResult = await page.evaluate(value);
733
+ } catch (evalErr) {
734
+ const pageUrl = page.url();
735
+ throw new Error(`evaluate threw on ${pageUrl}: ${evalErr.message}\n JS: ${jsSnippet}`);
736
+ }
169
737
  if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
170
- throw new Error(`evaluate failed: ${evalResult}`);
738
+ const pageUrl = page.url();
739
+ throw new Error(`evaluate failed on ${pageUrl}: ${evalResult}\n JS: ${jsSnippet}`);
171
740
  }
172
741
  if (evalResult === false) {
173
- throw new Error('evaluate returned false');
742
+ const pageUrl = page.url();
743
+ throw new Error(`evaluate returned false on ${pageUrl}\n JS: ${jsSnippet}`);
174
744
  }
175
745
  return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
176
746
  }
177
747
 
748
+ case 'wait_network_idle': {
749
+ const idleTime = value ? parseInt(value) : 500;
750
+ const maxTimeout = action.timeout ? parseInt(action.timeout) : 30000;
751
+ await page.waitForNetworkIdle({ idleTime, timeout: maxTimeout });
752
+ break;
753
+ }
754
+
178
755
  default:
179
- log('⚠️', `Unknown action: ${type}`);
756
+ throw new Error(`Unknown action type: "${type}"`);
180
757
  }
181
758
 
182
759
  return null;