@matware/e2e-runner 1.2.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 (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
package/src/config.js CHANGED
@@ -12,6 +12,22 @@ import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { pathToFileURL } from 'url';
14
14
 
15
+ /** Deep merge utility for nested config objects */
16
+ function deepMerge(...objects) {
17
+ const result = {};
18
+ for (const obj of objects) {
19
+ if (!obj || typeof obj !== 'object') continue;
20
+ for (const key of Object.keys(obj)) {
21
+ if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
22
+ result[key] = deepMerge(result[key] || {}, obj[key]);
23
+ } else if (obj[key] !== undefined) {
24
+ result[key] = obj[key];
25
+ }
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
15
31
  const DEFAULTS = {
16
32
  baseUrl: 'http://host.docker.internal:3000',
17
33
  poolUrl: 'ws://localhost:3333',
@@ -51,12 +67,65 @@ const DEFAULTS = {
51
67
  neo4jPassword: 'e2erunner',
52
68
  neo4jBoltPort: 7687,
53
69
  neo4jHttpPort: 7474,
70
+ verificationStrictness: 'moderate',
71
+ networkIgnoreDomains: [],
72
+ authLoginEndpoint: null,
73
+ authCredentials: null,
74
+ authTokenPath: 'token',
75
+ gqlEndpoint: '/api/graphql',
76
+ gqlAuthHeader: 'Authorization',
77
+ gqlAuthKey: 'accessToken',
78
+ gqlAuthPrefix: 'Bearer ',
79
+ poolUrls: null,
80
+ watchInterval: null,
81
+ watchRunOnStart: true,
82
+ watchGitPoll: false,
83
+ watchGitBranch: null,
84
+ watchGitInterval: '30s',
85
+ watchWebhookUrl: null,
86
+ watchWebhookEvents: 'failure',
87
+ watchProjects: null,
88
+
89
+ // Sync configuration
90
+ sync: {
91
+ mode: 'standalone', // 'standalone' | 'hub' | 'agent'
92
+ hub: {
93
+ port: null, // null = use dashboardPort
94
+ tls: {
95
+ enabled: false,
96
+ certPath: null,
97
+ keyPath: null,
98
+ mtls: false,
99
+ caPath: null,
100
+ },
101
+ allowRegistration: true,
102
+ requireApproval: false,
103
+ masterKeyEnv: 'E2E_SYNC_MASTER_KEY',
104
+ },
105
+ agent: {
106
+ hubUrl: null,
107
+ instanceId: null,
108
+ displayName: null,
109
+ apiKeyEnv: 'E2E_SYNC_API_KEY',
110
+ totpSecretEnv: 'E2E_SYNC_TOTP',
111
+ tls: {
112
+ certPath: null,
113
+ keyPath: null,
114
+ caPath: null,
115
+ },
116
+ autoSync: true,
117
+ pullOnDashboard: true,
118
+ offlineQueue: true,
119
+ queueRetryInterval: 60,
120
+ },
121
+ },
54
122
  };
55
123
 
56
124
  function loadEnvVars() {
57
125
  const env = {};
58
126
  if (process.env.BASE_URL) env.baseUrl = process.env.BASE_URL;
59
127
  if (process.env.CHROME_POOL_URL) env.poolUrl = process.env.CHROME_POOL_URL;
128
+ if (process.env.CHROME_POOL_URLS) env.poolUrls = process.env.CHROME_POOL_URLS.split(',').map(u => u.trim()).filter(Boolean);
60
129
  if (process.env.TESTS_DIR) env.testsDir = process.env.TESTS_DIR;
61
130
  if (process.env.MODULES_DIR) env.modulesDir = process.env.MODULES_DIR;
62
131
  if (process.env.SCREENSHOTS_DIR) env.screenshotsDir = process.env.SCREENSHOTS_DIR;
@@ -86,6 +155,61 @@ function loadEnvVars() {
86
155
  if (process.env.NEO4J_PASSWORD) env.neo4jPassword = process.env.NEO4J_PASSWORD;
87
156
  if (process.env.NEO4J_BOLT_PORT) env.neo4jBoltPort = parseInt(process.env.NEO4J_BOLT_PORT);
88
157
  if (process.env.NEO4J_HTTP_PORT) env.neo4jHttpPort = parseInt(process.env.NEO4J_HTTP_PORT);
158
+ if (process.env.NETWORK_IGNORE_DOMAINS) env.networkIgnoreDomains = process.env.NETWORK_IGNORE_DOMAINS.split(',').map(d => d.trim()).filter(Boolean);
159
+ if (process.env.AUTH_LOGIN_ENDPOINT) env.authLoginEndpoint = process.env.AUTH_LOGIN_ENDPOINT;
160
+ if (process.env.AUTH_TOKEN_PATH) env.authTokenPath = process.env.AUTH_TOKEN_PATH;
161
+ if (process.env.GQL_ENDPOINT) env.gqlEndpoint = process.env.GQL_ENDPOINT;
162
+ if (process.env.GQL_AUTH_HEADER) env.gqlAuthHeader = process.env.GQL_AUTH_HEADER;
163
+ if (process.env.GQL_AUTH_KEY) env.gqlAuthKey = process.env.GQL_AUTH_KEY;
164
+ if (process.env.GQL_AUTH_PREFIX) env.gqlAuthPrefix = process.env.GQL_AUTH_PREFIX;
165
+ if (process.env.WATCH_INTERVAL) env.watchInterval = process.env.WATCH_INTERVAL;
166
+ if (process.env.WATCH_WEBHOOK_URL) env.watchWebhookUrl = process.env.WATCH_WEBHOOK_URL;
167
+ if (process.env.WATCH_WEBHOOK_EVENTS) env.watchWebhookEvents = process.env.WATCH_WEBHOOK_EVENTS;
168
+ if (process.env.WATCH_GIT_POLL) env.watchGitPoll = process.env.WATCH_GIT_POLL === 'true' || process.env.WATCH_GIT_POLL === '1';
169
+ if (process.env.WATCH_GIT_BRANCH) env.watchGitBranch = process.env.WATCH_GIT_BRANCH;
170
+ if (process.env.WATCH_GIT_INTERVAL) env.watchGitInterval = process.env.WATCH_GIT_INTERVAL;
171
+ if (process.env.VERIFICATION_STRICTNESS) {
172
+ const val = process.env.VERIFICATION_STRICTNESS.toLowerCase();
173
+ if (['strict', 'moderate', 'lenient'].includes(val)) {
174
+ env.verificationStrictness = val;
175
+ }
176
+ }
177
+
178
+ // Sync configuration from env vars
179
+ if (process.env.E2E_SYNC_MODE) {
180
+ const mode = process.env.E2E_SYNC_MODE.toLowerCase();
181
+ if (['standalone', 'hub', 'agent'].includes(mode)) {
182
+ env.sync = env.sync || {};
183
+ env.sync.mode = mode;
184
+ }
185
+ }
186
+ if (process.env.E2E_SYNC_HUB_URL) {
187
+ env.sync = env.sync || {};
188
+ env.sync.agent = env.sync.agent || {};
189
+ env.sync.agent.hubUrl = process.env.E2E_SYNC_HUB_URL;
190
+ }
191
+ if (process.env.E2E_SYNC_INSTANCE_ID) {
192
+ env.sync = env.sync || {};
193
+ env.sync.agent = env.sync.agent || {};
194
+ env.sync.agent.instanceId = process.env.E2E_SYNC_INSTANCE_ID;
195
+ }
196
+ if (process.env.E2E_SYNC_DISPLAY_NAME) {
197
+ env.sync = env.sync || {};
198
+ env.sync.agent = env.sync.agent || {};
199
+ env.sync.agent.displayName = process.env.E2E_SYNC_DISPLAY_NAME;
200
+ }
201
+ if (process.env.E2E_SYNC_HUB_PORT) {
202
+ env.sync = env.sync || {};
203
+ env.sync.hub = env.sync.hub || {};
204
+ env.sync.hub.port = parseInt(process.env.E2E_SYNC_HUB_PORT);
205
+ }
206
+ if (process.env.E2E_SYNC_TLS_ENABLED) {
207
+ env.sync = env.sync || {};
208
+ env.sync.hub = env.sync.hub || {};
209
+ env.sync.hub.tls = env.sync.hub.tls || {};
210
+ env.sync.hub.tls.enabled = process.env.E2E_SYNC_TLS_ENABLED === 'true' || process.env.E2E_SYNC_TLS_ENABLED === '1';
211
+ }
212
+
89
213
  return env;
90
214
  }
91
215
 
@@ -142,6 +266,16 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
142
266
  ...envConfig,
143
267
  ...cliArgs,
144
268
  };
269
+
270
+ // Deep merge sync config (nested objects need special handling)
271
+ if (fileConfig.sync || envConfig.sync || cliArgs.sync) {
272
+ config.sync = deepMerge(
273
+ DEFAULTS.sync,
274
+ fileConfig.sync || {},
275
+ envConfig.sync || {},
276
+ cliArgs.sync || {}
277
+ );
278
+ }
145
279
 
146
280
  // Apply environment profile overrides
147
281
  if (config.env && config.env !== 'default' && config.environments?.[config.env]) {
@@ -172,5 +306,14 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
172
306
  config.projectName = path.basename(cwd);
173
307
  }
174
308
 
309
+ // Normalize pool URLs: poolUrls array → _poolUrls, keep poolUrl as primary
310
+ if (config.poolUrls && Array.isArray(config.poolUrls) && config.poolUrls.length > 0) {
311
+ config._poolUrls = config.poolUrls;
312
+ config.poolUrl = config.poolUrls[0];
313
+ } else {
314
+ config._poolUrls = [config.poolUrl];
315
+ }
316
+ delete config.poolUrls;
317
+
175
318
  return config;
176
319
  }
package/src/dashboard.js CHANGED
@@ -14,13 +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, 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 } from './learner-sqlite.js';
24
+ import { handleSyncRoutes } from './sync/hub-routes.js';
25
+ import { migrateSyncSchema } from './sync/schema.js';
24
26
 
25
27
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
26
28
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -38,6 +40,12 @@ export async function startDashboard(config) {
38
40
  const port = config.dashboardPort || 8484;
39
41
  const MAX_BODY = 1024 * 1024; // 1MB limit for POST bodies
40
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
+ }
41
49
 
42
50
  let currentRun = null; // { running: true, runId, report } or null
43
51
  let latestReport = null;
@@ -86,7 +94,7 @@ export async function startDashboard(config) {
86
94
  if (origin && allowedOrigins.includes(origin)) {
87
95
  res.setHeader('Access-Control-Allow-Origin', origin);
88
96
  }
89
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
97
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
90
98
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
91
99
 
92
100
  if (req.method === 'OPTIONS') {
@@ -96,6 +104,12 @@ export async function startDashboard(config) {
96
104
  }
97
105
 
98
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
+
99
113
  // Serve dashboard HTML
100
114
  if (pathname === '/' || pathname === '/index.html') {
101
115
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -105,9 +119,11 @@ export async function startDashboard(config) {
105
119
 
106
120
  // API: pool status + dashboard state
107
121
  if (pathname === '/api/status') {
108
- const poolStatus = await getPoolStatus(config.poolUrl);
122
+ const poolUrls = getPoolUrls(config);
123
+ const aggregated = await getAggregatedPoolStatus(poolUrls);
109
124
  jsonResponse(res, {
110
- pool: poolStatus,
125
+ pool: aggregated,
126
+ poolUrls,
111
127
  dashboard: {
112
128
  running: currentRun?.running || false,
113
129
  wsClients: wss.clientCount,
@@ -115,8 +131,10 @@ export async function startDashboard(config) {
115
131
  config: {
116
132
  baseUrl: config.baseUrl,
117
133
  poolUrl: config.poolUrl,
134
+ poolUrls,
118
135
  concurrency: config.concurrency,
119
136
  testsDir: config.testsDir,
137
+ sync: config.sync || { mode: 'standalone' },
120
138
  },
121
139
  });
122
140
  return;
@@ -162,6 +180,17 @@ export async function startDashboard(config) {
162
180
  return;
163
181
  }
164
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
+
165
194
  // API: DB — runs for a project
166
195
  const projectRunsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/runs$/);
167
196
  if (projectRunsMatch) {
@@ -226,6 +255,56 @@ export async function startDashboard(config) {
226
255
  return;
227
256
  }
228
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
+
229
308
  // API: DB — project screenshots list
230
309
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
231
310
  if (projectScreenshotsMatch) {
@@ -334,6 +413,57 @@ export async function startDashboard(config) {
334
413
  return;
335
414
  }
336
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
+
337
467
  // API: DB — project learnings (summary or specific category)
338
468
  const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
339
469
  if (learningsMatch) {
@@ -593,11 +723,12 @@ export async function startDashboard(config) {
593
723
  },
594
724
  });
595
725
 
596
- // Pool status polling
726
+ // Pool status polling (aggregated across all pools)
727
+ const poolUrls = getPoolUrls(config);
597
728
  const pollInterval = setInterval(async () => {
598
729
  try {
599
- const status = await getPoolStatus(config.poolUrl);
600
- 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 }));
601
732
  } catch { /* */ }
602
733
  }, 5000);
603
734
 
@@ -624,7 +755,8 @@ export async function startDashboard(config) {
624
755
  const projectCwd = dbGetProjectCwd(params.projectId);
625
756
  if (!projectCwd) throw new Error('Project not found');
626
757
  runConfig = await loadConfig({}, projectCwd);
627
- // Inherit pool URL from dashboard config (pool is shared)
758
+ // Inherit pool URLs from dashboard config (pool is shared)
759
+ runConfig._poolUrls = getPoolUrls(config);
628
760
  runConfig.poolUrl = config.poolUrl;
629
761
  } else {
630
762
  runConfig = { ...config };
@@ -647,12 +779,12 @@ export async function startDashboard(config) {
647
779
  ({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
648
780
  }
649
781
 
650
- await waitForPool(runConfig.poolUrl);
782
+ await waitForAnyPool(getPoolUrls(runConfig));
651
783
  const results = await runTestsParallel(tests, runConfig, hooks || {});
652
784
  const report = generateReport(results);
653
785
  const suiteName = params.suite || null;
654
786
  saveReport(report, runConfig.screenshotsDir, runConfig);
655
- persistRun(report, runConfig, suiteName);
787
+ await persistRun(report, runConfig, suiteName);
656
788
  latestReport = report;
657
789
  currentRun = { running: false };
658
790
  } catch (error) {
@@ -662,7 +794,7 @@ export async function startDashboard(config) {
662
794
  }
663
795
 
664
796
  return new Promise((resolve, reject) => {
665
- const host = config.dashboardHost || '127.0.0.1';
797
+ const host = config.dashboardHost || process.env.DASHBOARD_HOST || '0.0.0.0';
666
798
 
667
799
  server.on('error', (err) => {
668
800
  if (err.code === 'EADDRINUSE') {
package/src/db.js CHANGED
@@ -231,6 +231,30 @@ function migrate(db) {
231
231
  );
232
232
  `);
233
233
 
234
+ // ── Variables table ──────────────────────────────────────────────────────────
235
+
236
+ db.exec(`
237
+ CREATE TABLE IF NOT EXISTS variables (
238
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
239
+ project_id INTEGER NOT NULL REFERENCES projects(id),
240
+ scope TEXT NOT NULL DEFAULT 'project',
241
+ key TEXT NOT NULL,
242
+ value TEXT NOT NULL,
243
+ created_at TEXT DEFAULT (datetime('now')),
244
+ updated_at TEXT DEFAULT (datetime('now')),
245
+ UNIQUE(project_id, scope, key)
246
+ );
247
+ CREATE INDEX IF NOT EXISTS idx_vars_project ON variables(project_id);
248
+ CREATE INDEX IF NOT EXISTS idx_vars_scope ON variables(project_id, scope);
249
+ `);
250
+
251
+ // Add pool_url column for multi-pool tracking
252
+ try {
253
+ db.prepare('SELECT pool_url FROM test_results LIMIT 0').run();
254
+ } catch {
255
+ db.exec('ALTER TABLE test_results ADD COLUMN pool_url TEXT');
256
+ }
257
+
234
258
  // Migrations: add metadata columns to screenshot_hashes
235
259
  const ssColumns = db.pragma('table_info(screenshot_hashes)').map(c => c.name);
236
260
  if (!ssColumns.includes('test_name')) {
@@ -322,8 +346,8 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
322
346
  `);
323
347
 
324
348
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
327
351
  `);
328
352
 
329
353
  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 (?, ?, ?, ?, ?, ?, ?, ?)');
@@ -382,6 +406,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
382
406
  screenshots.length ? JSON.stringify(screenshots) : null,
383
407
  r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
384
408
  actionsCondensed.length ? JSON.stringify(actionsCondensed) : null,
409
+ r.poolUrl || null,
385
410
  );
386
411
 
387
412
  // Register screenshot hashes with metadata
@@ -397,6 +422,9 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
397
422
  if (r.verificationScreenshot) {
398
423
  insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId, r.name, null, null, 'verification');
399
424
  }
425
+ if (r.baselineScreenshot) {
426
+ insertHash.run(computeScreenshotHash(r.baselineScreenshot), r.baselineScreenshot, projectId, runDbId, r.name, null, null, 'baseline');
427
+ }
400
428
  }
401
429
 
402
430
  return runDbId;
@@ -405,6 +433,33 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
405
433
  return tx();
406
434
  }
407
435
 
436
+ /** Save a run from sync (remote instance). Returns the run's DB id. */
437
+ export function persistRunFromSync({ projectId, runId, total, passed, failed, passRate, duration, generatedAt, suiteName, triggeredBy, syncInstanceId, syncOrigin }) {
438
+ const d = getDb();
439
+
440
+ const stmt = d.prepare(`
441
+ 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)
442
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
443
+ `);
444
+
445
+ const result = stmt.run(
446
+ projectId,
447
+ runId,
448
+ total,
449
+ passed,
450
+ failed,
451
+ passRate,
452
+ duration,
453
+ generatedAt,
454
+ suiteName || null,
455
+ triggeredBy || null,
456
+ syncInstanceId || null,
457
+ syncOrigin || 'remote'
458
+ );
459
+
460
+ return result.lastInsertRowid;
461
+ }
462
+
408
463
  /** List all projects with aggregated stats. */
409
464
  export function listProjects() {
410
465
  const d = getDb();
@@ -487,6 +542,7 @@ export function getRunDetail(runDbId) {
487
542
  networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
488
543
  actions: t.actions_json ? JSON.parse(t.actions_json) : [],
489
544
  screenshotHashes,
545
+ poolUrl: t.pool_url || null,
490
546
  };
491
547
  }),
492
548
  };
@@ -572,7 +628,79 @@ export function getNetworkLogs(runDbId, filters = {}) {
572
628
  return results;
573
629
  }
574
630
 
631
+ // ── Variables CRUD ────────────────────────────────────────────────────────────
632
+
633
+ /** Upsert a variable. Scope is 'project' or a suite name. */
634
+ export function setVariable(projectId, scope, key, value) {
635
+ const d = getDb();
636
+ d.prepare(`
637
+ INSERT INTO variables (project_id, scope, key, value, updated_at)
638
+ VALUES (?, ?, ?, ?, datetime('now'))
639
+ ON CONFLICT(project_id, scope, key)
640
+ DO UPDATE SET value = excluded.value, updated_at = datetime('now')
641
+ `).run(projectId, scope, key, value);
642
+ }
643
+
644
+ /** Get variables for a specific scope. Returns { key: value } map. */
645
+ export function getVariables(projectId, scope) {
646
+ const d = getDb();
647
+ const rows = d.prepare('SELECT key, value FROM variables WHERE project_id = ? AND scope = ?').all(projectId, scope);
648
+ const map = {};
649
+ for (const r of rows) map[r.key] = r.value;
650
+ return map;
651
+ }
652
+
653
+ /** Delete a variable. Returns true if deleted. */
654
+ export function deleteVariable(projectId, scope, key) {
655
+ const d = getDb();
656
+ const info = d.prepare('DELETE FROM variables WHERE project_id = ? AND scope = ? AND key = ?').run(projectId, scope, key);
657
+ return info.changes > 0;
658
+ }
659
+
660
+ /** List all variables for a project, grouped by scope. Returns { scope: { key: value } }. */
661
+ export function listVariables(projectId) {
662
+ const d = getDb();
663
+ const rows = d.prepare('SELECT scope, key, value FROM variables WHERE project_id = ? ORDER BY scope, key').all(projectId);
664
+ const grouped = {};
665
+ for (const r of rows) {
666
+ if (!grouped[r.scope]) grouped[r.scope] = {};
667
+ grouped[r.scope][r.key] = r.value;
668
+ }
669
+ return grouped;
670
+ }
671
+
575
672
  /** Close the database connection. */
673
+ /** Projects with sparkline data (last N run pass rates, oldest→newest). */
674
+ export function listProjectsWithSparklines(sparklineSize = 20) {
675
+ const d = getDb();
676
+ const projects = d.prepare(`
677
+ SELECT
678
+ p.id, p.cwd, p.name, p.screenshots_dir, p.tests_dir, p.created_at, p.updated_at,
679
+ COUNT(r.id) AS runCount,
680
+ MAX(r.generated_at) AS lastRunAt,
681
+ (SELECT r2.pass_rate FROM runs r2 WHERE r2.project_id = p.id ORDER BY r2.generated_at DESC LIMIT 1) AS lastPassRate
682
+ FROM projects p
683
+ LEFT JOIN runs r ON r.project_id = p.id
684
+ GROUP BY p.id
685
+ ORDER BY p.updated_at DESC
686
+ `).all();
687
+
688
+ const sparkStmt = d.prepare(`
689
+ SELECT CAST(pass_rate AS REAL) AS rate
690
+ FROM runs
691
+ WHERE project_id = ?
692
+ ORDER BY generated_at DESC
693
+ LIMIT ?
694
+ `);
695
+
696
+ return projects.map(p => {
697
+ const rawRates = sparkStmt.all(p.id, sparklineSize).map(r => r.rate);
698
+ // Reverse to oldest→newest
699
+ rawRates.reverse();
700
+ return { ...p, sparkline: rawRates };
701
+ });
702
+ }
703
+
576
704
  export function closeDb() {
577
705
  if (db) {
578
706
  db.close();
package/src/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  export { loadConfig } from './config.js';
11
11
  export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
12
+ export { getPoolUrls, getAllPoolStatuses, getAggregatedPoolStatus, waitForAnyPool, selectPool, selectAndConnect } from './pool-manager.js';
12
13
  export { executeAction } from './actions.js';
13
14
  export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
14
15
  export { generateReport, generateJUnitXML, saveReport, printReport, saveHistory, loadHistory, loadHistoryRun } from './reporter.js';
@@ -18,13 +19,13 @@ export { buildPrompt, generateTests, hasApiKey } from './ai-generate.js';
18
19
  export { verifyIssue } from './verify.js';
19
20
  export { resolveTestData, loadModuleRegistry, listModules } from './module-resolver.js';
20
21
  export { learnFromRun, categorizeError } from './learner.js';
21
- export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights } from './learner-sqlite.js';
22
+ export { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestCreationContext, generateImprovements } from './learner-sqlite.js';
22
23
  export { generateLearningsMarkdown } from './learner-markdown.js';
23
24
  export { writeToGraph, queryGraph, closeNeo4j } from './learner-neo4j.js';
24
25
  export { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
25
26
 
26
27
  import { loadConfig } from './config.js';
27
- import { waitForPool } from './pool.js';
28
+ import { waitForAnyPool, getPoolUrls } from './pool-manager.js';
28
29
  import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites } from './runner.js';
29
30
  import { generateReport, saveReport, printReport } from './reporter.js';
30
31
 
@@ -41,7 +42,7 @@ export async function createRunner(userConfig = {}) {
41
42
 
42
43
  /** Runs all test suites from the tests directory */
43
44
  async runAll() {
44
- await waitForPool(config.poolUrl);
45
+ await waitForAnyPool(getPoolUrls(config));
45
46
  const { tests, hooks } = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
46
47
  const results = await runTestsParallel(tests, config, hooks);
47
48
  const report = generateReport(results);
@@ -52,7 +53,7 @@ export async function createRunner(userConfig = {}) {
52
53
 
53
54
  /** Runs a single suite by name */
54
55
  async runSuite(name) {
55
- await waitForPool(config.poolUrl);
56
+ await waitForAnyPool(getPoolUrls(config));
56
57
  const { tests, hooks } = loadTestSuite(name, config.testsDir, config.modulesDir);
57
58
  const results = await runTestsParallel(tests, config, hooks);
58
59
  const report = generateReport(results);
@@ -63,7 +64,7 @@ export async function createRunner(userConfig = {}) {
63
64
 
64
65
  /** Runs an array of test objects */
65
66
  async runTests(tests) {
66
- await waitForPool(config.poolUrl);
67
+ await waitForAnyPool(getPoolUrls(config));
67
68
  const results = await runTestsParallel(tests, config);
68
69
  const report = generateReport(results);
69
70
  saveReport(report, config.screenshotsDir, config);
@@ -73,7 +74,7 @@ export async function createRunner(userConfig = {}) {
73
74
 
74
75
  /** Runs tests from a JSON file path */
75
76
  async runFile(filePath) {
76
- await waitForPool(config.poolUrl);
77
+ await waitForAnyPool(getPoolUrls(config));
77
78
  const { tests, hooks } = loadTestFile(filePath, config.modulesDir);
78
79
  const results = await runTestsParallel(tests, config, hooks);
79
80
  const report = generateReport(results);