@matware/e2e-runner 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +475 -307
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +194 -6
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +10 -2
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +273 -18
  17. package/src/ai-generate.js +87 -7
  18. package/src/config.js +28 -0
  19. package/src/dashboard.js +156 -6
  20. package/src/db.js +207 -13
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +448 -18
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +35 -2
  31. package/src/runner.js +120 -46
  32. package/src/verify.js +5 -3
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +964 -378
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
@@ -0,0 +1,182 @@
1
+ # Troubleshooting Guide
2
+
3
+ ## Pool Connection Issues
4
+
5
+ ### "Pool not reachable" / Connection refused
6
+
7
+ **Cause**: Chrome pool (browserless/chrome Docker container) is not running.
8
+
9
+ **Fix**:
10
+ ```bash
11
+ npx e2e-runner pool start
12
+ npx e2e-runner pool status # verify it's running
13
+ ```
14
+
15
+ Pool management is CLI-only — `pool start` and `pool stop` are not available via MCP.
16
+
17
+ ### "Pool at capacity" / Tests queuing
18
+
19
+ **Cause**: All Chrome sessions are occupied.
20
+
21
+ **Fix**: Increase capacity or reduce concurrency:
22
+ ```bash
23
+ npx e2e-runner pool stop
24
+ npx e2e-runner pool start --max-sessions 10
25
+ ```
26
+ Or reduce test concurrency: `--concurrency 2`
27
+
28
+ The runner checks `/pressure` before each connection and waits up to 60s for a free slot.
29
+
30
+ ### Docker not running
31
+
32
+ **Cause**: Docker daemon is not started.
33
+
34
+ **Fix**: Start Docker Desktop or `sudo systemctl start docker`, then `npx e2e-runner pool start`.
35
+
36
+ ## React / SPA Issues
37
+
38
+ ### React inputs not updating state
39
+
40
+ **Symptom**: `type` action enters text but React state doesn't change (form validation fails, submit disabled).
41
+
42
+ **Fix**: Use `type_react` instead of `type` for React controlled inputs:
43
+ ```json
44
+ { "type": "type_react", "selector": "#email", "value": "user@test.com" }
45
+ ```
46
+
47
+ `type_react` uses the native value setter and dispatches `input` + `change` events that React's synthetic event system recognizes.
48
+
49
+ ### SPA navigation not completing
50
+
51
+ **Symptom**: `goto` hangs or times out on client-side route changes.
52
+
53
+ **Fix**: Use `navigate` instead of `goto` for SPA route changes:
54
+ ```json
55
+ { "type": "navigate", "value": "/new-page" }
56
+ ```
57
+
58
+ `navigate` uses a 5s race timeout and won't block if `load` doesn't fire (common in SPAs).
59
+
60
+ ### MUI autocomplete not opening
61
+
62
+ **Symptom**: Clicking or typing in an MUI Autocomplete doesn't open the dropdown.
63
+
64
+ **Fix**: Use `focus_autocomplete` to properly focus by label text:
65
+ ```json
66
+ { "type": "focus_autocomplete", "text": "Search by name" },
67
+ { "type": "type_react", "selector": "#autocomplete-input", "value": "search term" },
68
+ { "type": "click_option", "text": "Desired option" }
69
+ ```
70
+
71
+ ## Flaky Tests
72
+
73
+ ### Intermittent failures on dynamic content
74
+
75
+ **Symptom**: Tests pass sometimes, fail others. Usually timing-related.
76
+
77
+ **Fixes**:
78
+ 1. Add explicit `wait` before assertions:
79
+ ```json
80
+ { "type": "wait", "selector": ".data-loaded" },
81
+ { "type": "assert_text", "text": "Expected content" }
82
+ ```
83
+
84
+ 2. Use action-level retries for known flaky selectors:
85
+ ```json
86
+ { "type": "click", "selector": "#dynamic-btn", "retries": 3 }
87
+ ```
88
+
89
+ 3. Use test-level retries:
90
+ ```json
91
+ { "name": "flaky-test", "retries": 2, "actions": [...] }
92
+ ```
93
+
94
+ 4. Check the learning system for patterns:
95
+ ```
96
+ e2e_learnings("flaky") → identify consistently flaky tests
97
+ e2e_learnings("selectors") → find unstable selectors
98
+ ```
99
+
100
+ ### Tests interfering with each other
101
+
102
+ **Symptom**: Tests pass individually but fail when run together.
103
+
104
+ **Fix**: Mark tests that share mutable state as `serial`:
105
+ ```json
106
+ { "name": "create-item", "serial": true, "actions": [...] },
107
+ { "name": "verify-item", "serial": true, "actions": [...] }
108
+ ```
109
+
110
+ ## Timeout Issues
111
+
112
+ ### Test timeout (default 60s)
113
+
114
+ **Fix**: Increase per-test or globally:
115
+ ```json
116
+ { "name": "slow-test", "timeout": 120000, "actions": [...] }
117
+ ```
118
+ Or globally: `--test-timeout 120000`
119
+
120
+ ### Action timeout (default 10s)
121
+
122
+ Each action's `waitForSelector` uses the default timeout. Override per-action:
123
+ ```json
124
+ { "type": "wait", "selector": ".slow-element", "timeout": 30000 }
125
+ ```
126
+ Or globally: `--timeout 30000`
127
+
128
+ ## Network Errors
129
+
130
+ ### Tests passing but network requests failing
131
+
132
+ **Symptom**: Tests pass but `networkSummary` shows failed requests.
133
+
134
+ **Fix**: Enable strict mode to fail tests with network errors:
135
+ ```
136
+ e2e_run({ all: true, failOnNetworkError: true })
137
+ ```
138
+
139
+ Or use `assert_no_network_errors` at specific points:
140
+ ```json
141
+ { "type": "goto", "value": "/api-heavy-page" },
142
+ { "type": "wait", "selector": ".loaded" },
143
+ { "type": "assert_no_network_errors" }
144
+ ```
145
+
146
+ ### Investigating specific failures
147
+
148
+ Use network log drill-down:
149
+ ```
150
+ e2e_network_logs(runDbId, errorsOnly: true) → see all failed requests
151
+ e2e_network_logs(runDbId, urlPattern: "/api/patients") → filter by URL
152
+ e2e_network_logs(runDbId, testName: "create-patient", includeBodies: true) → full request/response
153
+ ```
154
+
155
+ ## Common Mistakes
156
+
157
+ ### Using `beforeAll` for browser state
158
+
159
+ `beforeAll` runs on a separate page that closes before tests. Use `beforeEach` for state setup.
160
+
161
+ ### Using `evaluate` for simple assertions
162
+
163
+ Prefer granular assertion actions over `evaluate` with inline JS:
164
+ ```json
165
+ // Bad: verbose, error-prone
166
+ { "type": "evaluate", "value": "if (!document.querySelector('h1').textContent.includes('Dashboard')) throw 'not found'" }
167
+
168
+ // Good: clear, auto-waits
169
+ { "type": "assert_element_text", "selector": "h1", "text": "Dashboard" }
170
+ ```
171
+
172
+ ### Forgetting `cwd` in MCP calls
173
+
174
+ All MCP tools need `cwd` to resolve config files and test directories. Always pass the project root.
175
+
176
+ ### Path-only `assert_url`
177
+
178
+ When checking paths, use path-only format (starts with `/`):
179
+ ```json
180
+ { "type": "assert_url", "value": "/dashboard" }
181
+ ```
182
+ This compares against the pathname only, ignoring the `host.docker.internal` origin.
package/src/actions.js CHANGED
@@ -31,13 +31,14 @@ export async function executeAction(page, action, config) {
31
31
  await page.waitForSelector(selector, { timeout });
32
32
  await page.click(selector);
33
33
  } else if (text) {
34
+ 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
35
  await page.waitForFunction(
35
- (t) => [...document.querySelectorAll('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span')]
36
+ (t, sel) => [...document.querySelectorAll(sel)]
36
37
  .find(el => el.textContent.includes(t)),
37
38
  { timeout },
38
- text
39
+ text, clickTextSelector
39
40
  );
40
- await page.$$eval('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span', (els, t) => {
41
+ await page.$$eval(clickTextSelector, (els, t) => {
41
42
  const el = els.find(e => e.textContent.includes(t));
42
43
  if (el) el.click();
43
44
  }, text);
@@ -54,13 +55,21 @@ export async function executeAction(page, action, config) {
54
55
 
55
56
  case 'wait':
56
57
  if (selector) {
57
- await page.waitForSelector(selector, { timeout });
58
+ try {
59
+ await page.waitForSelector(selector, { timeout });
60
+ } catch (e) {
61
+ throw new Error(`wait failed: selector "${selector}" not found after ${timeout}ms`);
62
+ }
58
63
  } else if (text) {
59
- await page.waitForFunction(
60
- (t) => document.body.innerText.includes(t),
61
- { timeout },
62
- text
63
- );
64
+ try {
65
+ await page.waitForFunction(
66
+ (t) => document.body.innerText.includes(t),
67
+ { timeout },
68
+ text
69
+ );
70
+ } catch (e) {
71
+ throw new Error(`wait failed: text "${text}" not found after ${timeout}ms`);
72
+ }
64
73
  } else if (value) {
65
74
  await sleep(parseInt(value));
66
75
  }
@@ -73,6 +82,13 @@ export async function executeAction(page, action, config) {
73
82
  }
74
83
  // Sanitize: use only the basename to prevent path traversal
75
84
  filename = path.basename(filename);
85
+ // Inject timestamp before extension to make filenames unique per run
86
+ // (prevents overwriting previous runs' screenshots)
87
+ if (value) {
88
+ const ext = path.extname(filename);
89
+ const base = filename.slice(0, -ext.length);
90
+ filename = `${base}-${Date.now()}${ext}`;
91
+ }
76
92
  const filepath = path.join(screenshotsDir, filename);
77
93
  await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
78
94
  return { screenshot: filepath };
@@ -88,8 +104,26 @@ export async function executeAction(page, action, config) {
88
104
 
89
105
  case 'assert_url': {
90
106
  const currentUrl = page.url();
91
- if (!currentUrl.includes(value)) {
92
- throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
107
+ let match = false;
108
+ if (value.startsWith('/')) {
109
+ // Path-only comparison: extract pathname (+ query if value has ?)
110
+ try {
111
+ const parsed = new URL(currentUrl);
112
+ const compareTo = value.includes('?') ? parsed.pathname + parsed.search : parsed.pathname;
113
+ match = compareTo === value || compareTo.startsWith(value);
114
+ } catch {
115
+ match = currentUrl.includes(value);
116
+ }
117
+ if (!match) {
118
+ const pathname = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
119
+ throw new Error(`assert_url failed: expected path "${value}", got "${pathname}" (full: ${currentUrl})`);
120
+ }
121
+ } else {
122
+ // Full URL comparison (backwards compatible)
123
+ match = currentUrl.includes(value);
124
+ if (!match) {
125
+ throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
126
+ }
93
127
  }
94
128
  break;
95
129
  }
@@ -111,13 +145,107 @@ export async function executeAction(page, action, config) {
111
145
 
112
146
  case 'assert_count': {
113
147
  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}`);
148
+ const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
149
+ if (opMatch) {
150
+ const [, op, numStr] = opMatch;
151
+ const expected = parseInt(numStr);
152
+ const passed = op === '>' ? count > expected
153
+ : op === '>=' ? count >= expected
154
+ : op === '<' ? count < expected
155
+ : count <= expected;
156
+ if (!passed) {
157
+ throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${op}${expected}`);
158
+ }
159
+ } else {
160
+ const expected = parseInt(value);
161
+ if (count !== expected) {
162
+ throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
163
+ }
164
+ }
165
+ break;
166
+ }
167
+
168
+ case 'assert_element_text': {
169
+ await page.waitForSelector(selector, { timeout });
170
+ const elText = await page.$eval(selector, el => el.textContent);
171
+ if (value === 'exact') {
172
+ if (elText.trim() !== text) {
173
+ throw new Error(`assert_element_text failed: "${selector}" text is "${elText.trim()}", expected exact "${text}"`);
174
+ }
175
+ } else {
176
+ if (!elText.includes(text)) {
177
+ throw new Error(`assert_element_text failed: "${selector}" text "${elText.trim()}" does not contain "${text}"`);
178
+ }
117
179
  }
118
180
  break;
119
181
  }
120
182
 
183
+ case 'assert_attribute': {
184
+ await page.waitForSelector(selector, { timeout });
185
+ const eqIndex = value.indexOf('=');
186
+ if (eqIndex === -1) {
187
+ const hasAttr = await page.$eval(selector, (el, attr) => el.hasAttribute(attr), value);
188
+ if (!hasAttr) {
189
+ throw new Error(`assert_attribute failed: "${selector}" does not have attribute "${value}"`);
190
+ }
191
+ } else {
192
+ const attrName = value.slice(0, eqIndex);
193
+ const expectedVal = value.slice(eqIndex + 1);
194
+ const actual = await page.$eval(selector, (el, attr) => el.getAttribute(attr), attrName);
195
+ if (actual !== expectedVal) {
196
+ throw new Error(`assert_attribute failed: "${selector}" attribute "${attrName}" is "${actual}", expected "${expectedVal}"`);
197
+ }
198
+ }
199
+ break;
200
+ }
201
+
202
+ case 'assert_class': {
203
+ await page.waitForSelector(selector, { timeout });
204
+ const hasClass = await page.$eval(selector, (el, cls) => el.classList.contains(cls), value);
205
+ if (!hasClass) {
206
+ throw new Error(`assert_class failed: "${selector}" does not have class "${value}"`);
207
+ }
208
+ break;
209
+ }
210
+
211
+ case 'assert_not_visible': {
212
+ const notVisEl = await page.$(selector);
213
+ if (notVisEl) {
214
+ const isVisible = await page.$eval(selector, (e) => {
215
+ const style = window.getComputedStyle(e);
216
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
217
+ });
218
+ if (isVisible) {
219
+ throw new Error(`assert_not_visible failed: "${selector}" is visible`);
220
+ }
221
+ }
222
+ break;
223
+ }
224
+
225
+ case 'assert_input_value': {
226
+ await page.waitForSelector(selector, { timeout });
227
+ const inputVal = await page.$eval(selector, el => el.value);
228
+ if (!inputVal.includes(value)) {
229
+ throw new Error(`assert_input_value failed: "${selector}" value is "${inputVal}", expected to contain "${value}"`);
230
+ }
231
+ break;
232
+ }
233
+
234
+ case 'assert_matches': {
235
+ await page.waitForSelector(selector, { timeout });
236
+ const matchText = await page.$eval(selector, el => el.textContent);
237
+ if (!new RegExp(value).test(matchText)) {
238
+ throw new Error(`assert_matches failed: "${selector}" text "${matchText.trim()}" does not match pattern /${value}/`);
239
+ }
240
+ break;
241
+ }
242
+
243
+ case 'get_text': {
244
+ await page.waitForSelector(selector, { timeout });
245
+ const getText = await page.$eval(selector, el => el.textContent.trim());
246
+ return { value: getText };
247
+ }
248
+
121
249
  case 'select':
122
250
  await page.waitForSelector(selector, { timeout });
123
251
  await page.select(selector, value);
@@ -162,15 +290,142 @@ export async function executeAction(page, action, config) {
162
290
  break;
163
291
  }
164
292
 
293
+ case 'clear_cookies': {
294
+ const client = await page.createCDPSession();
295
+ await client.send('Network.clearBrowserCookies');
296
+ await client.send('Storage.clearDataForOrigin', {
297
+ origin: value || baseUrl || page.url(),
298
+ storageTypes: 'cookies,local_storage,session_storage',
299
+ });
300
+ await client.detach();
301
+ break;
302
+ }
303
+
304
+ case 'type_react': {
305
+ // Types into React controlled inputs using the native value setter.
306
+ // This bypasses React's synthetic event system which ignores programmatic .value changes.
307
+ await page.waitForSelector(selector, { timeout });
308
+ await page.evaluate((sel, val) => {
309
+ const input = document.querySelector(sel);
310
+ if (!input) throw new Error(`type_react: element "${sel}" not found`);
311
+ const proto = input instanceof HTMLTextAreaElement
312
+ ? window.HTMLTextAreaElement.prototype
313
+ : window.HTMLInputElement.prototype;
314
+ const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
315
+ if (!descriptor || !descriptor.set) {
316
+ throw new Error(`type_react: element "${sel}" has no writable value property`);
317
+ }
318
+ descriptor.set.call(input, val);
319
+ input.dispatchEvent(new Event('input', { bubbles: true }));
320
+ input.dispatchEvent(new Event('change', { bubbles: true }));
321
+ input.focus();
322
+ }, selector, value);
323
+ break;
324
+ }
325
+
326
+ case 'click_regex': {
327
+ // Click an element whose textContent matches a regex pattern.
328
+ // text = regex pattern (always case-insensitive)
329
+ // selector = optional CSS scope (defaults to common clickable elements)
330
+ // value = "last" to click the last match (default: first)
331
+ 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';
332
+ const matchLast = value === 'last';
333
+ await page.waitForFunction(
334
+ (regex, sel) => [...document.querySelectorAll(sel)].some(el => new RegExp(regex, 'i').test(el.textContent)),
335
+ { timeout },
336
+ text, matchSelector
337
+ );
338
+ const clicked = await page.$$eval(matchSelector, (els, regex, last) => {
339
+ const matches = els.filter(el => new RegExp(regex, 'i').test(el.textContent));
340
+ if (matches.length === 0) return false;
341
+ const target = last ? matches[matches.length - 1] : matches[0];
342
+ target.click();
343
+ return true;
344
+ }, text, matchLast);
345
+ if (!clicked) {
346
+ throw new Error(`click_regex failed: no element matching /${text}/i found`);
347
+ }
348
+ break;
349
+ }
350
+
351
+ case 'click_option': {
352
+ // Click a [role="option"] element by text content — common in autocomplete dropdowns.
353
+ await page.waitForFunction(
354
+ (t) => [...document.querySelectorAll('[role="option"]')].some(el => el.textContent.includes(t)),
355
+ { timeout },
356
+ text
357
+ );
358
+ const optionClicked = await page.$$eval('[role="option"]', (els, t) => {
359
+ const match = els.find(el => el.textContent.includes(t));
360
+ if (match) { match.click(); return true; }
361
+ return false;
362
+ }, text);
363
+ if (!optionClicked) {
364
+ throw new Error(`click_option failed: no [role="option"] containing "${text}" found`);
365
+ }
366
+ break;
367
+ }
368
+
369
+ case 'focus_autocomplete': {
370
+ // Focus an autocomplete/combobox input by its label text.
371
+ // Supports MUI Autocomplete (.MuiAutocomplete-root) and generic [role="combobox"].
372
+ const focused = await page.evaluate((labelText) => {
373
+ const containers = [
374
+ ...document.querySelectorAll('.MuiAutocomplete-root'),
375
+ ...document.querySelectorAll('[role="combobox"]'),
376
+ ];
377
+ const match = containers.find(c => {
378
+ const label = c.querySelector('label');
379
+ return label && label.textContent.includes(labelText);
380
+ });
381
+ if (!match) return null;
382
+ const input = match.querySelector('input');
383
+ if (!input) return null;
384
+ input.focus();
385
+ input.click();
386
+ return input.id || 'focused';
387
+ }, text);
388
+ if (!focused) {
389
+ throw new Error(`focus_autocomplete failed: no autocomplete with label "${text}" found`);
390
+ }
391
+ break;
392
+ }
393
+
394
+ case 'click_chip': {
395
+ // Click a chip/tag element by text content.
396
+ // Searches MUI Chip classes and common chip patterns.
397
+ const chipClicked = await page.evaluate((chipText) => {
398
+ const chips = Array.from(document.querySelectorAll(
399
+ '[class*="Chip"], [class*="chip"], [data-chip], [role="option"][aria-selected]'
400
+ ));
401
+ const match = chips.find(c => c.textContent.includes(chipText));
402
+ if (!match) return false;
403
+ match.click();
404
+ return true;
405
+ }, text);
406
+ if (!chipClicked) {
407
+ throw new Error(`click_chip failed: no chip containing "${text}" found`);
408
+ }
409
+ break;
410
+ }
411
+
165
412
  case 'evaluate': {
166
413
  // 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
414
+ const jsSnippet = value.length > 120 ? value.slice(0, 120) + '...' : value;
415
+ let evalResult;
416
+ try {
417
+ evalResult = await page.evaluate(value);
418
+ } catch (evalErr) {
419
+ const pageUrl = page.url();
420
+ throw new Error(`evaluate threw on ${pageUrl}: ${evalErr.message}\n JS: ${jsSnippet}`);
421
+ }
169
422
  if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
170
- throw new Error(`evaluate failed: ${evalResult}`);
423
+ const pageUrl = page.url();
424
+ throw new Error(`evaluate failed on ${pageUrl}: ${evalResult}\n JS: ${jsSnippet}`);
171
425
  }
172
426
  if (evalResult === false) {
173
- throw new Error('evaluate returned false');
427
+ const pageUrl = page.url();
428
+ throw new Error(`evaluate returned false on ${pageUrl}\n JS: ${jsSnippet}`);
174
429
  }
175
430
  return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
176
431
  }