@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
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
- return { tests: data.tests || [], hooks: data.hooks || {} };
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
- page = await browser.newPage();
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 actionStart = Date.now();
139
- try {
140
- let actionResult;
141
- if (action.type === 'assert_no_network_errors') {
142
- // Handled inline needs access to result.networkErrors
143
- if (result.networkErrors.length > 0) {
144
- const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
145
- throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
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
- actionResult = null;
148
- } else {
149
- actionResult = await executeAction(page, action, config);
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 = [...tests];
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
- result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
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, tests.length); i++) {
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
- return normalizeTestData(data);
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
- return normalizeTestData(data);
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
- const { tests, hooks } = normalizeTestData(data);
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: mergedHooks };
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 storageKey = config.authStorageKey || 'accessToken';
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}', '${config.authToken}')` },
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)`);