@matware/e2e-runner 1.1.0 → 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 +505 -279
  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 +275 -7
  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 +11 -3
  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 +280 -17
  17. package/src/ai-generate.js +122 -11
  18. package/src/config.js +58 -0
  19. package/src/dashboard.js +173 -10
  20. package/src/db.js +232 -17
  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 +575 -16
  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 +47 -2
  31. package/src/runner.js +180 -40
  32. package/src/verify.js +19 -5
  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 +1091 -268
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
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
+ }
179
+ }
180
+ break;
181
+ }
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
+ }
117
198
  }
118
199
  break;
119
200
  }
120
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,10 +290,145 @@ export async function executeAction(page, action, config) {
162
290
  break;
163
291
  }
164
292
 
165
- case 'evaluate':
166
- // Intentional: runs JS in browser page context (from test JSON files)
167
- await page.evaluate(value);
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
+ }
168
409
  break;
410
+ }
411
+
412
+ case 'evaluate': {
413
+ // Intentional: runs JS in browser page context (from test JSON files)
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
+ }
422
+ if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
423
+ const pageUrl = page.url();
424
+ throw new Error(`evaluate failed on ${pageUrl}: ${evalResult}\n JS: ${jsSnippet}`);
425
+ }
426
+ if (evalResult === false) {
427
+ const pageUrl = page.url();
428
+ throw new Error(`evaluate returned false on ${pageUrl}\n JS: ${jsSnippet}`);
429
+ }
430
+ return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
431
+ }
169
432
 
170
433
  default:
171
434
  log('⚠️', `Unknown action: ${type}`);
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import fs from 'fs';
10
+ import path from 'path';
10
11
  import { listSuites } from './runner.js';
11
12
 
12
13
  const SYSTEM_PROMPT = `You are an E2E test generator for a JSON-driven browser test runner.
@@ -26,30 +27,118 @@ The test format is:
26
27
  { "type": "wait", "text": "Expected text" },
27
28
  { "type": "wait", "value": "2000" },
28
29
  { "type": "assert_text", "text": "Expected text on page" },
30
+ { "type": "assert_element_text", "selector": "#title", "text": "Dashboard" },
31
+ { "type": "assert_element_text", "selector": "#title", "text": "Dashboard", "value": "exact" },
32
+ { "type": "assert_attribute", "selector": "input#email", "value": "type=email" },
33
+ { "type": "assert_attribute", "selector": "button", "value": "disabled" },
34
+ { "type": "assert_class", "selector": ".nav-item", "value": "active" },
35
+ { "type": "assert_not_visible", "selector": ".error-banner" },
36
+ { "type": "assert_input_value", "selector": "#email", "value": "user@example.com" },
37
+ { "type": "assert_matches", "selector": ".phone", "value": "\\\\d{3}-\\\\d{3}-\\\\d{4}" },
29
38
  { "type": "assert_url", "value": "/expected-path" },
30
39
  { "type": "assert_visible", "selector": ".element" },
31
40
  { "type": "assert_count", "selector": ".items", "value": "5" },
41
+ { "type": "assert_count", "selector": ".rows", "value": ">3" },
42
+ { "type": "assert_count", "selector": ".errors", "value": "0" },
43
+ { "type": "get_text", "selector": "#patient-name" },
32
44
  { "type": "screenshot", "value": "step-name.png" },
33
45
  { "type": "select", "selector": "select#role", "value": "admin" },
34
46
  { "type": "clear", "selector": "input" },
35
47
  { "type": "press", "value": "Enter" },
36
48
  { "type": "scroll", "selector": ".target" },
37
49
  { "type": "hover", "selector": ".menu" },
38
- { "type": "evaluate", "value": "document.title" }
50
+ { "type": "evaluate", "value": "document.title" },
51
+ { "type": "type_react", "selector": "input#search", "value": "search term" },
52
+ { "type": "click_regex", "text": "submit order", "selector": "button", "value": "last" },
53
+ { "type": "click_option", "text": "Option Label" },
54
+ { "type": "focus_autocomplete", "text": "Search by label" },
55
+ { "type": "click_chip", "text": "Tag Name" }
39
56
  ]
40
57
  }
41
58
  ]
42
59
 
60
+ Framework-aware action reference (prefer these over evaluate for React/MUI apps):
61
+ - type_react: types into React controlled inputs using native value setter + input/change events (works with both input and textarea)
62
+ - click_regex: click element by regex text match (case-insensitive). Use "value": "last" for last match. Optional "selector" scopes the search
63
+ - click_option: click a [role="option"] element by text — for autocomplete/select dropdowns
64
+ - focus_autocomplete: focus an autocomplete input by its label text (supports MUI .MuiAutocomplete-root and [role="combobox"])
65
+ - click_chip: click a chip/tag element by text (searches [class*="Chip"], [data-chip])
66
+
67
+ Assertion action reference:
68
+ - assert_text: checks if text appears anywhere in the page body
69
+ - assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
70
+ - assert_attribute: checks HTML attributes — "attr=value" for value check, "attr" alone for existence
71
+ - assert_class: checks if element has a CSS class via classList.contains
72
+ - assert_visible / assert_not_visible: checks element visibility (display, visibility, opacity)
73
+ - assert_input_value: checks the .value of input/select/textarea elements
74
+ - assert_matches: checks element textContent against a regex pattern
75
+ - assert_count: counts matching elements — exact number or operators (">3", ">=1", "<10", "<=5")
76
+ - assert_url: checks if current URL contains the value
77
+ - get_text: extracts element text (non-assertion, returns { value })
78
+
79
+ Reusable modules:
80
+ - Tests can reference shared action sequences: { "$use": "module-name", "params": { "key": "value" } }
81
+ - Use modules for repeated flows like login, navigation, or setup
82
+
43
83
  Rules:
44
84
  - Output a JSON array of test objects
45
- - Use only the action types listed above
85
+ - NEVER use evaluate with inline JS for assertions that can be done with native action types:
86
+ * Use assert_element_text instead of evaluate to check element textContent
87
+ * Use assert_attribute instead of evaluate to check HTML attributes
88
+ * Use assert_class instead of evaluate to check CSS classes
89
+ * Use assert_input_value instead of evaluate to check input/select/textarea values
90
+ * Use assert_matches instead of evaluate for regex text matching
91
+ * Use assert_not_visible instead of evaluate to verify elements are hidden
92
+ * Use type_react instead of evaluate with native value setter for React controlled inputs
93
+ * Use click_regex instead of evaluate with Array.from(querySelectorAll).filter(regex) patterns
94
+ * Use click_option instead of evaluate with querySelectorAll('[role="option"]') patterns
95
+ * Use focus_autocomplete instead of evaluate with MuiAutocomplete-root label search patterns
96
+ * Use click_chip instead of evaluate with querySelectorAll('[class*="Chip"]') patterns
97
+ * Reserve evaluate ONLY for complex logic that cannot be expressed with existing action types
46
98
  - "click" with "text" (no selector) finds buttons/links by visible text
47
99
  - "goto" values starting with "/" are relative to the app's base URL
48
100
  - Include a screenshot action before key assertions for debugging
49
101
  - For bug reports: write tests that assert the CORRECT behavior. If the test fails, the bug is confirmed
50
102
  - Keep test names descriptive and kebab-case
51
103
  - Prefer CSS selectors that are stable (data-testid, name, role) over fragile ones (nth-child, classes)
52
- - If the issue description is vague, create a reasonable test that covers the described scenario`;
104
+ - If the issue description is vague, create a reasonable test that covers the described scenario
105
+ - If project context is provided (from CLAUDE.md), use the REAL routes, selectors, and UI patterns described there — never invent routes or selectors`;
106
+
107
+ const E2E_RULES = `
108
+ CRITICAL — UI-first testing rules:
109
+ - Every test MUST start with a "goto" action to navigate to a real page
110
+ - Tests MUST interact with UI elements: click, type, select, hover, scroll
111
+ - Verify results through visible page state: assert_text, assert_visible, assert_element_text, assert_url
112
+ - NEVER use evaluate to call APIs directly (no fetch, no GraphQL, no XHR, no window.__e2e.gql)
113
+ - NEVER use evaluate to set up or verify data — use the UI workflow instead
114
+ - Test the user journey as a real person would use the application
115
+ - Include screenshot actions before key assertions for debugging
116
+ `;
117
+
118
+ const API_RULES = `
119
+ API testing rules:
120
+ - Tests verify backend API behavior directly via evaluate actions
121
+ - Each test should: set up context → call API → assert response shape and values
122
+ - Use evaluate for GraphQL mutations, queries, and REST calls
123
+ - Name tests clearly describing the API operation (e.g. "createUser-returns-new-user")
124
+ - Include error case tests (invalid input, missing fields, auth failures)
125
+ - No need for goto/click/type — this is not UI testing
126
+ `;
127
+
128
+ /**
129
+ * Reads the project's CLAUDE.md for app context (routes, selectors, UI structure).
130
+ * Returns the content or empty string if not found.
131
+ */
132
+ function loadProjectContext(cwd) {
133
+ if (!cwd) return '';
134
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
135
+ if (!fs.existsSync(claudeMdPath)) return '';
136
+ try {
137
+ return fs.readFileSync(claudeMdPath, 'utf-8');
138
+ } catch {
139
+ return '';
140
+ }
141
+ }
53
142
 
54
143
  /**
55
144
  * Returns a structured prompt + issue data for Claude Code to consume.
@@ -60,13 +149,20 @@ Rules:
60
149
  * @param {object} config - Loaded config
61
150
  * @returns {object}
62
151
  */
63
- export function buildPrompt(issue, config) {
152
+ export function buildPrompt(issue, config, testType = 'e2e') {
64
153
  let existingSuites = [];
65
154
  try {
66
155
  existingSuites = listSuites(config.testsDir).map(s => s.name);
67
156
  } catch { /* no suites yet */ }
68
157
 
69
- const prompt = `Based on the following issue, generate E2E test actions using the e2e_create_test tool.
158
+ const projectContext = loadProjectContext(config._cwd);
159
+ const contextBlock = projectContext
160
+ ? `\n## Project Context (from CLAUDE.md)\nUse these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
161
+ : '';
162
+
163
+ const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
164
+
165
+ const prompt = `Based on the following issue, generate ${testType === 'api' ? 'API' : 'E2E'} test actions using the e2e_create_test tool.
70
166
 
71
167
  ## Issue: ${issue.title}
72
168
  **Repo:** ${issue.repo}
@@ -76,9 +172,11 @@ export function buildPrompt(issue, config) {
76
172
 
77
173
  ### Description
78
174
  ${issue.body || 'No description provided.'}
79
-
175
+ ${contextBlock}
176
+ ## Test Category: ${testType}
177
+ ${categoryRules}
80
178
  ## Instructions
81
- 1. Analyze the issue and determine what user flows to test
179
+ 1. Analyze the issue and determine what ${testType === 'api' ? 'API operations' : 'user flows'} to test
82
180
  2. Create one or more tests that verify the expected behavior
83
181
  3. For bug reports: assert the CORRECT behavior (test failure = bug confirmed)
84
182
  4. Use the \`e2e_create_test\` tool with suite name \`issue-${issue.number}\`
@@ -119,7 +217,7 @@ export function hasApiKey(config = {}) {
119
217
  * @param {object} config - Loaded config
120
218
  * @returns {Promise<{ tests: object[], suiteName: string }>}
121
219
  */
122
- export async function generateTests(issue, config) {
220
+ export async function generateTests(issue, config, testType = 'e2e') {
123
221
  const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
124
222
  if (!apiKey) {
125
223
  throw new Error('ANTHROPIC_API_KEY is required for test generation. Set it as an environment variable or in config.');
@@ -128,7 +226,14 @@ export async function generateTests(issue, config) {
128
226
  const model = config.anthropicModel || 'claude-sonnet-4-5-20250929';
129
227
  const suiteName = `issue-${issue.number}`;
130
228
 
131
- const userMessage = `Generate E2E tests for this issue:
229
+ const projectContext = loadProjectContext(config._cwd);
230
+ const contextBlock = projectContext
231
+ ? `\n## Project Context (from CLAUDE.md)\nIMPORTANT: Use these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
232
+ : '';
233
+
234
+ const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
235
+
236
+ const userMessage = `Generate ${testType === 'api' ? 'API' : 'E2E'} tests for this issue:
132
237
 
133
238
  Title: ${issue.title}
134
239
  Repo: ${issue.repo}
@@ -137,7 +242,9 @@ State: ${issue.state}
137
242
 
138
243
  Description:
139
244
  ${issue.body || 'No description provided.'}
140
-
245
+ ${contextBlock}
246
+ Test Category: ${testType}
247
+ ${categoryRules}
141
248
  Base URL: ${config.baseUrl}
142
249
 
143
250
  Output a JSON array of test objects. Nothing else.`;
@@ -151,7 +258,7 @@ Output a JSON array of test objects. Nothing else.`;
151
258
  },
152
259
  body: JSON.stringify({
153
260
  model,
154
- max_tokens: 4096,
261
+ max_tokens: 16384,
155
262
  system: SYSTEM_PROMPT,
156
263
  messages: [{ role: 'user', content: userMessage }],
157
264
  }),
@@ -168,6 +275,10 @@ Output a JSON array of test objects. Nothing else.`;
168
275
  throw new Error('Claude API returned empty response');
169
276
  }
170
277
 
278
+ if (result.stop_reason === 'max_tokens') {
279
+ throw new Error(`Claude API response was truncated (hit max_tokens). The issue may be too complex. Try simplifying the issue description or increasing anthropicMaxTokens.`);
280
+ }
281
+
171
282
  // Parse JSON — strip markdown fences if present
172
283
  const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
173
284
  let tests;
package/src/config.js CHANGED
@@ -16,6 +16,7 @@ const DEFAULTS = {
16
16
  baseUrl: 'http://host.docker.internal:3000',
17
17
  poolUrl: 'ws://localhost:3333',
18
18
  testsDir: 'e2e/tests',
19
+ modulesDir: 'e2e/modules',
19
20
  screenshotsDir: 'e2e/screenshots',
20
21
  concurrency: 3,
21
22
  viewport: { width: 1280, height: 720 },
@@ -33,8 +34,23 @@ const DEFAULTS = {
33
34
  dashboardPort: 8484,
34
35
  maxHistoryRuns: 100,
35
36
  projectName: null,
37
+ exclude: [],
38
+ failOnNetworkError: false,
39
+ actionRetries: 0,
40
+ actionRetryDelay: 500,
36
41
  anthropicApiKey: null,
37
42
  anthropicModel: 'claude-sonnet-4-5-20250929',
43
+ authToken: null,
44
+ authStorageKey: 'accessToken',
45
+ learningsEnabled: true,
46
+ learningsMarkdown: true,
47
+ learningsNeo4j: false,
48
+ learningsDays: 30,
49
+ neo4jBoltUrl: 'bolt://localhost:7687',
50
+ neo4jUser: 'neo4j',
51
+ neo4jPassword: 'e2erunner',
52
+ neo4jBoltPort: 7687,
53
+ neo4jHttpPort: 7474,
38
54
  };
39
55
 
40
56
  function loadEnvVars() {
@@ -42,6 +58,7 @@ function loadEnvVars() {
42
58
  if (process.env.BASE_URL) env.baseUrl = process.env.BASE_URL;
43
59
  if (process.env.CHROME_POOL_URL) env.poolUrl = process.env.CHROME_POOL_URL;
44
60
  if (process.env.TESTS_DIR) env.testsDir = process.env.TESTS_DIR;
61
+ if (process.env.MODULES_DIR) env.modulesDir = process.env.MODULES_DIR;
45
62
  if (process.env.SCREENSHOTS_DIR) env.screenshotsDir = process.env.SCREENSHOTS_DIR;
46
63
  if (process.env.CONCURRENCY) env.concurrency = parseInt(process.env.CONCURRENCY);
47
64
  if (process.env.DEFAULT_TIMEOUT) env.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT);
@@ -53,8 +70,22 @@ function loadEnvVars() {
53
70
  if (process.env.OUTPUT_FORMAT) env.outputFormat = process.env.OUTPUT_FORMAT;
54
71
  if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
55
72
  if (process.env.PROJECT_NAME) env.projectName = process.env.PROJECT_NAME;
73
+ if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
74
+ if (process.env.ACTION_RETRIES) env.actionRetries = parseInt(process.env.ACTION_RETRIES);
75
+ if (process.env.ACTION_RETRY_DELAY) env.actionRetryDelay = parseInt(process.env.ACTION_RETRY_DELAY);
56
76
  if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
57
77
  if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
78
+ if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
79
+ if (process.env.AUTH_STORAGE_KEY) env.authStorageKey = process.env.AUTH_STORAGE_KEY;
80
+ if (process.env.LEARNINGS_ENABLED) env.learningsEnabled = process.env.LEARNINGS_ENABLED !== 'false' && process.env.LEARNINGS_ENABLED !== '0';
81
+ if (process.env.LEARNINGS_MARKDOWN) env.learningsMarkdown = process.env.LEARNINGS_MARKDOWN !== 'false' && process.env.LEARNINGS_MARKDOWN !== '0';
82
+ if (process.env.LEARNINGS_NEO4J) env.learningsNeo4j = process.env.LEARNINGS_NEO4J === 'true' || process.env.LEARNINGS_NEO4J === '1';
83
+ if (process.env.LEARNINGS_DAYS) env.learningsDays = parseInt(process.env.LEARNINGS_DAYS);
84
+ if (process.env.NEO4J_BOLT_URL) env.neo4jBoltUrl = process.env.NEO4J_BOLT_URL;
85
+ if (process.env.NEO4J_USER) env.neo4jUser = process.env.NEO4J_USER;
86
+ if (process.env.NEO4J_PASSWORD) env.neo4jPassword = process.env.NEO4J_PASSWORD;
87
+ if (process.env.NEO4J_BOLT_PORT) env.neo4jBoltPort = parseInt(process.env.NEO4J_BOLT_PORT);
88
+ if (process.env.NEO4J_HTTP_PORT) env.neo4jHttpPort = parseInt(process.env.NEO4J_HTTP_PORT);
58
89
  return env;
59
90
  }
60
91
 
@@ -76,8 +107,32 @@ async function loadConfigFile(cwd) {
76
107
  return {};
77
108
  }
78
109
 
110
+ /** Load .env file from cwd into process.env (no deps, KEY=VALUE format). */
111
+ function loadDotEnv(cwd) {
112
+ const envPath = path.join(cwd, '.env');
113
+ if (!fs.existsSync(envPath)) return;
114
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
115
+ for (const line of lines) {
116
+ const trimmed = line.trim();
117
+ if (!trimmed || trimmed.startsWith('#')) continue;
118
+ const eqIdx = trimmed.indexOf('=');
119
+ if (eqIdx === -1) continue;
120
+ const key = trimmed.slice(0, eqIdx).trim();
121
+ let val = trimmed.slice(eqIdx + 1).trim();
122
+ // Strip surrounding quotes
123
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
124
+ val = val.slice(1, -1);
125
+ }
126
+ // Don't override existing env vars
127
+ if (!(key in process.env)) {
128
+ process.env[key] = val;
129
+ }
130
+ }
131
+ }
132
+
79
133
  export async function loadConfig(cliArgs = {}, cwd = null) {
80
134
  cwd = cwd || process.cwd();
135
+ loadDotEnv(cwd);
81
136
  const fileConfig = await loadConfigFile(cwd);
82
137
  const envConfig = loadEnvVars();
83
138
 
@@ -99,6 +154,9 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
99
154
  if (!path.isAbsolute(config.testsDir)) {
100
155
  config.testsDir = path.join(cwd, config.testsDir);
101
156
  }
157
+ if (config.modulesDir && !path.isAbsolute(config.modulesDir)) {
158
+ config.modulesDir = path.join(cwd, config.modulesDir);
159
+ }
102
160
  if (!path.isAbsolute(config.screenshotsDir)) {
103
161
  config.screenshotsDir = path.join(cwd, config.screenshotsDir);
104
162
  }