@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.
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- 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 {
|
|
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
|
|
122
|
+
const poolUrls = getPoolUrls(config);
|
|
123
|
+
const aggregated = await getAggregatedPoolStatus(poolUrls);
|
|
108
124
|
jsonResponse(res, {
|
|
109
|
-
pool:
|
|
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
|
|
460
|
-
wss.broadcast(JSON.stringify({ event: 'pool:status', data:
|
|
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
|
|
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
|
|
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 || '
|
|
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
|
|