@matware/e2e-runner 1.1.1 → 1.3.0

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 (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  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/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
package/src/dashboard.js CHANGED
@@ -14,12 +14,15 @@ 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, 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, getRunInsights, getHealthSnapshot } from './learner-sqlite.js';
24
+ import { handleSyncRoutes } from './sync/hub-routes.js';
25
+ import { migrateSyncSchema } from './sync/schema.js';
23
26
 
24
27
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
25
28
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -37,6 +40,12 @@ export async function startDashboard(config) {
37
40
  const port = config.dashboardPort || 8484;
38
41
  const MAX_BODY = 1024 * 1024; // 1MB limit for POST bodies
39
42
  const dashboardHtml = fs.readFileSync(path.join(__dirname, '..', 'templates', 'dashboard.html'), 'utf-8');
43
+
44
+ // Migrate sync schema if in hub mode
45
+ if (config.sync?.mode === 'hub') {
46
+ migrateSyncSchema();
47
+ log(`${C.cyan}[sync]${C.reset} Hub mode enabled`);
48
+ }
40
49
 
41
50
  let currentRun = null; // { running: true, runId, report } or null
42
51
  let latestReport = null;
@@ -85,7 +94,7 @@ export async function startDashboard(config) {
85
94
  if (origin && allowedOrigins.includes(origin)) {
86
95
  res.setHeader('Access-Control-Allow-Origin', origin);
87
96
  }
88
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
97
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
89
98
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
90
99
 
91
100
  if (req.method === 'OPTIONS') {
@@ -95,6 +104,12 @@ export async function startDashboard(config) {
95
104
  }
96
105
 
97
106
  try {
107
+ // Handle sync routes if in hub mode
108
+ if (config.sync?.mode === 'hub' && pathname.startsWith('/api/sync')) {
109
+ const handled = await handleSyncRoutes(req, res, config, pathname);
110
+ if (handled) return;
111
+ }
112
+
98
113
  // Serve dashboard HTML
99
114
  if (pathname === '/' || pathname === '/index.html') {
100
115
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -104,9 +119,11 @@ export async function startDashboard(config) {
104
119
 
105
120
  // API: pool status + dashboard state
106
121
  if (pathname === '/api/status') {
107
- const poolStatus = await getPoolStatus(config.poolUrl);
122
+ const poolUrls = getPoolUrls(config);
123
+ const aggregated = await getAggregatedPoolStatus(poolUrls);
108
124
  jsonResponse(res, {
109
- pool: poolStatus,
125
+ pool: aggregated,
126
+ poolUrls,
110
127
  dashboard: {
111
128
  running: currentRun?.running || false,
112
129
  wsClients: wss.clientCount,
@@ -114,8 +131,10 @@ export async function startDashboard(config) {
114
131
  config: {
115
132
  baseUrl: config.baseUrl,
116
133
  poolUrl: config.poolUrl,
134
+ poolUrls,
117
135
  concurrency: config.concurrency,
118
136
  testsDir: config.testsDir,
137
+ sync: config.sync || { mode: 'standalone' },
119
138
  },
120
139
  });
121
140
  return;
@@ -161,6 +180,17 @@ export async function startDashboard(config) {
161
180
  return;
162
181
  }
163
182
 
183
+ // API: DB — projects overview with sparklines (Watch view)
184
+ if (pathname === '/api/db/projects/overview') {
185
+ try {
186
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
187
+ jsonResponse(res, dbListProjectsWithSparklines(limit));
188
+ } catch (error) {
189
+ jsonResponse(res, { error: error.message }, 500);
190
+ }
191
+ return;
192
+ }
193
+
164
194
  // API: DB — runs for a project
165
195
  const projectRunsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/runs$/);
166
196
  if (projectRunsMatch) {
@@ -204,6 +234,77 @@ export async function startDashboard(config) {
204
234
  return;
205
235
  }
206
236
 
237
+ // API: DB — network logs for a run (filterable)
238
+ const networkLogsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/network-logs$/);
239
+ if (networkLogsMatch) {
240
+ try {
241
+ const runDbId = parseInt(networkLogsMatch[1], 10);
242
+ const filters = {};
243
+ if (url.searchParams.has('testName')) filters.testName = url.searchParams.get('testName');
244
+ if (url.searchParams.has('method')) filters.method = url.searchParams.get('method');
245
+ if (url.searchParams.has('statusMin')) filters.statusMin = parseInt(url.searchParams.get('statusMin'), 10);
246
+ if (url.searchParams.has('statusMax')) filters.statusMax = parseInt(url.searchParams.get('statusMax'), 10);
247
+ if (url.searchParams.has('urlPattern')) filters.urlPattern = url.searchParams.get('urlPattern');
248
+ if (url.searchParams.get('errorsOnly') === 'true') filters.errorsOnly = true;
249
+ if (url.searchParams.get('includeHeaders') === 'true') filters.includeHeaders = true;
250
+ if (url.searchParams.get('includeBodies') === 'true') filters.includeBodies = true;
251
+ jsonResponse(res, dbGetNetworkLogs(runDbId, filters));
252
+ } catch (error) {
253
+ jsonResponse(res, { error: error.message }, 500);
254
+ }
255
+ return;
256
+ }
257
+
258
+ // API: DB — run insights (health + contextual insights)
259
+ const runInsightsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/insights$/);
260
+ if (runInsightsMatch) {
261
+ try {
262
+ const runDbId = parseInt(runInsightsMatch[1], 10);
263
+ const detail = dbGetRunDetail(runDbId);
264
+ if (!detail) { jsonResponse(res, { error: 'Run not found' }, 404); return; }
265
+
266
+ const projectId = detail.projectId || null;
267
+ const health = projectId ? getHealthSnapshot(projectId) : null;
268
+
269
+ // Build a minimal report object for getRunInsights
270
+ const results = (detail.results || []).map(r => ({
271
+ name: r.name,
272
+ success: r.success,
273
+ actions: r.actions || [],
274
+ }));
275
+ const insights = projectId ? getRunInsights(projectId, { results }) : [];
276
+
277
+ jsonResponse(res, { health, insights });
278
+ } catch (error) {
279
+ jsonResponse(res, { error: error.message }, 500);
280
+ }
281
+ return;
282
+ }
283
+
284
+ // API: DB — project health snapshot
285
+ const projectHealthMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/health$/);
286
+ if (projectHealthMatch) {
287
+ try {
288
+ const projectId = parseInt(projectHealthMatch[1], 10);
289
+ const health = getHealthSnapshot(projectId);
290
+ jsonResponse(res, health || {});
291
+ } catch (error) {
292
+ jsonResponse(res, { error: error.message }, 500);
293
+ }
294
+ return;
295
+ }
296
+
297
+ // API: DB — cross-project health snapshot
298
+ if (pathname === '/api/db/health') {
299
+ try {
300
+ const health = getHealthSnapshot(null);
301
+ jsonResponse(res, health || {});
302
+ } catch (error) {
303
+ jsonResponse(res, { error: error.message }, 500);
304
+ }
305
+ return;
306
+ }
307
+
207
308
  // API: DB — project screenshots list
208
309
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
209
310
  if (projectScreenshotsMatch) {
@@ -239,6 +340,175 @@ export async function startDashboard(config) {
239
340
  return;
240
341
  }
241
342
 
343
+ // API: DB — suite detail (tests + actions)
344
+ const suiteDetailMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites\/(.+)$/);
345
+ if (suiteDetailMatch) {
346
+ try {
347
+ const projectId = parseInt(suiteDetailMatch[1], 10);
348
+ const suiteName = decodeURIComponent(suiteDetailMatch[2]);
349
+ const dir = dbGetProjectTestsDir(projectId);
350
+ if (!dir || !fs.existsSync(dir)) {
351
+ jsonResponse(res, { error: 'Tests directory not found' }, 404);
352
+ return;
353
+ }
354
+ const { tests, hooks } = loadTestSuite(suiteName, dir);
355
+ jsonResponse(res, { name: suiteName, tests, hooks });
356
+ } catch (error) {
357
+ jsonResponse(res, { error: error.message }, 500);
358
+ }
359
+ return;
360
+ }
361
+
362
+ // API: DB — cross-project learnings (when no project selected)
363
+ const crossLearningsMatch = pathname.match(/^\/api\/db\/learnings(?:\/(\w+))?$/);
364
+ if (crossLearningsMatch) {
365
+ try {
366
+ const category = crossLearningsMatch[1] || 'summary';
367
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
368
+ let data;
369
+ switch (category) {
370
+ case 'summary': {
371
+ const summary = getLearningsSummary(null);
372
+ const trends = getTestTrends(null, 7);
373
+ data = { ...summary, recentTrend: trends };
374
+ break;
375
+ }
376
+ default:
377
+ jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
378
+ return;
379
+ }
380
+ jsonResponse(res, data);
381
+ } catch (error) {
382
+ jsonResponse(res, { error: error.message }, 500);
383
+ }
384
+ return;
385
+ }
386
+
387
+ // API: DB — project modules list
388
+ const projectModulesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/modules$/);
389
+ if (projectModulesMatch) {
390
+ try {
391
+ const projectId = parseInt(projectModulesMatch[1], 10);
392
+ const projectCwd = dbGetProjectCwd(projectId);
393
+ if (!projectCwd) { jsonResponse(res, []); return; }
394
+ const modulesDir = path.join(projectCwd, 'e2e', 'modules');
395
+ if (!fs.existsSync(modulesDir)) { jsonResponse(res, []); return; }
396
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
397
+ const modules = files.map(f => {
398
+ try {
399
+ const data = JSON.parse(fs.readFileSync(path.join(modulesDir, f), 'utf-8'));
400
+ return {
401
+ name: f.replace('.json', ''),
402
+ file: f,
403
+ description: data.description || null,
404
+ params: data.params || [],
405
+ actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
406
+ };
407
+ } catch { return { name: f.replace('.json', ''), file: f, description: null, params: [], actionCount: 0 }; }
408
+ });
409
+ jsonResponse(res, modules);
410
+ } catch (error) {
411
+ jsonResponse(res, { error: error.message }, 500);
412
+ }
413
+ return;
414
+ }
415
+
416
+ // API: DB — project variables (list)
417
+ const projectVarsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables$/);
418
+ if (projectVarsMatch && req.method === 'GET') {
419
+ try {
420
+ const projectId = parseInt(projectVarsMatch[1], 10);
421
+ jsonResponse(res, dbListVariables(projectId));
422
+ } catch (error) {
423
+ jsonResponse(res, { error: error.message }, 500);
424
+ }
425
+ return;
426
+ }
427
+
428
+ // API: DB — project variables (set/upsert)
429
+ if (projectVarsMatch && req.method === 'PUT') {
430
+ let body = '';
431
+ let oversize = false;
432
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
433
+ req.on('end', () => {
434
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
435
+ try {
436
+ const projectId = parseInt(projectVarsMatch[1], 10);
437
+ const { scope, key, value } = JSON.parse(body);
438
+ if (!key || value === undefined) { jsonResponse(res, { error: 'Missing key or value' }, 400); return; }
439
+ dbSetVariable(projectId, scope || 'project', key, value);
440
+ jsonResponse(res, { ok: true });
441
+ } catch (error) {
442
+ jsonResponse(res, { error: error.message }, 500);
443
+ }
444
+ });
445
+ return;
446
+ }
447
+
448
+ // API: DB — project variables (delete)
449
+ const varDeleteMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/variables\/([^/]+)\/([^/]+)$/);
450
+ if (varDeleteMatch && req.method === 'DELETE') {
451
+ try {
452
+ const projectId = parseInt(varDeleteMatch[1], 10);
453
+ const scope = decodeURIComponent(varDeleteMatch[2]);
454
+ const key = decodeURIComponent(varDeleteMatch[3]);
455
+ const deleted = dbDeleteVariable(projectId, scope, key);
456
+ if (deleted) {
457
+ jsonResponse(res, { ok: true });
458
+ } else {
459
+ jsonResponse(res, { error: 'Variable not found' }, 404);
460
+ }
461
+ } catch (error) {
462
+ jsonResponse(res, { error: error.message }, 500);
463
+ }
464
+ return;
465
+ }
466
+
467
+ // API: DB — project learnings (summary or specific category)
468
+ const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
469
+ if (learningsMatch) {
470
+ try {
471
+ const projectId = parseInt(learningsMatch[1], 10);
472
+ const category = learningsMatch[2] || 'summary';
473
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
474
+
475
+ let data;
476
+ switch (category) {
477
+ case 'summary': {
478
+ const summary = getLearningsSummary(projectId);
479
+ const trends = getTestTrends(projectId, 7);
480
+ data = { ...summary, recentTrend: trends };
481
+ break;
482
+ }
483
+ case 'flaky':
484
+ data = getFlakySummary(projectId, days);
485
+ break;
486
+ case 'selectors':
487
+ data = getSelectorStability(projectId, days);
488
+ break;
489
+ case 'pages':
490
+ data = getPageHealth(projectId, days);
491
+ break;
492
+ case 'apis':
493
+ data = getApiHealth(projectId, days);
494
+ break;
495
+ case 'errors':
496
+ data = getErrorPatterns(projectId);
497
+ break;
498
+ case 'trends':
499
+ data = getTestTrends(projectId, days);
500
+ break;
501
+ default:
502
+ jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
503
+ return;
504
+ }
505
+ jsonResponse(res, data);
506
+ } catch (error) {
507
+ jsonResponse(res, { error: error.message }, 500);
508
+ }
509
+ return;
510
+ }
511
+
242
512
  // API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
243
513
  const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
244
514
  if (ssHashMatch) {
@@ -262,7 +532,7 @@ export async function startDashboard(config) {
262
532
  const ext = path.extname(realPath).toLowerCase();
263
533
  const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
264
534
  if (!mimeTypes[ext]) { jsonResponse(res, { error: 'Not an image' }, 400); return; }
265
- res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
535
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
266
536
  fs.createReadStream(realPath).pipe(res);
267
537
  } catch (error) {
268
538
  jsonResponse(res, { error: error.message }, 500);
@@ -302,7 +572,7 @@ export async function startDashboard(config) {
302
572
  jsonResponse(res, { error: 'Not an image' }, 400);
303
573
  return;
304
574
  }
305
- res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
575
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
306
576
  fs.createReadStream(realPath).pipe(res);
307
577
  return;
308
578
  }
@@ -347,7 +617,7 @@ export async function startDashboard(config) {
347
617
  return;
348
618
  }
349
619
  if (fs.existsSync(resolvedPath)) {
350
- res.writeHead(200, { 'Content-Type': imageMimeTypes[ext] });
620
+ res.writeHead(200, { 'Content-Type': imageMimeTypes[ext], 'Cache-Control': 'no-store' });
351
621
  fs.createReadStream(resolvedPath).pipe(res);
352
622
  } else {
353
623
  jsonResponse(res, { error: 'Not found' }, 404);
@@ -453,11 +723,12 @@ export async function startDashboard(config) {
453
723
  },
454
724
  });
455
725
 
456
- // Pool status polling
726
+ // Pool status polling (aggregated across all pools)
727
+ const poolUrls = getPoolUrls(config);
457
728
  const pollInterval = setInterval(async () => {
458
729
  try {
459
- const status = await getPoolStatus(config.poolUrl);
460
- wss.broadcast(JSON.stringify({ event: 'pool:status', data: status }));
730
+ const aggregated = await getAggregatedPoolStatus(poolUrls);
731
+ wss.broadcast(JSON.stringify({ event: 'pool:status', data: aggregated }));
461
732
  } catch { /* */ }
462
733
  }, 5000);
463
734
 
@@ -484,7 +755,8 @@ export async function startDashboard(config) {
484
755
  const projectCwd = dbGetProjectCwd(params.projectId);
485
756
  if (!projectCwd) throw new Error('Project not found');
486
757
  runConfig = await loadConfig({}, projectCwd);
487
- // Inherit pool URL from dashboard config (pool is shared)
758
+ // Inherit pool URLs from dashboard config (pool is shared)
759
+ runConfig._poolUrls = getPoolUrls(config);
488
760
  runConfig.poolUrl = config.poolUrl;
489
761
  } else {
490
762
  runConfig = { ...config };
@@ -504,15 +776,15 @@ export async function startDashboard(config) {
504
776
  if (params.suite) {
505
777
  ({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
506
778
  } else {
507
- ({ tests, hooks } = loadAllSuites(runConfig.testsDir));
779
+ ({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
508
780
  }
509
781
 
510
- await waitForPool(runConfig.poolUrl);
782
+ await waitForAnyPool(getPoolUrls(runConfig));
511
783
  const results = await runTestsParallel(tests, runConfig, hooks || {});
512
784
  const report = generateReport(results);
513
785
  const suiteName = params.suite || null;
514
786
  saveReport(report, runConfig.screenshotsDir, runConfig);
515
- persistRun(report, runConfig, suiteName);
787
+ await persistRun(report, runConfig, suiteName);
516
788
  latestReport = report;
517
789
  currentRun = { running: false };
518
790
  } catch (error) {
@@ -521,8 +793,18 @@ export async function startDashboard(config) {
521
793
  }
522
794
  }
523
795
 
524
- return new Promise((resolve) => {
525
- const host = config.dashboardHost || '127.0.0.1';
796
+ return new Promise((resolve, reject) => {
797
+ const host = config.dashboardHost || process.env.DASHBOARD_HOST || '0.0.0.0';
798
+
799
+ server.on('error', (err) => {
800
+ if (err.code === 'EADDRINUSE') {
801
+ log('❌', `${C.red}Port ${port} is already in use. Try a different port with --port <number>.${C.reset}`);
802
+ reject(new Error(`Port ${port} is already in use`));
803
+ } else {
804
+ reject(err);
805
+ }
806
+ });
807
+
526
808
  server.listen(port, host, () => {
527
809
  log('🖥️', `${C.bold}Dashboard${C.reset} running at ${C.cyan}http://${host}:${port}${C.reset}`);
528
810