@matware/e2e-runner 1.3.0 → 1.3.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/src/dashboard.js CHANGED
@@ -20,7 +20,8 @@ import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory,
20
20
  import { listProjects as dbListProjects, listProjectsWithSparklines as dbListProjectsWithSparklines, 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, listVariables as dbListVariables, setVariable as dbSetVariable, deleteVariable as dbDeleteVariable, 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, getRunInsights, getHealthSnapshot } from './learner-sqlite.js';
23
+ import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getHealthSnapshot, getActionHealthScores } from './learner-sqlite.js';
24
+ import { compareImages } from './visual-diff.js';
24
25
  import { handleSyncRoutes } from './sync/hub-routes.js';
25
26
  import { migrateSyncSchema } from './sync/schema.js';
26
27
 
@@ -120,7 +121,7 @@ export async function startDashboard(config) {
120
121
  // API: pool status + dashboard state
121
122
  if (pathname === '/api/status') {
122
123
  const poolUrls = getPoolUrls(config);
123
- const aggregated = await getAggregatedPoolStatus(poolUrls);
124
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
124
125
  jsonResponse(res, {
125
126
  pool: aggregated,
126
127
  poolUrls,
@@ -305,6 +306,38 @@ export async function startDashboard(config) {
305
306
  return;
306
307
  }
307
308
 
309
+ // API: DB — project config warnings (Docker hostname detection)
310
+ const configWarningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/config-warnings$/);
311
+ if (configWarningsMatch) {
312
+ try {
313
+ const projectId = parseInt(configWarningsMatch[1], 10);
314
+ const projectCwd = dbGetProjectCwd(projectId);
315
+ if (!projectCwd) { jsonResponse(res, { warnings: [] }); return; }
316
+ const cfg = await loadConfig({}, projectCwd);
317
+ const warnings = [];
318
+ const checkDockerHostname = (url, label) => {
319
+ try {
320
+ const parsed = new URL(url);
321
+ if (!parsed.hostname.includes('.') && parsed.hostname !== 'localhost' && parsed.hostname !== '127') {
322
+ warnings.push({
323
+ type: 'docker-hostname',
324
+ field: label,
325
+ hostname: parsed.hostname,
326
+ url,
327
+ message: `"${parsed.hostname}" looks like a Docker-internal hostname. The runner will auto-fallback to localhost for auth, but baseUrl requests go through Chrome in Docker (which can resolve it). If tests fail with ENOTFOUND, ensure Chrome pool is on the same Docker network.`,
328
+ });
329
+ }
330
+ } catch {}
331
+ };
332
+ if (cfg.baseUrl) checkDockerHostname(cfg.baseUrl, 'baseUrl');
333
+ if (cfg.authLoginEndpoint) checkDockerHostname(cfg.authLoginEndpoint, 'authLoginEndpoint');
334
+ jsonResponse(res, { warnings });
335
+ } catch (error) {
336
+ jsonResponse(res, { warnings: [], error: error.message });
337
+ }
338
+ return;
339
+ }
340
+
308
341
  // API: DB — project screenshots list
309
342
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
310
343
  if (projectScreenshotsMatch) {
@@ -498,6 +531,9 @@ export async function startDashboard(config) {
498
531
  case 'trends':
499
532
  data = getTestTrends(projectId, days);
500
533
  break;
534
+ case 'actions':
535
+ data = getActionHealthScores(projectId, days);
536
+ break;
501
537
  default:
502
538
  jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
503
539
  return;
@@ -509,6 +545,30 @@ export async function startDashboard(config) {
509
545
  return;
510
546
  }
511
547
 
548
+ // API: visual diff — compare two screenshots on demand
549
+ if (pathname === '/api/visual-diff') {
550
+ try {
551
+ const baseline = url.searchParams.get('baseline');
552
+ const current = url.searchParams.get('current');
553
+ const thresholdParam = url.searchParams.get('threshold');
554
+ if (!baseline || !current) {
555
+ jsonResponse(res, { error: 'Missing baseline or current parameter' }, 400); return;
556
+ }
557
+ if (!fs.existsSync(baseline)) { jsonResponse(res, { error: `Baseline not found: ${baseline}` }, 404); return; }
558
+ if (!fs.existsSync(current)) { jsonResponse(res, { error: `Current not found: ${current}` }, 404); return; }
559
+
560
+ const diffPath = path.join(config.screenshotsDir, `api-diff-${Date.now()}.png`);
561
+ const result = compareImages(baseline, current, {
562
+ threshold: thresholdParam ? parseFloat(thresholdParam) : 0.1,
563
+ diffOutputPath: diffPath,
564
+ });
565
+ jsonResponse(res, { ...result, diffImagePath: diffPath });
566
+ } catch (error) {
567
+ jsonResponse(res, { error: error.message }, 500);
568
+ }
569
+ return;
570
+ }
571
+
512
572
  // API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
513
573
  const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
514
574
  if (ssHashMatch) {
@@ -663,7 +723,9 @@ export async function startDashboard(config) {
663
723
  if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
664
724
  try {
665
725
  const data = JSON.parse(body);
666
- bufferLiveEvent(data);
726
+ if (data.event !== 'test:frame') {
727
+ bufferLiveEvent(data);
728
+ }
667
729
  wss.broadcast(JSON.stringify(data));
668
730
  } catch { /* */ }
669
731
  jsonResponse(res, { ok: true });
@@ -711,8 +773,9 @@ export async function startDashboard(config) {
711
773
  }
712
774
  }, 30000);
713
775
 
776
+ const devOrigins = process.env.NODE_ENV === 'production' ? [] : ['http://localhost:5173', 'http://127.0.0.1:5173'];
714
777
  const wss = createWebSocketServer(server, {
715
- allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
778
+ allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`, ...devOrigins],
716
779
  onConnect(socket) {
717
780
  // Replay live state for new/reconnected clients
718
781
  for (const rid of Object.keys(liveEventBuffers)) {
@@ -727,7 +790,7 @@ export async function startDashboard(config) {
727
790
  const poolUrls = getPoolUrls(config);
728
791
  const pollInterval = setInterval(async () => {
729
792
  try {
730
- const aggregated = await getAggregatedPoolStatus(poolUrls);
793
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
731
794
  wss.broadcast(JSON.stringify({ event: 'pool:status', data: aggregated }));
732
795
  } catch { /* */ }
733
796
  }, 5000);
@@ -765,21 +828,25 @@ export async function startDashboard(config) {
765
828
  runConfig.triggeredBy = 'dashboard';
766
829
  if (params.concurrency) runConfig.concurrency = params.concurrency;
767
830
  if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
831
+ if (params.screencast !== undefined) runConfig.screencast = params.screencast;
768
832
 
769
833
  // Wire up onProgress to broadcast WS events
770
834
  runConfig.onProgress = (data) => {
771
- bufferLiveEvent(data);
835
+ // Don't buffer screencast frames — they're ephemeral and high volume
836
+ if (data.event !== 'test:frame') {
837
+ bufferLiveEvent(data);
838
+ }
772
839
  wss.broadcast(JSON.stringify(data));
773
840
  };
774
841
 
775
842
  let tests, hooks;
776
843
  if (params.suite) {
777
- ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
844
+ ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir, runConfig.modulesDir));
778
845
  } else {
779
846
  ({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
780
847
  }
781
848
 
782
- await waitForAnyPool(getPoolUrls(runConfig));
849
+ await waitForAnyPool(getPoolUrls(runConfig), 30000, { poolDriver: runConfig.poolDriver, maxSessions: runConfig.maxSessions });
783
850
  const results = await runTestsParallel(tests, runConfig, hooks || {});
784
851
  const report = generateReport(results);
785
852
  const suiteName = params.suite || null;
package/src/db.js CHANGED
@@ -199,6 +199,24 @@ function migrate(db) {
199
199
  CREATE INDEX IF NOT EXISTS idx_al_project ON api_learnings(project_id);
200
200
  CREATE INDEX IF NOT EXISTS idx_al_endpoint ON api_learnings(endpoint);
201
201
 
202
+ CREATE TABLE IF NOT EXISTS action_health (
203
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
204
+ project_id INTEGER NOT NULL REFERENCES projects(id),
205
+ run_id INTEGER REFERENCES runs(id) ON DELETE CASCADE,
206
+ test_name TEXT NOT NULL,
207
+ action_index INTEGER NOT NULL,
208
+ action_type TEXT NOT NULL,
209
+ selector TEXT,
210
+ success INTEGER NOT NULL,
211
+ duration_ms INTEGER,
212
+ console_errors_after INTEGER DEFAULT 0,
213
+ network_errors_after INTEGER DEFAULT 0,
214
+ page_url TEXT,
215
+ created_at TEXT DEFAULT (datetime('now'))
216
+ );
217
+ CREATE INDEX IF NOT EXISTS idx_ah_project ON action_health(project_id);
218
+ CREATE INDEX IF NOT EXISTS idx_ah_selector ON action_health(selector);
219
+
202
220
  CREATE TABLE IF NOT EXISTS error_patterns (
203
221
  id INTEGER PRIMARY KEY AUTOINCREMENT,
204
222
  project_id INTEGER NOT NULL REFERENCES projects(id),
@@ -255,6 +273,28 @@ function migrate(db) {
255
273
  db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
256
274
  }
257
275
 
276
+ // Add pool_driver column to runs for driver visibility
277
+ try {
278
+ db.prepare('SELECT pool_driver FROM runs LIMIT 0').run();
279
+ } catch {
280
+ db.exec('ALTER TABLE runs ADD COLUMN pool_driver TEXT');
281
+ }
282
+
283
+ // Add visual diff columns to test_results
284
+ const trCols = db.pragma('table_info(test_results)').map(c => c.name);
285
+ if (!trCols.includes('baseline_screenshot')) {
286
+ db.exec('ALTER TABLE test_results ADD COLUMN baseline_screenshot TEXT');
287
+ }
288
+ if (!trCols.includes('verification_screenshot')) {
289
+ db.exec('ALTER TABLE test_results ADD COLUMN verification_screenshot TEXT');
290
+ }
291
+ if (!trCols.includes('diff_screenshot')) {
292
+ db.exec('ALTER TABLE test_results ADD COLUMN diff_screenshot TEXT');
293
+ }
294
+ if (!trCols.includes('visual_diff_json')) {
295
+ db.exec('ALTER TABLE test_results ADD COLUMN visual_diff_json TEXT');
296
+ }
297
+
258
298
  // Migrations: add metadata columns to screenshot_hashes
259
299
  const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
260
300
  if (!ssColumns.includes('test_name')) {
@@ -336,18 +376,18 @@ export function getScreenshotHashes(filePaths) {
336
376
  }
337
377
 
338
378
  /** Save a run + its test results in a single transaction. Returns the run's DB id. */
339
- export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
379
+ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDriver) {
340
380
  const d = getDb();
341
381
  const { summary, results, generatedAt } = report;
342
382
 
343
383
  const insertRun = d.prepare(`
344
- INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
345
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
384
+ INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver)
385
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
346
386
  `);
347
387
 
348
388
  const insertTest = d.prepare(`
349
- 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, pool_url)
350
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
389
+ 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, pool_url, baseline_screenshot, verification_screenshot, diff_screenshot, visual_diff_json)
390
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
351
391
  `);
352
392
 
353
393
  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 (?, ?, ?, ?, ?, ?, ?, ?)');
@@ -364,6 +404,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
364
404
  generatedAt,
365
405
  suiteName || null,
366
406
  triggeredBy || null,
407
+ poolDriver || null,
367
408
  );
368
409
  const runDbId = runInfo.lastInsertRowid;
369
410
 
@@ -407,6 +448,10 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
407
448
  r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
408
449
  actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
409
450
  r.poolUrl || null,
451
+ r.baselineScreenshot || null,
452
+ r.verificationScreenshot || null,
453
+ r.diffScreenshot || null,
454
+ r.visualDiff ? JSON.stringify(r.visualDiff) : null,
410
455
  );
411
456
 
412
457
  // Register screenshot hashes with metadata
@@ -425,6 +470,9 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
425
470
  if (r.baselineScreenshot) {
426
471
  insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
427
472
  }
473
+ if (r.diffScreenshot) {
474
+ insertHash.run(computeScreenshotHash(r.diffScreenshot), r.diffScreenshot, projectId, runDbId, r.name, null, null, 'diff');
475
+ }
428
476
  }
429
477
 
430
478
  return runDbId;
@@ -480,7 +528,7 @@ export function listProjects() {
480
528
  export function getProjectRuns(projectId, limit = 50, offset = 0) {
481
529
  const d = getDb();
482
530
  return d.prepare(`
483
- SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
531
+ SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, pool_driver
484
532
  FROM runs
485
533
  WHERE project_id = ?
486
534
  ORDER BY generated_at DESC
@@ -503,6 +551,9 @@ export function getRunDetail(runDbId) {
503
551
  const ss = t.screenshots ? JSON.parse(t.screenshots) : [];
504
552
  allPaths.push(...ss);
505
553
  if (t.error_screenshot) allPaths.push(t.error_screenshot);
554
+ if (t.baseline_screenshot) allPaths.push(t.baseline_screenshot);
555
+ if (t.verification_screenshot) allPaths.push(t.verification_screenshot);
556
+ if (t.diff_screenshot) allPaths.push(t.diff_screenshot);
506
557
  }
507
558
  const hashMap = getScreenshotHashes(allPaths);
508
559
 
@@ -518,6 +569,7 @@ export function getRunDetail(runDbId) {
518
569
  generatedAt: run.generated_at,
519
570
  suiteName: run.suite_name,
520
571
  triggeredBy: run.triggered_by || null,
572
+ poolDriver: run.pool_driver || null,
521
573
  results: tests.map(t => {
522
574
  const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
523
575
  const testPaths = [...screenshots];
@@ -543,6 +595,10 @@ export function getRunDetail(runDbId) {
543
595
  actions: t.actions_json ? JSON.parse(t.actions_json) : [],
544
596
  screenshotHashes,
545
597
  poolUrl: t.pool_url || null,
598
+ baselineScreenshot: t.baseline_screenshot || null,
599
+ verificationScreenshot: t.verification_screenshot || null,
600
+ diffScreenshot: t.diff_screenshot || null,
601
+ visualDiff: t.visual_diff_json ? JSON.parse(t.visual_diff_json) : null,
546
602
  };
547
603
  }),
548
604
  };
@@ -553,7 +609,7 @@ export function getAllRuns(limit = 50, offset = 0) {
553
609
  const d = getDb();
554
610
  return d.prepare(`
555
611
  SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
556
- r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
612
+ r.generated_at, r.suite_name, r.triggered_by, r.pool_driver, p.name AS project_name, p.id AS project_id
557
613
  FROM runs r
558
614
  JOIN projects p ON p.id = r.project_id
559
615
  ORDER BY r.generated_at DESC
package/src/index.js CHANGED
@@ -8,21 +8,23 @@
8
8
  */
9
9
 
10
10
  export { loadConfig } from './config.js';
11
- export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
11
+ export { waitForPool, connectToPool, disconnectFromPool, startPool, stopPool, restartPool, getPoolStatus, clearDriverCache, getCachedDriver, trackCdpSession, releaseCdpSession, releaseSteelSession } from './pool.js';
12
12
  export { getPoolUrls, getAllPoolStatuses, getAggregatedPoolStatus, waitForAnyPool, selectPool, selectAndConnect } from './pool-manager.js';
13
13
  export { executeAction } from './actions.js';
14
- export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
14
+ export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites, fetchAuthToken } from './runner.js';
15
15
  export { generateReport, generateJUnitXML, saveReport, printReport, saveHistory, loadHistory, loadHistoryRun } from './reporter.js';
16
16
  export { startDashboard, stopDashboard } from './dashboard.js';
17
17
  export { fetchIssue, parseIssueUrl, detectProvider, checkCliAuth } from './issues.js';
18
18
  export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
19
19
  export { verifyIssue } from './verify.js';
20
20
  export { resolveTestData, loadModuleRegistry, listModules } from './module-resolver.js';
21
- export { learnFromRun, categorizeError } from './learner.js';
22
- export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestCreationContext, generateImprovements } from './learner-sqlite.js';
21
+ export { learnFromRun, categorizeError, isInfraError, INFRA_CATEGORIES } from './learner.js';
22
+ export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestCreationContext, generateImprovements, getActionHealthScores } from './learner-sqlite.js';
23
23
  export { generateLearningsMarkdown } from './learner-markdown.js';
24
24
  export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
25
25
  export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
26
+ export { forkAppInstance, destroyFork, destroyAllForks, getAppPoolStatus, isAppPoolEnabled } from './app-pool.js';
27
+ export { compareImages, assertVisualMatch } from './visual-diff.js';
26
28
 
27
29
  import { loadConfig } from './config.js';
28
30
  import { waitForAnyPool, getPoolUrls } from './pool-manager.js';
@@ -293,6 +293,117 @@ export function getRunInsights(projectId, report) {
293
293
  }
294
294
  }
295
295
 
296
+ // ── At-Least-One Guarantee: generate positive insights if none exist ──
297
+ if (insights.length === 0 && report.results.length > 0) {
298
+ const allPassed = report.results.every(r => r.success);
299
+
300
+ // Green streak detection
301
+ if (allPassed) {
302
+ const recentRuns = d.prepare(`
303
+ SELECT run_id, MIN(success) AS all_passed
304
+ FROM test_learnings
305
+ WHERE project_id = ?
306
+ GROUP BY run_id
307
+ ORDER BY created_at DESC
308
+ LIMIT 10
309
+ `).all(projectId);
310
+ const streak = recentRuns.findIndex(r => r.all_passed === 0);
311
+ const streakCount = streak === -1 ? recentRuns.length : streak;
312
+ if (streakCount >= 3) {
313
+ insights.push({
314
+ type: 'green-streak',
315
+ streak: streakCount,
316
+ message: `${streakCount}-run green streak — suite is stable.`,
317
+ });
318
+ }
319
+ }
320
+
321
+ // New tests (no historical data)
322
+ const newTests = report.results.filter(r => {
323
+ const h = d.prepare('SELECT COUNT(*) AS c FROM test_learnings WHERE project_id = ? AND test_name = ?').get(projectId, r.name);
324
+ return !h || h.c <= 1; // <= 1 because current run may already be written
325
+ });
326
+ if (newTests.length > 0) {
327
+ insights.push({
328
+ type: 'new-tests',
329
+ tests: newTests.map(t => t.name),
330
+ message: `${newTests.length} new test(s): ${newTests.map(t => t.name).slice(0, 3).join(', ')}${newTests.length > 3 ? '...' : ''}`,
331
+ });
332
+ }
333
+
334
+ // Pass rate improvement vs 7-day average
335
+ const avg7d = d.prepare(`
336
+ SELECT ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS pass_rate
337
+ FROM test_learnings
338
+ WHERE project_id = ? AND created_at >= datetime('now', '-7 days')
339
+ `).get(projectId);
340
+ const thisRunPassRate = Math.round((report.results.filter(r => r.success).length / report.results.length) * 1000) / 10;
341
+ if (avg7d?.pass_rate && thisRunPassRate > avg7d.pass_rate + 5) {
342
+ insights.push({
343
+ type: 'improved-pass-rate',
344
+ message: `Pass rate improved: ${thisRunPassRate}% this run vs ${avg7d.pass_rate}% 7-day average.`,
345
+ });
346
+ }
347
+
348
+ // Performance comparison
349
+ const avgDuration = d.prepare(`
350
+ SELECT ROUND(AVG(duration_ms)) AS avg_ms
351
+ FROM test_learnings
352
+ WHERE project_id = ? AND duration_ms IS NOT NULL AND created_at >= datetime('now', '-30 days')
353
+ `).get(projectId);
354
+ if (avgDuration?.avg_ms && report.results.length > 0) {
355
+ const thisAvg = report.results.reduce((s, r) => {
356
+ const ms = (r.endTime && r.startTime) ? new Date(r.endTime) - new Date(r.startTime) : 0;
357
+ return s + ms;
358
+ }, 0) / report.results.length;
359
+ const delta = Math.round(((thisAvg - avgDuration.avg_ms) / avgDuration.avg_ms) * 100);
360
+ if (Math.abs(delta) > 15) {
361
+ insights.push({
362
+ type: 'performance',
363
+ message: delta < 0
364
+ ? `This run was ${Math.abs(delta)}% faster than the 30-day average.`
365
+ : `This run was ${delta}% slower than the 30-day average — check for new slow pages.`,
366
+ });
367
+ }
368
+ }
369
+
370
+ // Stable selectors confirmed
371
+ if (allPassed) {
372
+ const usedSelectors = new Set();
373
+ for (const r of report.results) {
374
+ if (!r.actions) continue;
375
+ for (const a of r.actions) {
376
+ if (a.selector) usedSelectors.add(a.selector);
377
+ }
378
+ }
379
+ if (usedSelectors.size > 0) {
380
+ const stableCount = d.prepare(`
381
+ SELECT COUNT(DISTINCT selector) AS c
382
+ FROM selector_learnings
383
+ WHERE project_id = ? AND selector IN (${[...usedSelectors].map(() => '?').join(',')})
384
+ GROUP BY selector
385
+ HAVING SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) = 0 AND COUNT(*) > 3
386
+ `).all(projectId, ...usedSelectors).length;
387
+ if (stableCount > 0) {
388
+ insights.push({
389
+ type: 'stable-selectors',
390
+ count: stableCount,
391
+ message: `${stableCount} selector(s) confirmed stable across multiple runs.`,
392
+ });
393
+ }
394
+ }
395
+ }
396
+
397
+ // Fallback: if still no insights, report basic run stats
398
+ if (insights.length === 0) {
399
+ const passed = report.results.filter(r => r.success).length;
400
+ insights.push({
401
+ type: 'run-summary',
402
+ message: `${passed}/${report.results.length} tests passed (${thisRunPassRate}%).`,
403
+ });
404
+ }
405
+ }
406
+
296
407
  return insights;
297
408
  }
298
409
 
@@ -397,6 +508,49 @@ export function getSelectorHistory(projectId, selector, days = 30) {
397
508
  * Aggregated context for test authoring — curates the most actionable learnings
398
509
  * into a compact object that AI agents can use to write better tests.
399
510
  */
511
+ /**
512
+ * Action health scores — composite per-action metrics aggregated by (action_type, selector).
513
+ * Score = (success_rate * 0.5) + (speed_score * 0.3) + (collateral_score * 0.2)
514
+ */
515
+ export function getActionHealthScores(projectId, days = 30) {
516
+ const d = getDb();
517
+ const rows = d.prepare(`
518
+ SELECT
519
+ action_type,
520
+ selector,
521
+ page_url,
522
+ COUNT(*) AS total_uses,
523
+ ROUND(AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END), 1) AS success_rate,
524
+ ROUND(AVG(duration_ms)) AS avg_duration_ms,
525
+ MAX(duration_ms) AS max_duration_ms,
526
+ ROUND(AVG(console_errors_after + network_errors_after), 1) AS avg_collateral_errors,
527
+ COUNT(DISTINCT test_name) AS used_by_tests
528
+ FROM action_health
529
+ WHERE project_id = ? AND created_at >= datetime('now', '-' || ? || ' days')
530
+ GROUP BY action_type, selector
531
+ HAVING total_uses >= 2
532
+ ORDER BY success_rate ASC, total_uses DESC
533
+ `).all(projectId, days);
534
+
535
+ return rows.map(r => {
536
+ const speedScore = 100 - Math.min(100, ((r.avg_duration_ms || 0) / 5000) * 100);
537
+ const collateralScore = 100 - Math.min(100, (r.avg_collateral_errors || 0) * 20);
538
+ const healthScore = Math.round(r.success_rate * 0.5 + speedScore * 0.3 + collateralScore * 0.2);
539
+ return {
540
+ actionType: r.action_type,
541
+ selector: r.selector,
542
+ pageUrl: r.page_url,
543
+ totalUses: r.total_uses,
544
+ successRate: r.success_rate,
545
+ avgDurationMs: r.avg_duration_ms,
546
+ maxDurationMs: r.max_duration_ms,
547
+ avgCollateralErrors: r.avg_collateral_errors,
548
+ usedByTests: r.used_by_tests,
549
+ healthScore,
550
+ };
551
+ });
552
+ }
553
+
400
554
  export function getTestCreationContext(projectId) {
401
555
  const d = getDb();
402
556
  const ctx = {};
package/src/learner.js CHANGED
@@ -17,8 +17,12 @@ const ERROR_CATEGORIES = [
17
17
  { pattern: /waitForSelector/i, category: 'selector-not-found' },
18
18
  { pattern: /not visible/i, category: 'selector-not-found' },
19
19
  { pattern: /navigation/i, category: 'navigation-error' },
20
- { pattern: /net::ERR_/i, category: 'connection-refused' },
20
+ { pattern: /ERR_NAME_NOT_RESOLVED/i, category: 'dns-resolution' },
21
21
  { pattern: /ERR_CONNECTION_REFUSED/i, category: 'connection-refused' },
22
+ { pattern: /ECONNREFUSED/i, category: 'connection-refused' },
23
+ { pattern: /Chrome Pool unavailable/i, category: 'pool-unavailable' },
24
+ { pattern: /Failed to connect to pool/i, category: 'pool-connect-failed' },
25
+ { pattern: /net::ERR_/i, category: 'network-error' },
22
26
  { pattern: /assert_text/i, category: 'assert-text-failed' },
23
27
  { pattern: /assert_url/i, category: 'assert-url-failed' },
24
28
  { pattern: /assert_visible/i, category: 'assert-visible-failed' },
@@ -35,6 +39,18 @@ const ERROR_CATEGORIES = [
35
39
  { pattern: /evaluate.*ERROR/i, category: 'evaluate-error' },
36
40
  ];
37
41
 
42
+ /** Categories that indicate infrastructure failures — not test/app issues. */
43
+ export const INFRA_CATEGORIES = new Set([
44
+ 'connection-refused', 'dns-resolution', 'pool-unavailable', 'pool-connect-failed', 'network-error',
45
+ ]);
46
+
47
+ /** Returns true if the error is an infrastructure issue (pool down, DNS, connection refused). */
48
+ export function isInfraError(errorMsg) {
49
+ if (!errorMsg) return false;
50
+ const { category } = categorizeError(errorMsg);
51
+ return INFRA_CATEGORIES.has(category);
52
+ }
53
+
38
54
  export function categorizeError(errorMsg) {
39
55
  if (!errorMsg) return { category: 'unknown', pattern: 'unknown' };
40
56
 
@@ -204,6 +220,11 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
204
220
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
205
221
  `);
206
222
 
223
+ const insertActionHealth = d.prepare(`
224
+ INSERT INTO action_health (project_id, run_id, test_name, action_index, action_type, selector, success, duration_ms, console_errors_after, network_errors_after, page_url)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
226
+ `);
227
+
207
228
  const upsertErrorPattern = d.prepare(`
208
229
  INSERT INTO error_patterns (project_id, pattern, category, occurrence_count, first_seen, last_seen, example_error, example_test)
209
230
  VALUES (?, ?, ?, 1, datetime('now'), datetime('now'), ?, ?)
@@ -214,23 +235,40 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
214
235
  example_test = excluded.example_test
215
236
  `);
216
237
 
238
+ let infraCount = 0;
239
+
217
240
  const tx = d.transaction(() => {
218
241
  for (const result of results) {
219
242
  const durationMs = (result.endTime && result.startTime)
220
243
  ? new Date(result.endTime) - new Date(result.startTime)
221
244
  : null;
222
- const isFlaky = result.success && (result.attempt || 1) > 1 ? 1 : 0;
245
+ const isFlaky = result.flaky ? 1 : (result.success && (result.attempt || 1) > 1 ? 1 : 0);
223
246
 
224
247
  // Categorize error
225
248
  let errorPattern = null;
249
+ let infraFailure = false;
226
250
  if (result.error) {
227
251
  const { category, pattern } = categorizeError(result.error);
228
252
  errorPattern = category;
253
+ infraFailure = INFRA_CATEGORIES.has(category);
229
254
 
230
- // Track error pattern
255
+ // Always track error patterns (even infra) for awareness
231
256
  upsertErrorPattern.run(projectId, pattern, category, result.error, result.name);
232
257
  }
233
258
 
259
+ if (infraFailure) {
260
+ infraCount++;
261
+ // Still write test_learnings so run counts are accurate,
262
+ // but skip selector/page/api learnings to avoid polluting metrics
263
+ insertTestLearning.run(
264
+ projectId, runDbId, result.name,
265
+ result.success ? 1 : 0, durationMs, isFlaky,
266
+ result.attempt || 1, result.maxAttempts || 1,
267
+ errorPattern
268
+ );
269
+ continue;
270
+ }
271
+
234
272
  // Test-level learning
235
273
  insertTestLearning.run(
236
274
  projectId, runDbId, result.name,
@@ -275,6 +313,33 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
275
313
  api.isError, result.name
276
314
  );
277
315
  }
316
+
317
+ // Action health — per-action metrics with collateral error estimation
318
+ if (result.actions?.length) {
319
+ const totalConsoleErrors = (result.consoleLogs || []).filter(l => l.type === 'error').length;
320
+ const totalNetworkErrors = (result.networkErrors || []).length;
321
+ const actionCount = result.actions.length;
322
+ let currentPage = '/';
323
+
324
+ for (let i = 0; i < actionCount; i++) {
325
+ const action = result.actions[i];
326
+ if (action.type === 'goto' || action.type === 'navigate') {
327
+ try { currentPage = new URL(action.value, 'http://placeholder').pathname; } catch { currentPage = action.value || '/'; }
328
+ }
329
+ // Estimate collateral errors: later actions inherit more errors (weighted distribution)
330
+ const weight = (i + 1) / actionCount;
331
+ const consoleAfter = action.success === false ? Math.round(totalConsoleErrors * weight) : 0;
332
+ const networkAfter = action.success === false ? Math.round(totalNetworkErrors * weight) : 0;
333
+
334
+ insertActionHealth.run(
335
+ projectId, runDbId, result.name, i,
336
+ action.type || 'unknown', action.selector || null,
337
+ action.success === false ? 0 : 1,
338
+ action.duration || null,
339
+ consoleAfter, networkAfter, currentPage
340
+ );
341
+ }
342
+ }
278
343
  }
279
344
  });
280
345
 
@@ -287,6 +352,8 @@ export function learnFromRun(projectId, runDbId, report, config, suiteName) {
287
352
  if (config?.learningsNeo4j) {
288
353
  writeToGraph(projectId, runDbId, report, config, suiteName).catch(() => {});
289
354
  }
355
+
356
+ return { infraCount };
290
357
  }
291
358
 
292
359
  // ── Summary cache ─────────────────────────────────────────────────────────────