@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/dashboard.js CHANGED
@@ -17,9 +17,10 @@ import { createWebSocketServer } from './websocket.js';
17
17
  import { getPoolStatus, waitForPool } from './pool.js';
18
18
  import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
19
19
  import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.js';
20
- import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, closeDb } from './db.js';
20
+ import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, closeDb } from './db.js';
21
21
  import { loadConfig } from './config.js';
22
22
  import { log, colors as C } from './logger.js';
23
+ import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from './learner-sqlite.js';
23
24
 
24
25
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
25
26
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -204,6 +205,27 @@ export async function startDashboard(config) {
204
205
  return;
205
206
  }
206
207
 
208
+ // API: DB — network logs for a run (filterable)
209
+ const networkLogsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/network-logs$/);
210
+ if (networkLogsMatch) {
211
+ try {
212
+ const runDbId = parseInt(networkLogsMatch[1], 10);
213
+ const filters = {};
214
+ if (url.searchParams.has('testName')) filters.testName = url.searchParams.get('testName');
215
+ if (url.searchParams.has('method')) filters.method = url.searchParams.get('method');
216
+ if (url.searchParams.has('statusMin')) filters.statusMin = parseInt(url.searchParams.get('statusMin'), 10);
217
+ if (url.searchParams.has('statusMax')) filters.statusMax = parseInt(url.searchParams.get('statusMax'), 10);
218
+ if (url.searchParams.has('urlPattern')) filters.urlPattern = url.searchParams.get('urlPattern');
219
+ if (url.searchParams.get('errorsOnly') === 'true') filters.errorsOnly = true;
220
+ if (url.searchParams.get('includeHeaders') === 'true') filters.includeHeaders = true;
221
+ if (url.searchParams.get('includeBodies') === 'true') filters.includeBodies = true;
222
+ jsonResponse(res, dbGetNetworkLogs(runDbId, filters));
223
+ } catch (error) {
224
+ jsonResponse(res, { error: error.message }, 500);
225
+ }
226
+ return;
227
+ }
228
+
207
229
  // API: DB — project screenshots list
208
230
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
209
231
  if (projectScreenshotsMatch) {
@@ -239,6 +261,124 @@ export async function startDashboard(config) {
239
261
  return;
240
262
  }
241
263
 
264
+ // API: DB — suite detail (tests + actions)
265
+ const suiteDetailMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites\/(.+)$/);
266
+ if (suiteDetailMatch) {
267
+ try {
268
+ const projectId = parseInt(suiteDetailMatch[1], 10);
269
+ const suiteName = decodeURIComponent(suiteDetailMatch[2]);
270
+ const dir = dbGetProjectTestsDir(projectId);
271
+ if (!dir || !fs.existsSync(dir)) {
272
+ jsonResponse(res, { error: 'Tests directory not found' }, 404);
273
+ return;
274
+ }
275
+ const { tests, hooks } = loadTestSuite(suiteName, dir);
276
+ jsonResponse(res, { name: suiteName, tests, hooks });
277
+ } catch (error) {
278
+ jsonResponse(res, { error: error.message }, 500);
279
+ }
280
+ return;
281
+ }
282
+
283
+ // API: DB — cross-project learnings (when no project selected)
284
+ const crossLearningsMatch = pathname.match(/^\/api\/db\/learnings(?:\/(\w+))?$/);
285
+ if (crossLearningsMatch) {
286
+ try {
287
+ const category = crossLearningsMatch[1] || 'summary';
288
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
289
+ let data;
290
+ switch (category) {
291
+ case 'summary': {
292
+ const summary = getLearningsSummary(null);
293
+ const trends = getTestTrends(null, 7);
294
+ data = { ...summary, recentTrend: trends };
295
+ break;
296
+ }
297
+ default:
298
+ jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
299
+ return;
300
+ }
301
+ jsonResponse(res, data);
302
+ } catch (error) {
303
+ jsonResponse(res, { error: error.message }, 500);
304
+ }
305
+ return;
306
+ }
307
+
308
+ // API: DB — project modules list
309
+ const projectModulesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/modules$/);
310
+ if (projectModulesMatch) {
311
+ try {
312
+ const projectId = parseInt(projectModulesMatch[1], 10);
313
+ const projectCwd = dbGetProjectCwd(projectId);
314
+ if (!projectCwd) { jsonResponse(res, []); return; }
315
+ const modulesDir = path.join(projectCwd, 'e2e', 'modules');
316
+ if (!fs.existsSync(modulesDir)) { jsonResponse(res, []); return; }
317
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
318
+ const modules = files.map(f => {
319
+ try {
320
+ const data = JSON.parse(fs.readFileSync(path.join(modulesDir, f), 'utf-8'));
321
+ return {
322
+ name: f.replace('.json', ''),
323
+ file: f,
324
+ description: data.description || null,
325
+ params: data.params || [],
326
+ actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
327
+ };
328
+ } catch { return { name: f.replace('.json', ''), file: f, description: null, params: [], actionCount: 0 }; }
329
+ });
330
+ jsonResponse(res, modules);
331
+ } catch (error) {
332
+ jsonResponse(res, { error: error.message }, 500);
333
+ }
334
+ return;
335
+ }
336
+
337
+ // API: DB — project learnings (summary or specific category)
338
+ const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
339
+ if (learningsMatch) {
340
+ try {
341
+ const projectId = parseInt(learningsMatch[1], 10);
342
+ const category = learningsMatch[2] || 'summary';
343
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
344
+
345
+ let data;
346
+ switch (category) {
347
+ case 'summary': {
348
+ const summary = getLearningsSummary(projectId);
349
+ const trends = getTestTrends(projectId, 7);
350
+ data = { ...summary, recentTrend: trends };
351
+ break;
352
+ }
353
+ case 'flaky':
354
+ data = getFlakySummary(projectId, days);
355
+ break;
356
+ case 'selectors':
357
+ data = getSelectorStability(projectId, days);
358
+ break;
359
+ case 'pages':
360
+ data = getPageHealth(projectId, days);
361
+ break;
362
+ case 'apis':
363
+ data = getApiHealth(projectId, days);
364
+ break;
365
+ case 'errors':
366
+ data = getErrorPatterns(projectId);
367
+ break;
368
+ case 'trends':
369
+ data = getTestTrends(projectId, days);
370
+ break;
371
+ default:
372
+ jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
373
+ return;
374
+ }
375
+ jsonResponse(res, data);
376
+ } catch (error) {
377
+ jsonResponse(res, { error: error.message }, 500);
378
+ }
379
+ return;
380
+ }
381
+
242
382
  // API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
243
383
  const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
244
384
  if (ssHashMatch) {
@@ -262,7 +402,7 @@ export async function startDashboard(config) {
262
402
  const ext = path.extname(realPath).toLowerCase();
263
403
  const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
264
404
  if (!mimeTypes[ext]) { jsonResponse(res, { error: 'Not an image' }, 400); return; }
265
- res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
405
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
266
406
  fs.createReadStream(realPath).pipe(res);
267
407
  } catch (error) {
268
408
  jsonResponse(res, { error: error.message }, 500);
@@ -302,7 +442,7 @@ export async function startDashboard(config) {
302
442
  jsonResponse(res, { error: 'Not an image' }, 400);
303
443
  return;
304
444
  }
305
- res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
445
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
306
446
  fs.createReadStream(realPath).pipe(res);
307
447
  return;
308
448
  }
@@ -347,7 +487,7 @@ export async function startDashboard(config) {
347
487
  return;
348
488
  }
349
489
  if (fs.existsSync(resolvedPath)) {
350
- res.writeHead(200, { 'Content-Type': imageMimeTypes[ext] });
490
+ res.writeHead(200, { 'Content-Type': imageMimeTypes[ext], 'Cache-Control': 'no-store' });
351
491
  fs.createReadStream(resolvedPath).pipe(res);
352
492
  } else {
353
493
  jsonResponse(res, { error: 'Not found' }, 404);
@@ -422,20 +562,31 @@ export async function startDashboard(config) {
422
562
  function bufferLiveEvent(data) {
423
563
  const rid = data.runId;
424
564
  if (!rid) return;
425
- if (data.event === 'run:start') liveEventBuffers[rid] = [];
426
- if (!liveEventBuffers[rid]) liveEventBuffers[rid] = [];
427
- liveEventBuffers[rid].push(data);
565
+ if (data.event === 'run:start') liveEventBuffers[rid] = { events: [], ts: Date.now() };
566
+ if (!liveEventBuffers[rid]) liveEventBuffers[rid] = { events: [], ts: Date.now() };
567
+ liveEventBuffers[rid].events.push(data);
568
+ liveEventBuffers[rid].ts = Date.now();
428
569
  if (data.event === 'run:complete' || data.event === 'run:error') {
429
570
  setTimeout(() => { delete liveEventBuffers[rid]; }, 30000);
430
571
  }
431
572
  }
432
573
 
574
+ // Purge stale live event buffers (runs that never completed, max 5 min)
575
+ const bufferPurgeInterval = setInterval(() => {
576
+ const maxAge = 5 * 60 * 1000;
577
+ for (const rid of Object.keys(liveEventBuffers)) {
578
+ if (Date.now() - liveEventBuffers[rid].ts > maxAge) {
579
+ delete liveEventBuffers[rid];
580
+ }
581
+ }
582
+ }, 30000);
583
+
433
584
  const wss = createWebSocketServer(server, {
434
585
  allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
435
586
  onConnect(socket) {
436
587
  // Replay live state for new/reconnected clients
437
588
  for (const rid of Object.keys(liveEventBuffers)) {
438
- for (const evt of liveEventBuffers[rid]) {
589
+ for (const evt of liveEventBuffers[rid].events) {
439
590
  wss.sendTo(socket, JSON.stringify(evt));
440
591
  }
441
592
  }
@@ -479,6 +630,7 @@ export async function startDashboard(config) {
479
630
  runConfig = { ...config };
480
631
  }
481
632
 
633
+ runConfig.triggeredBy = 'dashboard';
482
634
  if (params.concurrency) runConfig.concurrency = params.concurrency;
483
635
  if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
484
636
 
@@ -492,7 +644,7 @@ export async function startDashboard(config) {
492
644
  if (params.suite) {
493
645
  ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
494
646
  } else {
495
- ({ tests, hooks } = loadAllSuites(runConfig.testsDir));
647
+ ({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
496
648
  }
497
649
 
498
650
  await waitForPool(runConfig.poolUrl);
@@ -509,8 +661,18 @@ export async function startDashboard(config) {
509
661
  }
510
662
  }
511
663
 
512
- return new Promise((resolve) => {
664
+ return new Promise((resolve, reject) => {
513
665
  const host = config.dashboardHost || '127.0.0.1';
666
+
667
+ server.on('error', (err) => {
668
+ if (err.code === 'EADDRINUSE') {
669
+ log('❌', `${C.red}Port ${port} is already in use. Try a different port with --port <number>.${C.reset}`);
670
+ reject(new Error(`Port ${port} is already in use`));
671
+ } else {
672
+ reject(err);
673
+ }
674
+ });
675
+
514
676
  server.listen(port, host, () => {
515
677
  log('🖥️', `${C.bold}Dashboard${C.reset} running at ${C.cyan}http://${host}:${port}${C.reset}`);
516
678
 
@@ -521,6 +683,7 @@ export async function startDashboard(config) {
521
683
  close() {
522
684
  clearInterval(pollInterval);
523
685
  clearInterval(dbPollInterval);
686
+ clearInterval(bufferPurgeInterval);
524
687
  wss.close();
525
688
  server.close();
526
689
  closeDb();
package/src/db.js CHANGED
@@ -100,6 +100,27 @@ function migrate(db) {
100
100
  db.exec('ALTER TABLE test_results ADD COLUMN screenshots TEXT');
101
101
  }
102
102
 
103
+ // Add network_logs column if upgrading from older schema
104
+ try {
105
+ db.prepare('SELECT network_logs FROM test_results LIMIT 0').run();
106
+ } catch {
107
+ db.exec('ALTER TABLE test_results ADD COLUMN network_logs TEXT');
108
+ }
109
+
110
+ // Add actions_json column if upgrading from older schema
111
+ try {
112
+ db.prepare('SELECT actions_json FROM test_results LIMIT 0').run();
113
+ } catch {
114
+ db.exec('ALTER TABLE test_results ADD COLUMN actions_json TEXT');
115
+ }
116
+
117
+ // Add triggered_by column if upgrading from older schema
118
+ try {
119
+ db.prepare('SELECT triggered_by FROM runs LIMIT 0').run();
120
+ } catch {
121
+ db.exec('ALTER TABLE runs ADD COLUMN triggered_by TEXT');
122
+ }
123
+
103
124
  // Screenshot hashes table
104
125
  db.exec(`
105
126
  CREATE TABLE IF NOT EXISTS screenshot_hashes (
@@ -111,6 +132,113 @@ function migrate(db) {
111
132
  );
112
133
  CREATE INDEX IF NOT EXISTS idx_ss_path ON screenshot_hashes(file_path);
113
134
  `);
135
+
136
+ // ── Learning system tables ──────────────────────────────────────────────────
137
+
138
+ db.exec(`
139
+ CREATE TABLE IF NOT EXISTS test_learnings (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ project_id INTEGER NOT NULL REFERENCES projects(id),
142
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
143
+ test_name TEXT NOT NULL,
144
+ success INTEGER NOT NULL,
145
+ duration_ms INTEGER,
146
+ flaky INTEGER DEFAULT 0,
147
+ attempt INTEGER DEFAULT 1,
148
+ max_attempts INTEGER DEFAULT 1,
149
+ error_pattern TEXT,
150
+ created_at TEXT DEFAULT (datetime('now'))
151
+ );
152
+ CREATE INDEX IF NOT EXISTS idx_tl_project ON test_learnings(project_id);
153
+ CREATE INDEX IF NOT EXISTS idx_tl_test ON test_learnings(test_name);
154
+ CREATE INDEX IF NOT EXISTS idx_tl_created ON test_learnings(created_at);
155
+
156
+ CREATE TABLE IF NOT EXISTS selector_learnings (
157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ project_id INTEGER NOT NULL REFERENCES projects(id),
159
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
160
+ selector TEXT NOT NULL,
161
+ action_type TEXT NOT NULL,
162
+ success INTEGER NOT NULL,
163
+ page_url TEXT,
164
+ test_name TEXT,
165
+ error TEXT,
166
+ created_at TEXT DEFAULT (datetime('now'))
167
+ );
168
+ CREATE INDEX IF NOT EXISTS idx_sl_project ON selector_learnings(project_id);
169
+ CREATE INDEX IF NOT EXISTS idx_sl_selector ON selector_learnings(selector);
170
+
171
+ CREATE TABLE IF NOT EXISTS page_learnings (
172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ project_id INTEGER NOT NULL REFERENCES projects(id),
174
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
175
+ url_path TEXT NOT NULL,
176
+ load_time_ms INTEGER,
177
+ console_errors INTEGER DEFAULT 0,
178
+ console_warns INTEGER DEFAULT 0,
179
+ network_errors INTEGER DEFAULT 0,
180
+ test_name TEXT,
181
+ success INTEGER NOT NULL,
182
+ created_at TEXT DEFAULT (datetime('now'))
183
+ );
184
+ CREATE INDEX IF NOT EXISTS idx_pl_project ON page_learnings(project_id);
185
+ CREATE INDEX IF NOT EXISTS idx_pl_url ON page_learnings(url_path);
186
+
187
+ CREATE TABLE IF NOT EXISTS api_learnings (
188
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
189
+ project_id INTEGER NOT NULL REFERENCES projects(id),
190
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
191
+ endpoint TEXT NOT NULL,
192
+ method TEXT NOT NULL,
193
+ status INTEGER,
194
+ duration_ms INTEGER,
195
+ is_error INTEGER DEFAULT 0,
196
+ test_name TEXT,
197
+ created_at TEXT DEFAULT (datetime('now'))
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
200
+ CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
201
+
202
+ CREATE TABLE IF NOT EXISTS error_patterns (
203
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
204
+ project_id INTEGER NOT NULL REFERENCES projects(id),
205
+ pattern TEXT NOT NULL,
206
+ category TEXT NOT NULL,
207
+ occurrence_count INTEGER DEFAULT 1,
208
+ first_seen TEXT DEFAULT (datetime('now')),
209
+ last_seen TEXT DEFAULT (datetime('now')),
210
+ example_error TEXT,
211
+ example_test TEXT,
212
+ UNIQUE(project_id, pattern)
213
+ );
214
+ CREATE INDEX IF NOT EXISTS idx_ep_project ON error_patterns(project_id);
215
+ CREATE INDEX IF NOT EXISTS idx_ep_cat ON error_patterns(category);
216
+
217
+ CREATE TABLE IF NOT EXISTS learning_summary (
218
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
219
+ project_id INTEGER NOT NULL UNIQUE REFERENCES projects(id),
220
+ total_runs INTEGER DEFAULT 0,
221
+ total_tests INTEGER DEFAULT 0,
222
+ overall_pass_rate REAL DEFAULT 0,
223
+ avg_duration_ms REAL DEFAULT 0,
224
+ flaky_tests TEXT,
225
+ slow_tests TEXT,
226
+ unstable_selectors TEXT,
227
+ failing_pages TEXT,
228
+ api_issues TEXT,
229
+ top_errors TEXT,
230
+ updated_at TEXT DEFAULT (datetime('now'))
231
+ );
232
+ `);
233
+
234
+ // Migrations: add metadata columns to screenshot_hashes
235
+ const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
236
+ if (!ssColumns.includes('test_name')) {
237
+ db.exec('ALTER TABLE screenshot_hashes ADD COLUMN test_name TEXT');
238
+ db.exec('ALTER TABLE screenshot_hashes ADD COLUMN step_index INTEGER');
239
+ db.exec('ALTER TABLE screenshot_hashes ADD COLUMN page_url TEXT');
240
+ db.exec('ALTER TABLE screenshot_hashes ADD COLUMN screenshot_type TEXT');
241
+ }
114
242
  }
115
243
 
116
244
  /** Upsert a project row. Returns the project id. */
@@ -155,17 +283,19 @@ export function computeScreenshotHash(filePath) {
155
283
  return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 8);
156
284
  }
157
285
 
158
- /** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. */
159
- export function registerScreenshotHash(hash, filePath, projectId, runDbId) {
286
+ /** Register a screenshot hash. INSERT OR IGNORE avoids duplicates. Optional metadata: testName, stepIndex, pageUrl, screenshotType. */
287
+ export function registerScreenshotHash(hash, filePath, projectId, runDbId, meta = {}) {
160
288
  const d = getDb();
161
- d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)').run(hash, filePath, projectId || null, runDbId || null);
289
+ d.prepare(
290
+ 'INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id, test_name, step_index, page_url, screenshot_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
291
+ ).run(hash, filePath, projectId || null, runDbId || null, meta.testName || null, meta.stepIndex ?? null, meta.pageUrl || null, meta.screenshotType || null);
162
292
  }
163
293
 
164
- /** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id } or null. */
294
+ /** Look up a screenshot by hash. Strips optional "ss:" prefix. Returns { hash, file_path, project_id, test_name, step_index, page_url, screenshot_type } or null. */
165
295
  export function lookupScreenshotHash(rawHash) {
166
296
  const d = getDb();
167
297
  const hash = rawHash.replace(/^ss:/, '');
168
- return d.prepare('SELECT hash, file_path, project_id FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
298
+ return d.prepare('SELECT hash, file_path, project_id, test_name, step_index, page_url, screenshot_type FROM screenshot_hashes WHERE hash = ?').get(hash) || null;
169
299
  }
170
300
 
171
301
  /** Batch lookup: given an array of file paths, returns { [path]: hash } map. */
@@ -182,21 +312,21 @@ export function getScreenshotHashes(filePaths) {
182
312
  }
183
313
 
184
314
  /** Save a run + its test results in a single transaction. Returns the run's DB id. */
185
- export function saveRun(projectId, report, runId, suiteName) {
315
+ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
186
316
  const d = getDb();
187
317
  const { summary, results, generatedAt } = report;
188
318
 
189
319
  const insertRun = d.prepare(`
190
- INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name)
191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
320
+ INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
321
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192
322
  `);
193
323
 
194
324
  const insertTest = d.prepare(`
195
- INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots)
196
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
325
+ INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots, network_logs, actions_json)
326
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
197
327
  `);
198
328
 
199
- const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)');
329
+ const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id, test_name, step_index, page_url, screenshot_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
200
330
 
201
331
  const tx = d.transaction(() => {
202
332
  const runInfo = insertRun.run(
@@ -209,6 +339,7 @@ export function saveRun(projectId, report, runId, suiteName) {
209
339
  summary.duration,
210
340
  generatedAt,
211
341
  suiteName || null,
342
+ triggeredBy || null,
212
343
  );
213
344
  const runDbId = runInfo.lastInsertRowid;
214
345
 
@@ -222,6 +353,19 @@ export function saveRun(projectId, report, runId, suiteName) {
222
353
  .filter(a => a.type === 'screenshot' && a.result?.screenshot)
223
354
  .map(a => a.result.screenshot);
224
355
 
356
+ // Condensed actions for narrative display
357
+ const actionsCondensed = (r.actions || []).map(a => ({
358
+ type: a.type,
359
+ selector: a.selector || undefined,
360
+ value: a.value || undefined,
361
+ text: a.text || undefined,
362
+ success: a.success,
363
+ duration: a.duration,
364
+ narrative: a.narrative || undefined,
365
+ error: a.error || undefined,
366
+ actionRetries: a.actionRetries || undefined,
367
+ }));
368
+
225
369
  insertTest.run(
226
370
  runDbId,
227
371
  r.name,
@@ -236,14 +380,22 @@ export function saveRun(projectId, report, runId, suiteName) {
236
380
  r.consoleLogs ? JSON.stringify(r.consoleLogs) : null,
237
381
  r.networkErrors ? JSON.stringify(r.networkErrors) : null,
238
382
  screenshots.length ? JSON.stringify(screenshots) : null,
383
+ r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
384
+ actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
239
385
  );
240
386
 
241
- // Register screenshot hashes
242
- for (const ssPath of screenshots) {
243
- insertHash.run(computeScreenshotHash(ssPath), ssPath, projectId, runDbId);
387
+ // Register screenshot hashes with metadata
388
+ const ssActions = (r.actions || []).filter(a => a.type === 'screenshot' && a.result?.screenshot);
389
+ for (let si = 0; si < ssActions.length; si++) {
390
+ const a = ssActions[si];
391
+ const actionIdx = r.actions.indexOf(a);
392
+ insertHash.run(computeScreenshotHash(a.result.screenshot), a.result.screenshot, projectId, runDbId, r.name, actionIdx, null, 'action');
244
393
  }
245
394
  if (r.errorScreenshot) {
246
- insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId);
395
+ insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId, r.name, null, null, 'error');
396
+ }
397
+ if (r.verificationScreenshot) {
398
+ insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
247
399
  }
248
400
  }
249
401
 
@@ -273,7 +425,7 @@ export function listProjects() {
273
425
  export function getProjectRuns(projectId, limit = 50, offset = 0) {
274
426
  const d = getDb();
275
427
  return d.prepare(`
276
- SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name
428
+ SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
277
429
  FROM runs
278
430
  WHERE project_id = ?
279
431
  ORDER BY generated_at DESC
@@ -310,6 +462,7 @@ export function getRunDetail(runDbId) {
310
462
  },
311
463
  generatedAt: run.generated_at,
312
464
  suiteName: run.suite_name,
465
+ triggeredBy: run.triggered_by || null,
313
466
  results: tests.map(t => {
314
467
  const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
315
468
  const testPaths = [...screenshots];
@@ -331,6 +484,8 @@ export function getRunDetail(runDbId) {
331
484
  screenshots,
332
485
  consoleLogs: t.console_logs ? JSON.parse(t.console_logs) : [],
333
486
  networkErrors: t.network_errors ? JSON.parse(t.network_errors) : [],
487
+ networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
488
+ actions: t.actions_json ? JSON.parse(t.actions_json) : [],
334
489
  screenshotHashes,
335
490
  };
336
491
  }),
@@ -342,7 +497,7 @@ export function getAllRuns(limit = 50, offset = 0) {
342
497
  const d = getDb();
343
498
  return d.prepare(`
344
499
  SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
345
- r.generated_at, r.suite_name, p.name AS project_name, p.id AS project_id
500
+ r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
346
501
  FROM runs r
347
502
  JOIN projects p ON p.id = r.project_id
348
503
  ORDER BY r.generated_at DESC
@@ -357,6 +512,66 @@ export function getRunCount() {
357
512
  return row.cnt;
358
513
  }
359
514
 
515
+ /** Query network logs for a run with optional filters.
516
+ * Filters: testName, method, statusMin, statusMax, urlPattern, errorsOnly, includeHeaders, includeBodies.
517
+ * By default returns only: url, method, status, statusText, duration.
518
+ */
519
+ export function getNetworkLogs(runDbId, filters = {}) {
520
+ const d = getDb();
521
+
522
+ let query = 'SELECT name, network_logs FROM test_results WHERE run_id = ?';
523
+ const params = [runDbId];
524
+
525
+ if (filters.testName) {
526
+ query += ' AND name = ?';
527
+ params.push(filters.testName);
528
+ }
529
+
530
+ const rows = d.prepare(query).all(...params);
531
+ const results = [];
532
+
533
+ for (const row of rows) {
534
+ if (!row.network_logs) continue;
535
+ let logs = JSON.parse(row.network_logs);
536
+
537
+ if (filters.method) {
538
+ logs = logs.filter(l => l.method === filters.method.toUpperCase());
539
+ }
540
+ if (filters.statusMin !== undefined) {
541
+ logs = logs.filter(l => l.status >= filters.statusMin);
542
+ }
543
+ if (filters.statusMax !== undefined) {
544
+ logs = logs.filter(l => l.status <= filters.statusMax);
545
+ }
546
+ if (filters.urlPattern) {
547
+ const re = new RegExp(filters.urlPattern, 'i');
548
+ logs = logs.filter(l => re.test(l.url));
549
+ }
550
+ if (filters.errorsOnly) {
551
+ logs = logs.filter(l => l.status >= 400);
552
+ }
553
+
554
+ const mapped = logs.map(l => {
555
+ const entry = { url: l.url, method: l.method, status: l.status, statusText: l.statusText, duration: l.duration };
556
+ if (filters.includeHeaders || filters.includeBodies) {
557
+ entry.requestHeaders = l.requestHeaders;
558
+ entry.responseHeaders = l.responseHeaders;
559
+ }
560
+ if (filters.includeBodies) {
561
+ entry.requestBody = l.requestBody;
562
+ entry.responseBody = l.responseBody;
563
+ }
564
+ return entry;
565
+ });
566
+
567
+ if (mapped.length > 0) {
568
+ results.push({ testName: row.name, logs: mapped });
569
+ }
570
+ }
571
+
572
+ return results;
573
+ }
574
+
360
575
  /** Close the database connection. */
361
576
  export function closeDb() {
362
577
  if (db) {
package/src/index.js CHANGED
@@ -16,6 +16,12 @@ export { startDashboard, stopDashboard } from './dashboard.js';
16
16
  export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
17
17
  export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
18
18
  export { verifyIssue } from './verify.js';
19
+ export { resolveTestData, loadModuleRegistry, listModules } from './module-resolver.js';
20
+ export { learnFromRun, categorizeError } from './learner.js';
21
+ export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights } from './learner-sqlite.js';
22
+ export { generateLearningsMarkdown } from './learner-markdown.js';
23
+ export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
24
+ export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
19
25
 
20
26
  import { loadConfig } from './config.js';
21
27
  import { waitForPool } from './pool.js';
@@ -36,7 +42,7 @@ export async function createRunner(userConfig = {}) {
36
42
  /** Runs all test suites from the tests directory */
37
43
  async runAll() {
38
44
  await waitForPool(config.poolUrl);
39
- const { tests, hooks } = loadAllSuites(config.testsDir);
45
+ const { tests, hooks } = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
40
46
  const results = await runTestsParallel(tests, config, hooks);
41
47
  const report = generateReport(results);
42
48
  saveReport(report, config.screenshotsDir, config);
@@ -47,7 +53,7 @@ export async function createRunner(userConfig = {}) {
47
53
  /** Runs a single suite by name */
48
54
  async runSuite(name) {
49
55
  await waitForPool(config.poolUrl);
50
- const { tests, hooks } = loadTestSuite(name, config.testsDir);
56
+ const { tests, hooks } = loadTestSuite(name, config.testsDir, config.modulesDir);
51
57
  const results = await runTestsParallel(tests, config, hooks);
52
58
  const report = generateReport(results);
53
59
  saveReport(report, config.screenshotsDir, config);
@@ -68,7 +74,7 @@ export async function createRunner(userConfig = {}) {
68
74
  /** Runs tests from a JSON file path */
69
75
  async runFile(filePath) {
70
76
  await waitForPool(config.poolUrl);
71
- const { tests, hooks } = loadTestFile(filePath);
77
+ const { tests, hooks } = loadTestFile(filePath, config.modulesDir);
72
78
  const results = await runTestsParallel(tests, config, hooks);
73
79
  const report = generateReport(results);
74
80
  saveReport(report, config.screenshotsDir, config);