@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.
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +505 -279
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +275 -7
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +11 -3
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +280 -17
- package/src/ai-generate.js +122 -11
- package/src/config.js +58 -0
- package/src/dashboard.js +173 -10
- package/src/db.js +232 -17
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +575 -16
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +47 -2
- package/src/runner.js +180 -40
- package/src/verify.js +19 -5
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +1091 -268
- package/templates/docker-compose-neo4j.yml +19 -0
- 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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
|
|
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 '
|
|
166
|
-
|
|
167
|
-
await
|
|
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}`);
|
package/src/ai-generate.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|