@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.
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +475 -307
- 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 +194 -6
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +10 -2
- 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 +273 -18
- package/src/ai-generate.js +87 -7
- package/src/config.js +28 -0
- package/src/dashboard.js +156 -6
- package/src/db.js +207 -13
- 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 +448 -18
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +35 -2
- package/src/runner.js +120 -46
- package/src/verify.js +5 -3
- 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 +964 -378
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
package/src/runner.js
CHANGED
|
@@ -9,12 +9,24 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
|
|
11
11
|
import { executeAction } from './actions.js';
|
|
12
|
+
import { narrateAction } from './narrate.js';
|
|
12
13
|
import { log, colors as C } from './logger.js';
|
|
14
|
+
import { resolveTestData } from './module-resolver.js';
|
|
13
15
|
|
|
14
16
|
function sleep(ms) {
|
|
15
17
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** Simple glob matching with * wildcards for exclude patterns. */
|
|
21
|
+
function matchesExclude(filename, excludePatterns) {
|
|
22
|
+
if (!excludePatterns?.length) return false;
|
|
23
|
+
const name = filename.replace('.json', '');
|
|
24
|
+
return excludePatterns.some(pattern => {
|
|
25
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
26
|
+
return regex.test(name) || regex.test(filename);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
function timeDiff(start, end) {
|
|
19
31
|
const ms = new Date(end) - new Date(start);
|
|
20
32
|
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -44,7 +56,13 @@ function normalizeTestData(data) {
|
|
|
44
56
|
if (Array.isArray(data)) {
|
|
45
57
|
return { tests: data, hooks: {} };
|
|
46
58
|
}
|
|
47
|
-
|
|
59
|
+
// Support hooks nested under "hooks" key or directly at root level
|
|
60
|
+
const hooks = data.hooks || {};
|
|
61
|
+
if (!hooks.beforeAll && data.beforeAll) hooks.beforeAll = data.beforeAll;
|
|
62
|
+
if (!hooks.afterAll && data.afterAll) hooks.afterAll = data.afterAll;
|
|
63
|
+
if (!hooks.beforeEach && data.beforeEach) hooks.beforeEach = data.beforeEach;
|
|
64
|
+
if (!hooks.afterEach && data.afterEach) hooks.afterEach = data.afterEach;
|
|
65
|
+
return { tests: data.tests || [], hooks };
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
/** Waits until the pool has capacity before connecting */
|
|
@@ -70,6 +88,7 @@ async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
|
|
|
70
88
|
/** Runs a single test end-to-end */
|
|
71
89
|
export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
72
90
|
let browser = null;
|
|
91
|
+
let context = null;
|
|
73
92
|
let page = null;
|
|
74
93
|
|
|
75
94
|
const result = {
|
|
@@ -87,7 +106,9 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
87
106
|
try {
|
|
88
107
|
await waitForSlot(config.poolUrl);
|
|
89
108
|
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
90
|
-
|
|
109
|
+
// Use incognito context for cookie isolation between concurrent tests
|
|
110
|
+
context = await browser.createBrowserContext();
|
|
111
|
+
page = await context.newPage();
|
|
91
112
|
await page.setViewport(config.viewport);
|
|
92
113
|
|
|
93
114
|
page.on('console', (msg) => {
|
|
@@ -135,37 +156,57 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
135
156
|
|
|
136
157
|
for (let i = 0; i < test.actions.length; i++) {
|
|
137
158
|
const action = test.actions[i];
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
159
|
+
const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
|
|
160
|
+
const actionRetryDelay = config.actionRetryDelay ?? 500;
|
|
161
|
+
let lastError = null;
|
|
162
|
+
|
|
163
|
+
for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
|
|
164
|
+
const actionStart = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
let actionResult;
|
|
167
|
+
if (action.type === 'assert_no_network_errors') {
|
|
168
|
+
// Handled inline — needs access to result.networkErrors
|
|
169
|
+
if (result.networkErrors.length > 0) {
|
|
170
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
171
|
+
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
172
|
+
}
|
|
173
|
+
actionResult = null;
|
|
174
|
+
} else {
|
|
175
|
+
actionResult = await executeAction(page, action, config);
|
|
176
|
+
}
|
|
177
|
+
const actionDuration = Date.now() - actionStart;
|
|
178
|
+
const actionEntry = {
|
|
179
|
+
...action,
|
|
180
|
+
success: true,
|
|
181
|
+
duration: actionDuration,
|
|
182
|
+
result: actionResult,
|
|
183
|
+
};
|
|
184
|
+
if (attempt > 0) actionEntry.actionRetries = attempt;
|
|
185
|
+
actionEntry.narrative = narrateAction(action, actionEntry);
|
|
186
|
+
result.actions.push(actionEntry);
|
|
187
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null });
|
|
188
|
+
lastError = null;
|
|
189
|
+
break;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
lastError = error;
|
|
192
|
+
if (attempt < maxActionRetries) {
|
|
193
|
+
log('🔄', `${C.dim}Action ${action.type} retry ${attempt + 1}/${maxActionRetries} (${error.message})${C.reset}`);
|
|
194
|
+
await sleep(actionRetryDelay);
|
|
195
|
+
continue;
|
|
146
196
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
const actionDuration = Date.now() - actionStart;
|
|
198
|
+
const failedEntry = {
|
|
199
|
+
...action,
|
|
200
|
+
success: false,
|
|
201
|
+
duration: actionDuration,
|
|
202
|
+
error: error.message,
|
|
203
|
+
};
|
|
204
|
+
if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
|
|
205
|
+
failedEntry.narrative = narrateAction(action, failedEntry);
|
|
206
|
+
result.actions.push(failedEntry);
|
|
207
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
|
|
208
|
+
throw error;
|
|
150
209
|
}
|
|
151
|
-
const actionDuration = Date.now() - actionStart;
|
|
152
|
-
result.actions.push({
|
|
153
|
-
...action,
|
|
154
|
-
success: true,
|
|
155
|
-
duration: actionDuration,
|
|
156
|
-
result: actionResult,
|
|
157
|
-
});
|
|
158
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
|
|
159
|
-
} catch (error) {
|
|
160
|
-
const actionDuration = Date.now() - actionStart;
|
|
161
|
-
result.actions.push({
|
|
162
|
-
...action,
|
|
163
|
-
success: false,
|
|
164
|
-
duration: actionDuration,
|
|
165
|
-
error: error.message,
|
|
166
|
-
});
|
|
167
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
|
|
168
|
-
throw error;
|
|
169
210
|
}
|
|
170
211
|
}
|
|
171
212
|
|
|
@@ -216,6 +257,9 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
216
257
|
if (page) {
|
|
217
258
|
try { result.finalUrl = page.url(); } catch { /* */ }
|
|
218
259
|
}
|
|
260
|
+
if (context) {
|
|
261
|
+
try { await context.close(); } catch { /* */ }
|
|
262
|
+
}
|
|
219
263
|
if (browser) {
|
|
220
264
|
try { browser.disconnect(); } catch { /* */ }
|
|
221
265
|
}
|
|
@@ -230,6 +274,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
230
274
|
|
|
231
275
|
// Run beforeAll hook
|
|
232
276
|
if (hooks.beforeAll?.length) {
|
|
277
|
+
const stateActions = hooks.beforeAll.filter(a =>
|
|
278
|
+
['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
|
|
279
|
+
);
|
|
280
|
+
if (stateActions.length > 0) {
|
|
281
|
+
log('⚠️', `${C.yellow}beforeAll runs on a separate browser — state from ${stateActions.map(a => a.type).join(', ')} will NOT carry over to tests. Use beforeEach instead.${C.reset}`);
|
|
282
|
+
}
|
|
233
283
|
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
234
284
|
let browser = null;
|
|
235
285
|
try {
|
|
@@ -252,10 +302,18 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
252
302
|
const _cwd = config._cwd || null;
|
|
253
303
|
const _triggeredBy = config.triggeredBy || 'unknown';
|
|
254
304
|
const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
|
|
305
|
+
|
|
306
|
+
// Split serial and parallel tests
|
|
307
|
+
const parallelTests = tests.filter(t => !t.serial);
|
|
308
|
+
const serialTests = tests.filter(t => t.serial);
|
|
309
|
+
if (serialTests.length > 0) {
|
|
310
|
+
log('🔒', `${C.dim}${serialTests.length} serial test(s) will run after parallel batch${C.reset}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
255
313
|
_progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
|
|
256
314
|
|
|
257
315
|
const results = [];
|
|
258
|
-
const queue = [...
|
|
316
|
+
const queue = [...parallelTests];
|
|
259
317
|
let activeCount = 0;
|
|
260
318
|
|
|
261
319
|
const worker = async () => {
|
|
@@ -263,7 +321,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
263
321
|
const test = queue.shift();
|
|
264
322
|
activeCount++;
|
|
265
323
|
log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
266
|
-
_progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
|
|
324
|
+
_progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
|
|
267
325
|
|
|
268
326
|
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
269
327
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
@@ -276,7 +334,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
276
334
|
});
|
|
277
335
|
|
|
278
336
|
try {
|
|
279
|
-
|
|
337
|
+
const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
|
|
338
|
+
result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
|
|
280
339
|
} catch (error) {
|
|
281
340
|
result = {
|
|
282
341
|
name: test.name,
|
|
@@ -325,11 +384,18 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
325
384
|
};
|
|
326
385
|
|
|
327
386
|
const workers = [];
|
|
328
|
-
for (let i = 0; i < Math.min(concurrency,
|
|
387
|
+
for (let i = 0; i < Math.min(concurrency, parallelTests.length); i++) {
|
|
329
388
|
workers.push(worker());
|
|
330
389
|
}
|
|
331
390
|
await Promise.all(workers);
|
|
332
391
|
|
|
392
|
+
// Run serial tests one at a time
|
|
393
|
+
if (serialTests.length > 0) {
|
|
394
|
+
log('🔒', `${C.dim}Running ${serialTests.length} serial test(s)...${C.reset}`);
|
|
395
|
+
queue.push(...serialTests);
|
|
396
|
+
await worker();
|
|
397
|
+
}
|
|
398
|
+
|
|
333
399
|
// Run afterAll hook
|
|
334
400
|
if (hooks.afterAll?.length) {
|
|
335
401
|
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
@@ -357,16 +423,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
357
423
|
}
|
|
358
424
|
|
|
359
425
|
/** Loads tests from a JSON file — returns { tests, hooks } */
|
|
360
|
-
export function loadTestFile(filePath) {
|
|
426
|
+
export function loadTestFile(filePath, modulesDir) {
|
|
361
427
|
if (!fs.existsSync(filePath)) {
|
|
362
428
|
throw new Error(`File not found: ${filePath}`);
|
|
363
429
|
}
|
|
364
430
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
365
|
-
|
|
431
|
+
const normalized = normalizeTestData(data);
|
|
432
|
+
return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
366
433
|
}
|
|
367
434
|
|
|
368
435
|
/** Loads a test suite by name — returns { tests, hooks } */
|
|
369
|
-
export function loadTestSuite(suiteName, testsDir) {
|
|
436
|
+
export function loadTestSuite(suiteName, testsDir, modulesDir) {
|
|
370
437
|
// Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
|
|
371
438
|
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
|
|
372
439
|
const exact = files.find(f => f === `${suiteName}.json`);
|
|
@@ -378,29 +445,36 @@ export function loadTestSuite(suiteName, testsDir) {
|
|
|
378
445
|
}
|
|
379
446
|
|
|
380
447
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
381
|
-
|
|
448
|
+
const normalized = normalizeTestData(data);
|
|
449
|
+
return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
382
450
|
}
|
|
383
451
|
|
|
384
452
|
/** Loads all test suites from the tests directory — returns { tests, hooks } */
|
|
385
|
-
export function loadAllSuites(testsDir) {
|
|
453
|
+
export function loadAllSuites(testsDir, modulesDir, exclude = []) {
|
|
386
454
|
if (!fs.existsSync(testsDir)) {
|
|
387
455
|
throw new Error(`Tests directory not found: ${testsDir}`);
|
|
388
456
|
}
|
|
389
457
|
|
|
390
|
-
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
458
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
459
|
+
.filter(f => !matchesExclude(f, exclude));
|
|
391
460
|
let allTests = [];
|
|
392
|
-
let mergedHooks = {};
|
|
393
461
|
|
|
394
462
|
for (const file of files) {
|
|
395
463
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
|
|
396
|
-
|
|
464
|
+
let { tests, hooks } = normalizeTestData(data);
|
|
465
|
+
// Resolve modules per-suite before concatenating
|
|
466
|
+
if (modulesDir) {
|
|
467
|
+
({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
|
|
468
|
+
}
|
|
469
|
+
// Tag each test with its own suite's hooks so they're preserved
|
|
470
|
+
for (const t of tests) {
|
|
471
|
+
t._suiteHooks = hooks;
|
|
472
|
+
}
|
|
397
473
|
allTests = allTests.concat(tests);
|
|
398
|
-
// Last suite's hooks win for each non-empty key
|
|
399
|
-
mergedHooks = mergeHooks(mergedHooks, hooks);
|
|
400
474
|
log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
|
|
401
475
|
}
|
|
402
476
|
|
|
403
|
-
return { tests: allTests, hooks:
|
|
477
|
+
return { tests: allTests, hooks: {} };
|
|
404
478
|
}
|
|
405
479
|
|
|
406
480
|
/** Lists all available test suites */
|
package/src/verify.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function verifyIssue(url, config) {
|
|
|
25
25
|
const issue = fetchIssue(url);
|
|
26
26
|
|
|
27
27
|
// 2. Generate tests via Claude API
|
|
28
|
-
const { tests, suiteName } = await generateTests(issue, config);
|
|
28
|
+
const { tests, suiteName } = await generateTests(issue, config, config.testType || 'e2e');
|
|
29
29
|
|
|
30
30
|
// 3. Save tests to a temp file (underscore prefix for cleanup identification)
|
|
31
31
|
const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
|
|
@@ -38,10 +38,12 @@ export async function verifyIssue(url, config) {
|
|
|
38
38
|
// 4. Build hooks (inject auth if provided)
|
|
39
39
|
const hooks = {};
|
|
40
40
|
if (config.authToken) {
|
|
41
|
-
const
|
|
41
|
+
const esc = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
42
|
+
const storageKey = esc(config.authStorageKey || 'accessToken');
|
|
43
|
+
const token = esc(config.authToken);
|
|
42
44
|
hooks.beforeEach = [
|
|
43
45
|
{ type: 'goto', value: '/' },
|
|
44
|
-
{ type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${
|
|
46
|
+
{ type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${token}')` },
|
|
45
47
|
{ type: 'goto', value: '/' },
|
|
46
48
|
{ type: 'wait', value: '1000' },
|
|
47
49
|
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build script — concatenates dashboard/template.html + dashboard/styles.css + dashboard/app.js
|
|
4
|
+
* into a single dashboard.html file.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node templates/build-dashboard.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const dashDir = path.join(__dirname, 'dashboard');
|
|
15
|
+
|
|
16
|
+
const template = fs.readFileSync(path.join(dashDir, 'template.html'), 'utf-8');
|
|
17
|
+
const styles = fs.readFileSync(path.join(dashDir, 'styles.css'), 'utf-8');
|
|
18
|
+
const script = fs.readFileSync(path.join(dashDir, 'app.js'), 'utf-8');
|
|
19
|
+
|
|
20
|
+
const output = template
|
|
21
|
+
.replace('/* __STYLES__ */', () => styles)
|
|
22
|
+
.replace('/* __SCRIPT__ */', () => script);
|
|
23
|
+
|
|
24
|
+
const outPath = path.join(__dirname, 'dashboard.html');
|
|
25
|
+
fs.writeFileSync(outPath, output);
|
|
26
|
+
|
|
27
|
+
const lines = output.split('\n').length;
|
|
28
|
+
console.log(`Built ${outPath} (${lines} lines)`);
|