@matware/e2e-runner 1.0.2 → 1.1.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/README.md +87 -3
- package/bin/cli.js +161 -1
- package/package.json +5 -2
- package/src/actions.js +17 -1
- package/src/ai-generate.js +185 -0
- package/src/config.js +14 -0
- package/src/dashboard.js +546 -0
- package/src/db.js +366 -0
- package/src/index.js +5 -1
- package/src/issues.js +152 -0
- package/src/mcp-server.js +8 -328
- package/src/mcp-tools.js +527 -0
- package/src/reporter.js +102 -1
- package/src/runner.js +60 -8
- package/src/verify.js +53 -0
- package/src/websocket.js +177 -0
- package/templates/dashboard.html +1044 -0
- package/templates/e2e.config.js +3 -0
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard — HTTP server, REST API, WebSocket broadcast, pool polling, test execution.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { startDashboard, stopDashboard } from './dashboard.js';
|
|
6
|
+
* const handle = await startDashboard(config);
|
|
7
|
+
* // ... later
|
|
8
|
+
* stopDashboard(handle);
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { createRequire } from 'module';
|
|
16
|
+
import { createWebSocketServer } from './websocket.js';
|
|
17
|
+
import { getPoolStatus, waitForPool } from './pool.js';
|
|
18
|
+
import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
|
|
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';
|
|
21
|
+
import { loadConfig } from './config.js';
|
|
22
|
+
import { log, colors as C } from './logger.js';
|
|
23
|
+
|
|
24
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
25
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
26
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
27
|
+
import { DASHBOARD_TOOLS, dispatchTool, errorResult } from './mcp-tools.js';
|
|
28
|
+
|
|
29
|
+
const _require = createRequire(import.meta.url);
|
|
30
|
+
const { version: VERSION } = _require('../package.json');
|
|
31
|
+
|
|
32
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
33
|
+
const __dirname = path.dirname(__filename);
|
|
34
|
+
|
|
35
|
+
/** Starts the dashboard server */
|
|
36
|
+
export async function startDashboard(config) {
|
|
37
|
+
const port = config.dashboardPort || 8484;
|
|
38
|
+
const MAX_BODY = 1024 * 1024; // 1MB limit for POST bodies
|
|
39
|
+
const dashboardHtml = fs.readFileSync(path.join(__dirname, '..', 'templates', 'dashboard.html'), 'utf-8');
|
|
40
|
+
|
|
41
|
+
let currentRun = null; // { running: true, runId, report } or null
|
|
42
|
+
let latestReport = null;
|
|
43
|
+
|
|
44
|
+
// Load latest report from disk if exists
|
|
45
|
+
const reportPath = path.join(config.screenshotsDir, 'report.json');
|
|
46
|
+
if (fs.existsSync(reportPath)) {
|
|
47
|
+
try { latestReport = JSON.parse(fs.readFileSync(reportPath, 'utf-8')); } catch { /* */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MCP helper: creates a fresh stateless transport+server per request
|
|
51
|
+
// (the SDK requires a new transport for each request in stateless mode)
|
|
52
|
+
async function handleMcpRequest(req, res) {
|
|
53
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
54
|
+
const mcpServer = new Server(
|
|
55
|
+
{ name: 'e2e-runner-dashboard', version: VERSION },
|
|
56
|
+
{ capabilities: { tools: {} } }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
60
|
+
tools: DASHBOARD_TOOLS,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
64
|
+
const { name, arguments: args = {} } = request.params;
|
|
65
|
+
try {
|
|
66
|
+
return await dispatchTool(name, args);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return errorResult(error.message);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await mcpServer.connect(transport);
|
|
73
|
+
await transport.handleRequest(req, res);
|
|
74
|
+
await transport.close();
|
|
75
|
+
await mcpServer.close();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const server = http.createServer(async (req, res) => {
|
|
79
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
80
|
+
const pathname = url.pathname;
|
|
81
|
+
|
|
82
|
+
// CORS — restrict to same-origin (localhost on dashboard port)
|
|
83
|
+
const allowedOrigins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
|
|
84
|
+
const origin = req.headers.origin;
|
|
85
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
86
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
87
|
+
}
|
|
88
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
89
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
|
|
90
|
+
|
|
91
|
+
if (req.method === 'OPTIONS') {
|
|
92
|
+
res.writeHead(204);
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Serve dashboard HTML
|
|
99
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
100
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
101
|
+
res.end(dashboardHtml);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// API: pool status + dashboard state
|
|
106
|
+
if (pathname === '/api/status') {
|
|
107
|
+
const poolStatus = await getPoolStatus(config.poolUrl);
|
|
108
|
+
jsonResponse(res, {
|
|
109
|
+
pool: poolStatus,
|
|
110
|
+
dashboard: {
|
|
111
|
+
running: currentRun?.running || false,
|
|
112
|
+
wsClients: wss.clientCount,
|
|
113
|
+
},
|
|
114
|
+
config: {
|
|
115
|
+
baseUrl: config.baseUrl,
|
|
116
|
+
poolUrl: config.poolUrl,
|
|
117
|
+
concurrency: config.concurrency,
|
|
118
|
+
testsDir: config.testsDir,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// API: list suites
|
|
125
|
+
if (pathname === '/api/suites') {
|
|
126
|
+
try {
|
|
127
|
+
const suites = listSuites(config.testsDir);
|
|
128
|
+
jsonResponse(res, suites);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// API: history
|
|
136
|
+
if (pathname === '/api/history') {
|
|
137
|
+
const history = loadHistory(config.screenshotsDir);
|
|
138
|
+
jsonResponse(res, history);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// API: history run detail
|
|
143
|
+
const historyMatch = pathname.match(/^\/api\/history\/(.+)$/);
|
|
144
|
+
if (historyMatch) {
|
|
145
|
+
const run = loadHistoryRun(config.screenshotsDir, decodeURIComponent(historyMatch[1]));
|
|
146
|
+
if (run) {
|
|
147
|
+
jsonResponse(res, run);
|
|
148
|
+
} else {
|
|
149
|
+
jsonResponse(res, { error: 'Run not found' }, 404);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// API: DB — list projects
|
|
155
|
+
if (pathname === '/api/db/projects') {
|
|
156
|
+
try {
|
|
157
|
+
jsonResponse(res, dbListProjects());
|
|
158
|
+
} catch (error) {
|
|
159
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// API: DB — runs for a project
|
|
165
|
+
const projectRunsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/runs$/);
|
|
166
|
+
if (projectRunsMatch) {
|
|
167
|
+
try {
|
|
168
|
+
const projectId = parseInt(projectRunsMatch[1], 10);
|
|
169
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
170
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
171
|
+
jsonResponse(res, dbGetProjectRuns(projectId, limit, offset));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// API: DB — all runs (cross-project)
|
|
179
|
+
if (pathname === '/api/db/runs') {
|
|
180
|
+
try {
|
|
181
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
182
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
183
|
+
jsonResponse(res, dbGetAllRuns(limit, offset));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// API: DB — run detail
|
|
191
|
+
const runDetailMatch = pathname.match(/^\/api\/db\/runs\/(\d+)$/);
|
|
192
|
+
if (runDetailMatch) {
|
|
193
|
+
try {
|
|
194
|
+
const runDbId = parseInt(runDetailMatch[1], 10);
|
|
195
|
+
const detail = dbGetRunDetail(runDbId);
|
|
196
|
+
if (detail) {
|
|
197
|
+
jsonResponse(res, detail);
|
|
198
|
+
} else {
|
|
199
|
+
jsonResponse(res, { error: 'Run not found' }, 404);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// API: DB — project screenshots list
|
|
208
|
+
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
209
|
+
if (projectScreenshotsMatch) {
|
|
210
|
+
try {
|
|
211
|
+
const projectId = parseInt(projectScreenshotsMatch[1], 10);
|
|
212
|
+
const dir = dbGetProjectScreenshotsDir(projectId);
|
|
213
|
+
if (!dir || !fs.existsSync(dir)) {
|
|
214
|
+
jsonResponse(res, []);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const files = fs.readdirSync(dir).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort();
|
|
218
|
+
jsonResponse(res, files.map(f => ({ name: f, path: path.join(dir, f) })));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// API: DB — project suites list
|
|
226
|
+
const projectSuitesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites$/);
|
|
227
|
+
if (projectSuitesMatch) {
|
|
228
|
+
try {
|
|
229
|
+
const projectId = parseInt(projectSuitesMatch[1], 10);
|
|
230
|
+
const dir = dbGetProjectTestsDir(projectId);
|
|
231
|
+
if (!dir || !fs.existsSync(dir)) {
|
|
232
|
+
jsonResponse(res, []);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
jsonResponse(res, listSuites(dir));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
|
|
243
|
+
const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
|
|
244
|
+
if (ssHashMatch) {
|
|
245
|
+
try {
|
|
246
|
+
const row = dbLookupScreenshotHash(ssHashMatch[1]);
|
|
247
|
+
if (!row) { jsonResponse(res, { error: 'Hash not found' }, 404); return; }
|
|
248
|
+
let realPath;
|
|
249
|
+
try { realPath = fs.realpathSync(row.file_path); } catch {
|
|
250
|
+
jsonResponse(res, { error: 'File not found' }, 404); return;
|
|
251
|
+
}
|
|
252
|
+
const allowedDirs = [path.resolve(config.screenshotsDir)];
|
|
253
|
+
try {
|
|
254
|
+
const projects = dbListProjects();
|
|
255
|
+
for (const p of projects) {
|
|
256
|
+
const dir = p.screenshots_dir || path.join(p.cwd, 'e2e', 'screenshots');
|
|
257
|
+
allowedDirs.push(path.resolve(dir));
|
|
258
|
+
}
|
|
259
|
+
} catch { /* */ }
|
|
260
|
+
const isAllowed = allowedDirs.some(dir => realPath.startsWith(dir + path.sep) || realPath === dir);
|
|
261
|
+
if (!isAllowed) { jsonResponse(res, { error: 'Access denied' }, 403); return; }
|
|
262
|
+
const ext = path.extname(realPath).toLowerCase();
|
|
263
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
264
|
+
if (!mimeTypes[ext]) { jsonResponse(res, { error: 'Not an image' }, 400); return; }
|
|
265
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
266
|
+
fs.createReadStream(realPath).pipe(res);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// API: serve image by absolute path (for cross-project screenshots)
|
|
274
|
+
if (pathname === '/api/image') {
|
|
275
|
+
const imgPath = url.searchParams.get('path');
|
|
276
|
+
if (!imgPath || !path.isAbsolute(imgPath)) {
|
|
277
|
+
jsonResponse(res, { error: 'Invalid path' }, 400);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Resolve real path (follows symlinks) and validate against known screenshot dirs
|
|
281
|
+
let realPath;
|
|
282
|
+
try { realPath = fs.realpathSync(imgPath); } catch {
|
|
283
|
+
jsonResponse(res, { error: 'Not found' }, 404);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const allowedDirs = [path.resolve(config.screenshotsDir)];
|
|
287
|
+
try {
|
|
288
|
+
const projects = dbListProjects();
|
|
289
|
+
for (const p of projects) {
|
|
290
|
+
const dir = p.screenshots_dir || path.join(p.cwd, 'e2e', 'screenshots');
|
|
291
|
+
allowedDirs.push(path.resolve(dir));
|
|
292
|
+
}
|
|
293
|
+
} catch { /* db may not be available */ }
|
|
294
|
+
const isAllowed = allowedDirs.some(dir => realPath.startsWith(dir + path.sep) || realPath === dir);
|
|
295
|
+
if (!isAllowed) {
|
|
296
|
+
jsonResponse(res, { error: 'Access denied' }, 403);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const ext = path.extname(realPath).toLowerCase();
|
|
300
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
301
|
+
if (!mimeTypes[ext]) {
|
|
302
|
+
jsonResponse(res, { error: 'Not an image' }, 400);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
306
|
+
fs.createReadStream(realPath).pipe(res);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// API: latest report
|
|
311
|
+
if (pathname === '/api/report/latest') {
|
|
312
|
+
if (latestReport) {
|
|
313
|
+
jsonResponse(res, latestReport);
|
|
314
|
+
} else {
|
|
315
|
+
jsonResponse(res, { error: 'No report available' }, 404);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// API: latest report as JUnit XML
|
|
321
|
+
if (pathname === '/api/report/junit') {
|
|
322
|
+
if (latestReport) {
|
|
323
|
+
const xml = generateJUnitXML(latestReport);
|
|
324
|
+
res.writeHead(200, { 'Content-Type': 'application/xml; charset=utf-8', 'Content-Disposition': 'attachment; filename="junit.xml"' });
|
|
325
|
+
res.end(xml);
|
|
326
|
+
} else {
|
|
327
|
+
jsonResponse(res, { error: 'No report available' }, 404);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// API: screenshots
|
|
333
|
+
const screenshotMatch = pathname.match(/^\/api\/screenshots\/(.+)$/);
|
|
334
|
+
if (screenshotMatch) {
|
|
335
|
+
const filename = decodeURIComponent(screenshotMatch[1]);
|
|
336
|
+
const resolvedPath = path.resolve(config.screenshotsDir, filename);
|
|
337
|
+
const screenshotsDirResolved = path.resolve(config.screenshotsDir);
|
|
338
|
+
// Validate resolved path stays within screenshotsDir
|
|
339
|
+
if (!resolvedPath.startsWith(screenshotsDirResolved + path.sep)) {
|
|
340
|
+
jsonResponse(res, { error: 'Invalid path' }, 400);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const imageMimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
344
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
345
|
+
if (!imageMimeTypes[ext]) {
|
|
346
|
+
jsonResponse(res, { error: 'Not an image' }, 400);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (fs.existsSync(resolvedPath)) {
|
|
350
|
+
res.writeHead(200, { 'Content-Type': imageMimeTypes[ext] });
|
|
351
|
+
fs.createReadStream(resolvedPath).pipe(res);
|
|
352
|
+
} else {
|
|
353
|
+
jsonResponse(res, { error: 'Not found' }, 404);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// API: list screenshot files
|
|
359
|
+
if (pathname === '/api/screenshots') {
|
|
360
|
+
const files = fs.existsSync(config.screenshotsDir)
|
|
361
|
+
? fs.readdirSync(config.screenshotsDir).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort()
|
|
362
|
+
: [];
|
|
363
|
+
jsonResponse(res, files);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// API: trigger run
|
|
368
|
+
if (pathname === '/api/run' && req.method === 'POST') {
|
|
369
|
+
if (currentRun?.running) {
|
|
370
|
+
jsonResponse(res, { error: 'A run is already in progress' }, 409);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let body = '';
|
|
375
|
+
let oversize = false;
|
|
376
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
|
|
377
|
+
req.on('end', () => {
|
|
378
|
+
if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
|
|
379
|
+
let params = {};
|
|
380
|
+
try { params = body ? JSON.parse(body) : {}; } catch { /* */ }
|
|
381
|
+
triggerRun(params);
|
|
382
|
+
jsonResponse(res, { status: 'started' });
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// API: broadcast event (used by external runners like MCP/CLI to send live progress)
|
|
388
|
+
if (pathname === '/api/broadcast' && req.method === 'POST') {
|
|
389
|
+
let body = '';
|
|
390
|
+
let oversize = false;
|
|
391
|
+
req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
|
|
392
|
+
req.on('end', () => {
|
|
393
|
+
if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
|
|
394
|
+
try {
|
|
395
|
+
const data = JSON.parse(body);
|
|
396
|
+
bufferLiveEvent(data);
|
|
397
|
+
wss.broadcast(JSON.stringify(data));
|
|
398
|
+
} catch { /* */ }
|
|
399
|
+
jsonResponse(res, { ok: true });
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// MCP Streamable HTTP transport
|
|
405
|
+
if (pathname === '/mcp') {
|
|
406
|
+
await handleMcpRequest(req, res);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 404
|
|
411
|
+
jsonResponse(res, { error: 'Not found' }, 404);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
process.stderr.write(`[dashboard] ${error.message}\n`);
|
|
414
|
+
jsonResponse(res, { error: 'Internal server error' }, 500);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Live event buffer — replayed to new WS clients so F5 restores the Live view
|
|
419
|
+
// Keyed by runId to support concurrent runs from different projects
|
|
420
|
+
const liveEventBuffers = {};
|
|
421
|
+
|
|
422
|
+
function bufferLiveEvent(data) {
|
|
423
|
+
const rid = data.runId;
|
|
424
|
+
if (!rid) return;
|
|
425
|
+
if (data.event === 'run:start') liveEventBuffers[rid] = [];
|
|
426
|
+
if (!liveEventBuffers[rid]) liveEventBuffers[rid] = [];
|
|
427
|
+
liveEventBuffers[rid].push(data);
|
|
428
|
+
if (data.event === 'run:complete' || data.event === 'run:error') {
|
|
429
|
+
setTimeout(() => { delete liveEventBuffers[rid]; }, 30000);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const wss = createWebSocketServer(server, {
|
|
434
|
+
allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
|
|
435
|
+
onConnect(socket) {
|
|
436
|
+
// Replay live state for new/reconnected clients
|
|
437
|
+
for (const rid of Object.keys(liveEventBuffers)) {
|
|
438
|
+
for (const evt of liveEventBuffers[rid]) {
|
|
439
|
+
wss.sendTo(socket, JSON.stringify(evt));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Pool status polling
|
|
446
|
+
const pollInterval = setInterval(async () => {
|
|
447
|
+
try {
|
|
448
|
+
const status = await getPoolStatus(config.poolUrl);
|
|
449
|
+
wss.broadcast(JSON.stringify({ event: 'pool:status', data: status }));
|
|
450
|
+
} catch { /* */ }
|
|
451
|
+
}, 5000);
|
|
452
|
+
|
|
453
|
+
// DB change detection — polls run count every 10s, broadcasts when new runs appear
|
|
454
|
+
let lastRunCount = 0;
|
|
455
|
+
try { lastRunCount = dbGetRunCount(); } catch { /* */ }
|
|
456
|
+
const dbPollInterval = setInterval(() => {
|
|
457
|
+
try {
|
|
458
|
+
const count = dbGetRunCount();
|
|
459
|
+
if (count !== lastRunCount) {
|
|
460
|
+
lastRunCount = count;
|
|
461
|
+
wss.broadcast(JSON.stringify({ event: 'db:updated' }));
|
|
462
|
+
}
|
|
463
|
+
} catch { /* */ }
|
|
464
|
+
}, 10000);
|
|
465
|
+
|
|
466
|
+
async function triggerRun(params) {
|
|
467
|
+
currentRun = { running: true };
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
// If a projectId is specified, load that project's config from its cwd
|
|
471
|
+
let runConfig;
|
|
472
|
+
if (params.projectId) {
|
|
473
|
+
const projectCwd = dbGetProjectCwd(params.projectId);
|
|
474
|
+
if (!projectCwd) throw new Error('Project not found');
|
|
475
|
+
runConfig = await loadConfig({}, projectCwd);
|
|
476
|
+
// Inherit pool URL from dashboard config (pool is shared)
|
|
477
|
+
runConfig.poolUrl = config.poolUrl;
|
|
478
|
+
} else {
|
|
479
|
+
runConfig = { ...config };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (params.concurrency) runConfig.concurrency = params.concurrency;
|
|
483
|
+
if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
|
|
484
|
+
|
|
485
|
+
// Wire up onProgress to broadcast WS events
|
|
486
|
+
runConfig.onProgress = (data) => {
|
|
487
|
+
bufferLiveEvent(data);
|
|
488
|
+
wss.broadcast(JSON.stringify(data));
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
let tests, hooks;
|
|
492
|
+
if (params.suite) {
|
|
493
|
+
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
|
|
494
|
+
} else {
|
|
495
|
+
({ tests, hooks } = loadAllSuites(runConfig.testsDir));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
await waitForPool(runConfig.poolUrl);
|
|
499
|
+
const results = await runTestsParallel(tests, runConfig, hooks || {});
|
|
500
|
+
const report = generateReport(results);
|
|
501
|
+
const suiteName = params.suite || null;
|
|
502
|
+
saveReport(report, runConfig.screenshotsDir, runConfig);
|
|
503
|
+
persistRun(report, runConfig, suiteName);
|
|
504
|
+
latestReport = report;
|
|
505
|
+
currentRun = { running: false };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
wss.broadcast(JSON.stringify({ event: 'run:error', error: error.message }));
|
|
508
|
+
currentRun = { running: false };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return new Promise((resolve) => {
|
|
513
|
+
const host = config.dashboardHost || '127.0.0.1';
|
|
514
|
+
server.listen(port, host, () => {
|
|
515
|
+
log('🖥️', `${C.bold}Dashboard${C.reset} running at ${C.cyan}http://${host}:${port}${C.reset}`);
|
|
516
|
+
|
|
517
|
+
const handle = {
|
|
518
|
+
server,
|
|
519
|
+
wss,
|
|
520
|
+
port,
|
|
521
|
+
close() {
|
|
522
|
+
clearInterval(pollInterval);
|
|
523
|
+
clearInterval(dbPollInterval);
|
|
524
|
+
wss.close();
|
|
525
|
+
server.close();
|
|
526
|
+
closeDb();
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
resolve(handle);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Stops the dashboard */
|
|
536
|
+
export function stopDashboard(handle) {
|
|
537
|
+
if (handle) {
|
|
538
|
+
handle.close();
|
|
539
|
+
log('🖥️', 'Dashboard stopped');
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function jsonResponse(res, data, status = 200) {
|
|
544
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
545
|
+
res.end(JSON.stringify(data));
|
|
546
|
+
}
|