@matware/e2e-runner 1.2.1 → 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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
package/src/dashboard.js CHANGED
@@ -14,13 +14,16 @@ import path from 'path';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { createRequire } from 'module';
16
16
  import { createWebSocketServer } from './websocket.js';
17
- import { getPoolStatus, waitForPool } from './pool.js';
17
+ import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool } from './pool-manager.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, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, closeDb } from './db.js';
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 } 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';
25
+ import { handleSyncRoutes } from './sync/hub-routes.js';
26
+ import { migrateSyncSchema } from './sync/schema.js';
24
27
 
25
28
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
26
29
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -38,6 +41,12 @@ export async function startDashboard(config) {
38
41
  const port = config.dashboardPort || 8484;
39
42
  const MAX_BODY = 1024 * 1024; // 1MB limit for POST bodies
40
43
  const dashboardHtml = fs.readFileSync(path.join(__dirname, '..', 'templates', 'dashboard.html'), 'utf-8');
44
+
45
+ // Migrate sync schema if in hub mode
46
+ if (config.sync?.mode === 'hub') {
47
+ migrateSyncSchema();
48
+ log(`${C.cyan}[sync]${C.reset} Hub mode enabled`);
49
+ }
41
50
 
42
51
  let currentRun = null; // { running: true, runId, report } or null
43
52
  let latestReport = null;
@@ -86,7 +95,7 @@ export async function startDashboard(config) {
86
95
  if (origin && allowedOrigins.includes(origin)) {
87
96
  res.setHeader('Access-Control-Allow-Origin', origin);
88
97
  }
89
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
98
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
90
99
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
91
100
 
92
101
  if (req.method === 'OPTIONS') {
@@ -96,6 +105,12 @@ export async function startDashboard(config) {
96
105
  }
97
106
 
98
107
  try {
108
+ // Handle sync routes if in hub mode
109
+ if (config.sync?.mode === 'hub' && pathname.startsWith('/api/sync')) {
110
+ const handled = await handleSyncRoutes(req, res, config, pathname);
111
+ if (handled) return;
112
+ }
113
+
99
114
  // Serve dashboard HTML
100
115
  if (pathname === '/' || pathname === '/index.html') {
101
116
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -105,9 +120,11 @@ export async function startDashboard(config) {
105
120
 
106
121
  // API: pool status + dashboard state
107
122
  if (pathname === '/api/status') {
108
- const poolStatus = await getPoolStatus(config.poolUrl);
123
+ const poolUrls = getPoolUrls(config);
124
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
109
125
  jsonResponse(res, {
110
- pool: poolStatus,
126
+ pool: aggregated,
127
+ poolUrls,
111
128
  dashboard: {
112
129
  running: currentRun?.running || false,
113
130
  wsClients: wss.clientCount,
@@ -115,8 +132,10 @@ export async function startDashboard(config) {
115
132
  config: {
116
133
  baseUrl: config.baseUrl,
117
134
  poolUrl: config.poolUrl,
135
+ poolUrls,
118
136
  concurrency: config.concurrency,
119
137
  testsDir: config.testsDir,
138
+ sync: config.sync || { mode: 'standalone' },
120
139
  },
121
140
  });
122
141
  return;
@@ -162,6 +181,17 @@ export async function startDashboard(config) {
162
181
  return;
163
182
  }
164
183
 
184
+ // API: DB — projects overview with sparklines (Watch view)
185
+ if (pathname === '/api/db/projects/overview') {
186
+ try {
187
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
188
+ jsonResponse(res, dbListProjectsWithSparklines(limit));
189
+ } catch (error) {
190
+ jsonResponse(res, { error: error.message }, 500);
191
+ }
192
+ return;
193
+ }
194
+
165
195
  // API: DB — runs for a project
166
196
  const projectRunsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/runs$/);
167
197
  if (projectRunsMatch) {
@@ -226,6 +256,88 @@ export async function startDashboard(config) {
226
256
  return;
227
257
  }
228
258
 
259
+ // API: DB — run insights (health + contextual insights)
260
+ const runInsightsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/insights$/);
261
+ if (runInsightsMatch) {
262
+ try {
263
+ const runDbId = parseInt(runInsightsMatch[1], 10);
264
+ const detail = dbGetRunDetail(runDbId);
265
+ if (!detail) { jsonResponse(res, { error: 'Run not found' }, 404); return; }
266
+
267
+ const projectId = detail.projectId || null;
268
+ const health = projectId ? getHealthSnapshot(projectId) : null;
269
+
270
+ // Build a minimal report object for getRunInsights
271
+ const results = (detail.results || []).map(r => ({
272
+ name: r.name,
273
+ success: r.success,
274
+ actions: r.actions || [],
275
+ }));
276
+ const insights = projectId ? getRunInsights(projectId, { results }) : [];
277
+
278
+ jsonResponse(res, { health, insights });
279
+ } catch (error) {
280
+ jsonResponse(res, { error: error.message }, 500);
281
+ }
282
+ return;
283
+ }
284
+
285
+ // API: DB — project health snapshot
286
+ const projectHealthMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/health$/);
287
+ if (projectHealthMatch) {
288
+ try {
289
+ const projectId = parseInt(projectHealthMatch[1], 10);
290
+ const health = getHealthSnapshot(projectId);
291
+ jsonResponse(res, health || {});
292
+ } catch (error) {
293
+ jsonResponse(res, { error: error.message }, 500);
294
+ }
295
+ return;
296
+ }
297
+
298
+ // API: DB — cross-project health snapshot
299
+ if (pathname === '/api/db/health') {
300
+ try {
301
+ const health = getHealthSnapshot(null);
302
+ jsonResponse(res, health || {});
303
+ } catch (error) {
304
+ jsonResponse(res, { error: error.message }, 500);
305
+ }
306
+ return;
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
+
229
341
  // API: DB — project screenshots list
230
342
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
231
343
  if (projectScreenshotsMatch) {
@@ -334,6 +446,57 @@ export async function startDashboard(config) {
334
446
  return;
335
447
  }
336
448
 
449
+ // API: DB — project variables (list)
450
+ const projectVarsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables$/);
451
+ if (projectVarsMatch && req.method === 'GET') {
452
+ try {
453
+ const projectId = parseInt(projectVarsMatch[1], 10);
454
+ jsonResponse(res, dbListVariables(projectId));
455
+ } catch (error) {
456
+ jsonResponse(res, { error: error.message }, 500);
457
+ }
458
+ return;
459
+ }
460
+
461
+ // API: DB — project variables (set/upsert)
462
+ if (projectVarsMatch && req.method === 'PUT') {
463
+ let body = '';
464
+ let oversize = false;
465
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
466
+ req.on('end', () => {
467
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
468
+ try {
469
+ const projectId = parseInt(projectVarsMatch[1], 10);
470
+ const { scope, key, value } = JSON.parse(body);
471
+ if (!key || value === undefined) { jsonResponse(res, { error: 'Missing key or value' }, 400); return; }
472
+ dbSetVariable(projectId, scope || 'project', key, value);
473
+ jsonResponse(res, { ok: true });
474
+ } catch (error) {
475
+ jsonResponse(res, { error: error.message }, 500);
476
+ }
477
+ });
478
+ return;
479
+ }
480
+
481
+ // API: DB — project variables (delete)
482
+ const varDeleteMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables\/([^/]+)\/([^/]+)$/);
483
+ if (varDeleteMatch && req.method === 'DELETE') {
484
+ try {
485
+ const projectId = parseInt(varDeleteMatch[1], 10);
486
+ const scope = decodeURIComponent(varDeleteMatch[2]);
487
+ const key = decodeURIComponent(varDeleteMatch[3]);
488
+ const deleted = dbDeleteVariable(projectId, scope, key);
489
+ if (deleted) {
490
+ jsonResponse(res, { ok: true });
491
+ } else {
492
+ jsonResponse(res, { error: 'Variable not found' }, 404);
493
+ }
494
+ } catch (error) {
495
+ jsonResponse(res, { error: error.message }, 500);
496
+ }
497
+ return;
498
+ }
499
+
337
500
  // API: DB — project learnings (summary or specific category)
338
501
  const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
339
502
  if (learningsMatch) {
@@ -368,6 +531,9 @@ export async function startDashboard(config) {
368
531
  case 'trends':
369
532
  data = getTestTrends(projectId, days);
370
533
  break;
534
+ case 'actions':
535
+ data = getActionHealthScores(projectId, days);
536
+ break;
371
537
  default:
372
538
  jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
373
539
  return;
@@ -379,6 +545,30 @@ export async function startDashboard(config) {
379
545
  return;
380
546
  }
381
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
+
382
572
  // API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
383
573
  const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
384
574
  if (ssHashMatch) {
@@ -533,7 +723,9 @@ export async function startDashboard(config) {
533
723
  if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
534
724
  try {
535
725
  const data = JSON.parse(body);
536
- bufferLiveEvent(data);
726
+ if (data.event !== 'test:frame') {
727
+ bufferLiveEvent(data);
728
+ }
537
729
  wss.broadcast(JSON.stringify(data));
538
730
  } catch { /* */ }
539
731
  jsonResponse(res, { ok: true });
@@ -581,8 +773,9 @@ export async function startDashboard(config) {
581
773
  }
582
774
  }, 30000);
583
775
 
776
+ const devOrigins = process.env.NODE_ENV === 'production' ? [] : ['http://localhost:5173', 'http://127.0.0.1:5173'];
584
777
  const wss = createWebSocketServer(server, {
585
- allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
778
+ allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`, ...devOrigins],
586
779
  onConnect(socket) {
587
780
  // Replay live state for new/reconnected clients
588
781
  for (const rid of Object.keys(liveEventBuffers)) {
@@ -593,11 +786,12 @@ export async function startDashboard(config) {
593
786
  },
594
787
  });
595
788
 
596
- // Pool status polling
789
+ // Pool status polling (aggregated across all pools)
790
+ const poolUrls = getPoolUrls(config);
597
791
  const pollInterval = setInterval(async () => {
598
792
  try {
599
- const status = await getPoolStatus(config.poolUrl);
600
- wss.broadcast(JSON.stringify({ event: 'pool:status', data: status }));
793
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
794
+ wss.broadcast(JSON.stringify({ event: 'pool:status', data: aggregated }));
601
795
  } catch { /* */ }
602
796
  }, 5000);
603
797
 
@@ -624,7 +818,8 @@ export async function startDashboard(config) {
624
818
  const projectCwd = dbGetProjectCwd(params.projectId);
625
819
  if (!projectCwd) throw new Error('Project not found');
626
820
  runConfig = await loadConfig({}, projectCwd);
627
- // Inherit pool URL from dashboard config (pool is shared)
821
+ // Inherit pool URLs from dashboard config (pool is shared)
822
+ runConfig._poolUrls = getPoolUrls(config);
628
823
  runConfig.poolUrl = config.poolUrl;
629
824
  } else {
630
825
  runConfig = { ...config };
@@ -633,26 +828,30 @@ export async function startDashboard(config) {
633
828
  runConfig.triggeredBy = 'dashboard';
634
829
  if (params.concurrency) runConfig.concurrency = params.concurrency;
635
830
  if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
831
+ if (params.screencast !== undefined) runConfig.screencast = params.screencast;
636
832
 
637
833
  // Wire up onProgress to broadcast WS events
638
834
  runConfig.onProgress = (data) => {
639
- bufferLiveEvent(data);
835
+ // Don't buffer screencast frames — they're ephemeral and high volume
836
+ if (data.event !== 'test:frame') {
837
+ bufferLiveEvent(data);
838
+ }
640
839
  wss.broadcast(JSON.stringify(data));
641
840
  };
642
841
 
643
842
  let tests, hooks;
644
843
  if (params.suite) {
645
- ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
844
+ ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir, runConfig.modulesDir));
646
845
  } else {
647
846
  ({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
648
847
  }
649
848
 
650
- await waitForPool(runConfig.poolUrl);
849
+ await waitForAnyPool(getPoolUrls(runConfig), 30000, { poolDriver: runConfig.poolDriver, maxSessions: runConfig.maxSessions });
651
850
  const results = await runTestsParallel(tests, runConfig, hooks || {});
652
851
  const report = generateReport(results);
653
852
  const suiteName = params.suite || null;
654
853
  saveReport(report, runConfig.screenshotsDir, runConfig);
655
- persistRun(report, runConfig, suiteName);
854
+ await persistRun(report, runConfig, suiteName);
656
855
  latestReport = report;
657
856
  currentRun = { running: false };
658
857
  } catch (error) {
@@ -662,7 +861,7 @@ export async function startDashboard(config) {
662
861
  }
663
862
 
664
863
  return new Promise((resolve, reject) => {
665
- const host = config.dashboardHost || '127.0.0.1';
864
+ const host = config.dashboardHost || process.env.DASHBOARD_HOST || '0.0.0.0';
666
865
 
667
866
  server.on('error', (err) => {
668
867
  if (err.code === 'EADDRINUSE') {
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),
@@ -231,6 +249,52 @@ function migrate(db) {
231
249
  );
232
250
  `);
233
251
 
252
+ // ── Variables table ──────────────────────────────────────────────────────────
253
+
254
+ db.exec(`
255
+ CREATE TABLE IF NOT EXISTS variables (
256
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
257
+ project_id INTEGER NOT NULL REFERENCES projects(id),
258
+ scope TEXT NOT NULL DEFAULT 'project',
259
+ key TEXT NOT NULL,
260
+ value TEXT NOT NULL,
261
+ created_at TEXT DEFAULT (datetime('now')),
262
+ updated_at TEXT DEFAULT (datetime('now')),
263
+ UNIQUE(project_id, scope, key)
264
+ );
265
+ CREATE INDEX IF NOT EXISTS idx_vars_project ON variables(project_id);
266
+ CREATE INDEX IF NOT EXISTS idx_vars_scope ON variables(project_id, scope);
267
+ `);
268
+
269
+ // Add pool_url column for multi-pool tracking
270
+ try {
271
+ db.prepare('SELECT pool_url FROM test_results LIMIT 0').run();
272
+ } catch {
273
+ db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
274
+ }
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
+
234
298
  // Migrations: add metadata columns to screenshot_hashes
235
299
  const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
236
300
  if (!ssColumns.includes('test_name')) {
@@ -312,18 +376,18 @@ export function getScreenshotHashes(filePaths) {
312
376
  }
313
377
 
314
378
  /** Save a run + its test results in a single transaction. Returns the run's DB id. */
315
- export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
379
+ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDriver) {
316
380
  const d = getDb();
317
381
  const { summary, results, generatedAt } = report;
318
382
 
319
383
  const insertRun = d.prepare(`
320
- INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
321
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322
386
  `);
323
387
 
324
388
  const insertTest = d.prepare(`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
327
391
  `);
328
392
 
329
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 (?, ?, ?, ?, ?, ?, ?, ?)');
@@ -340,6 +404,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
340
404
  generatedAt,
341
405
  suiteName || null,
342
406
  triggeredBy || null,
407
+ poolDriver || null,
343
408
  );
344
409
  const runDbId = runInfo.lastInsertRowid;
345
410
 
@@ -382,6 +447,11 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
382
447
  screenshots.length ? JSON.stringify(screenshots) : null,
383
448
  r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
384
449
  actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
450
+ r.poolUrl || null,
451
+ r.baselineScreenshot || null,
452
+ r.verificationScreenshot || null,
453
+ r.diffScreenshot || null,
454
+ r.visualDiff ? JSON.stringify(r.visualDiff) : null,
385
455
  );
386
456
 
387
457
  // Register screenshot hashes with metadata
@@ -397,6 +467,12 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
397
467
  if (r.verificationScreenshot) {
398
468
  insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
399
469
  }
470
+ if (r.baselineScreenshot) {
471
+ insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
472
+ }
473
+ if (r.diffScreenshot) {
474
+ insertHash.run(computeScreenshotHash(r.diffScreenshot), r.diffScreenshot, projectId, runDbId, r.name, null, null, 'diff');
475
+ }
400
476
  }
401
477
 
402
478
  return runDbId;
@@ -405,6 +481,33 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
405
481
  return tx();
406
482
  }
407
483
 
484
+ /** Save a run from sync (remote instance). Returns the run's DB id. */
485
+ export function persistRunFromSync({ projectId, runId, total, passed, failed, passRate, duration, generatedAt, suiteName, triggeredBy, syncInstanceId, syncOrigin }) {
486
+ const d = getDb();
487
+
488
+ const stmt = d.prepare(`
489
+ INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by, sync_instance_id, sync_origin, synced_at)
490
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
491
+ `);
492
+
493
+ const result = stmt.run(
494
+ projectId,
495
+ runId,
496
+ total,
497
+ passed,
498
+ failed,
499
+ passRate,
500
+ duration,
501
+ generatedAt,
502
+ suiteName || null,
503
+ triggeredBy || null,
504
+ syncInstanceId || null,
505
+ syncOrigin || 'remote'
506
+ );
507
+
508
+ return result.lastInsertRowid;
509
+ }
510
+
408
511
  /** List all projects with aggregated stats. */
409
512
  export function listProjects() {
410
513
  const d = getDb();
@@ -425,7 +528,7 @@ export function listProjects() {
425
528
  export function getProjectRuns(projectId, limit = 50, offset = 0) {
426
529
  const d = getDb();
427
530
  return d.prepare(`
428
- 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
429
532
  FROM runs
430
533
  WHERE project_id = ?
431
534
  ORDER BY generated_at DESC
@@ -448,6 +551,9 @@ export function getRunDetail(runDbId) {
448
551
  const ss = t.screenshots ? JSON.parse(t.screenshots) : [];
449
552
  allPaths.push(...ss);
450
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);
451
557
  }
452
558
  const hashMap = getScreenshotHashes(allPaths);
453
559
 
@@ -463,6 +569,7 @@ export function getRunDetail(runDbId) {
463
569
  generatedAt: run.generated_at,
464
570
  suiteName: run.suite_name,
465
571
  triggeredBy: run.triggered_by || null,
572
+ poolDriver: run.pool_driver || null,
466
573
  results: tests.map(t => {
467
574
  const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
468
575
  const testPaths = [...screenshots];
@@ -487,6 +594,11 @@ export function getRunDetail(runDbId) {
487
594
  networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
488
595
  actions: t.actions_json ? JSON.parse(t.actions_json) : [],
489
596
  screenshotHashes,
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,
490
602
  };
491
603
  }),
492
604
  };
@@ -497,7 +609,7 @@ export function getAllRuns(limit = 50, offset = 0) {
497
609
  const d = getDb();
498
610
  return d.prepare(`
499
611
  SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
500
- 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
501
613
  FROM runs r
502
614
  JOIN projects p ON p.id = r.project_id
503
615
  ORDER BY r.generated_at DESC
@@ -572,7 +684,79 @@ export function getNetworkLogs(runDbId, filters = {}) {
572
684
  return results;
573
685
  }
574
686
 
687
+ // ── Variables CRUD ────────────────────────────────────────────────────────────
688
+
689
+ /** Upsert a variable. Scope is 'project' or a suite name. */
690
+ export function setVariable(projectId, scope, key, value) {
691
+ const d = getDb();
692
+ d.prepare(`
693
+ INSERT INTO variables (project_id, scope, key, value, updated_at)
694
+ VALUES (?, ?, ?, ?, datetime('now'))
695
+ ON CONFLICT(project_id, scope, key)
696
+ DO UPDATE SET value = excluded.value, updated_at = datetime('now')
697
+ `).run(projectId, scope, key, value);
698
+ }
699
+
700
+ /** Get variables for a specific scope. Returns { key: value } map. */
701
+ export function getVariables(projectId, scope) {
702
+ const d = getDb();
703
+ const rows = d.prepare('SELECT key, value FROM variables WHERE project_id = ? AND scope = ?').all(projectId, scope);
704
+ const map = {};
705
+ for (const r of rows) map[r.key] = r.value;
706
+ return map;
707
+ }
708
+
709
+ /** Delete a variable. Returns true if deleted. */
710
+ export function deleteVariable(projectId, scope, key) {
711
+ const d = getDb();
712
+ const info = d.prepare('DELETE FROM variables WHERE project_id = ? AND scope = ? AND key = ?').run(projectId, scope, key);
713
+ return info.changes > 0;
714
+ }
715
+
716
+ /** List all variables for a project, grouped by scope. Returns { scope: { key: value } }. */
717
+ export function listVariables(projectId) {
718
+ const d = getDb();
719
+ const rows = d.prepare('SELECT scope, key, value FROM variables WHERE project_id = ? ORDER BY scope, key').all(projectId);
720
+ const grouped = {};
721
+ for (const r of rows) {
722
+ if (!grouped[r.scope]) grouped[r.scope] = {};
723
+ grouped[r.scope][r.key] = r.value;
724
+ }
725
+ return grouped;
726
+ }
727
+
575
728
  /** Close the database connection. */
729
+ /** Projects with sparkline data (last N run pass rates, oldest→newest). */
730
+ export function listProjectsWithSparklines(sparklineSize = 20) {
731
+ const d = getDb();
732
+ const projects = d.prepare(`
733
+ SELECT
734
+ p.id, p.cwd, p.name, p.screenshots_dir, p.tests_dir, p.created_at, p.updated_at,
735
+ COUNT(r.id) AS runCount,
736
+ MAX(r.generated_at) AS lastRunAt,
737
+ (SELECT r2.pass_rate FROM runs r2 WHERE r2.project_id = p.id ORDER BY r2.generated_at DESC LIMIT 1) AS lastPassRate
738
+ FROM projects p
739
+ LEFT JOIN runs r ON r.project_id = p.id
740
+ GROUP BY p.id
741
+ ORDER BY p.updated_at DESC
742
+ `).all();
743
+
744
+ const sparkStmt = d.prepare(`
745
+ SELECT CAST(pass_rate AS REAL) AS rate
746
+ FROM runs
747
+ WHERE project_id = ?
748
+ ORDER BY generated_at DESC
749
+ LIMIT ?
750
+ `);
751
+
752
+ return projects.map(p => {
753
+ const rawRates = sparkStmt.all(p.id, sparklineSize).map(r => r.rate);
754
+ // Reverse to oldest→newest
755
+ rawRates.reverse();
756
+ return { ...p, sparkline: rawRates };
757
+ });
758
+ }
759
+
576
760
  export function closeDb() {
577
761
  if (db) {
578
762
  db.close();