@matware/e2e-runner 1.0.3 → 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.
@@ -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
+ }