@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,527 @@
1
+ /**
2
+ * Shared MCP tool definitions and handlers.
3
+ *
4
+ * Used by both the stdio MCP server (src/mcp-server.js) and the
5
+ * Streamable HTTP transport mounted on the dashboard (src/dashboard.js).
6
+ *
7
+ * Kept in its own module so importing it does NOT trigger the
8
+ * console.log→stderr redirect that mcp-server.js applies.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import http from 'http';
14
+
15
+ import { loadConfig } from './config.js';
16
+ import { waitForPool, getPoolStatus } from './pool.js';
17
+ import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
18
+ import { generateReport, saveReport, persistRun } from './reporter.js';
19
+ import { startDashboard, stopDashboard } from './dashboard.js';
20
+ import { lookupScreenshotHash } from './db.js';
21
+ import { fetchIssue, checkCliAuth, detectProvider } from './issues.js';
22
+ import { buildPrompt, hasApiKey } from './ai-generate.js';
23
+ import { verifyIssue } from './verify.js';
24
+
25
+ // ── Tool definitions ──────────────────────────────────────────────────────────
26
+
27
+ export const TOOLS = [
28
+ {
29
+ name: 'e2e_run',
30
+ description:
31
+ 'Run E2E browser tests. Specify "all" to run every suite, "suite" for a specific suite, or "file" for a JSON file path. Returns structured results with pass/fail status, duration, and error details.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ all: {
36
+ type: 'boolean',
37
+ description: 'Run all test suites from the tests directory',
38
+ },
39
+ suite: {
40
+ type: 'string',
41
+ description: 'Suite name to run (e.g. "auth", "01-login"). Matches with or without numeric prefix.',
42
+ },
43
+ file: {
44
+ type: 'string',
45
+ description: 'Absolute or relative path to a JSON test file',
46
+ },
47
+ concurrency: {
48
+ type: 'number',
49
+ description: 'Number of parallel workers (default from config)',
50
+ },
51
+ baseUrl: {
52
+ type: 'string',
53
+ description: 'Override the base URL for this run',
54
+ },
55
+ retries: {
56
+ type: 'number',
57
+ description: 'Number of retries for failed tests',
58
+ },
59
+ cwd: {
60
+ type: 'string',
61
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
62
+ },
63
+ },
64
+ },
65
+ },
66
+ {
67
+ name: 'e2e_list',
68
+ description:
69
+ 'List all available E2E test suites with their test names and counts.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ cwd: {
74
+ type: 'string',
75
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
76
+ },
77
+ },
78
+ },
79
+ },
80
+ {
81
+ name: 'e2e_create_test',
82
+ description:
83
+ 'Create a new E2E test JSON file. Provide the suite name and an array of test objects, each with a name and actions array.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ name: {
88
+ type: 'string',
89
+ description: 'Suite file name without .json extension (e.g. "login", "05-checkout")',
90
+ },
91
+ tests: {
92
+ type: 'array',
93
+ description: 'Array of test objects with { name, actions }',
94
+ items: {
95
+ type: 'object',
96
+ properties: {
97
+ name: { type: 'string', description: 'Test name' },
98
+ actions: {
99
+ type: 'array',
100
+ description: 'Sequential browser actions',
101
+ items: {
102
+ type: 'object',
103
+ properties: {
104
+ type: {
105
+ type: 'string',
106
+ description: 'Action type: goto, click, type, wait, assert_text, assert_url, assert_visible, assert_count, screenshot, select, clear, press, scroll, hover, evaluate, navigate',
107
+ },
108
+ selector: { type: 'string', description: 'CSS selector' },
109
+ value: { type: 'string', description: 'Value for the action' },
110
+ text: { type: 'string', description: 'Text content to match' },
111
+ },
112
+ required: ['type'],
113
+ },
114
+ },
115
+ },
116
+ required: ['name', 'actions'],
117
+ },
118
+ },
119
+ hooks: {
120
+ type: 'object',
121
+ description: 'Optional hooks: beforeAll, afterAll, beforeEach, afterEach (each an array of actions)',
122
+ properties: {
123
+ beforeAll: { type: 'array', items: { type: 'object' } },
124
+ afterAll: { type: 'array', items: { type: 'object' } },
125
+ beforeEach: { type: 'array', items: { type: 'object' } },
126
+ afterEach: { type: 'array', items: { type: 'object' } },
127
+ },
128
+ },
129
+ cwd: {
130
+ type: 'string',
131
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
132
+ },
133
+ },
134
+ required: ['name', 'tests'],
135
+ },
136
+ },
137
+ {
138
+ name: 'e2e_pool_status',
139
+ description:
140
+ 'Get the status of the Chrome pool (browserless/chrome). Shows availability, running sessions, capacity, and queued requests.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ cwd: {
145
+ type: 'string',
146
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
147
+ },
148
+ },
149
+ },
150
+ },
151
+ {
152
+ name: 'e2e_screenshot',
153
+ description:
154
+ 'Retrieve a screenshot by its hash (e.g. ss:a3f2b1c9). Returns the image. Hashes are shown in the dashboard next to screenshots.',
155
+ inputSchema: {
156
+ type: 'object',
157
+ properties: {
158
+ hash: {
159
+ type: 'string',
160
+ description: 'Screenshot hash with or without ss: prefix (e.g. "ss:a3f2b1c9" or "a3f2b1c9")',
161
+ },
162
+ },
163
+ required: ['hash'],
164
+ },
165
+ },
166
+ {
167
+ name: 'e2e_dashboard_start',
168
+ description:
169
+ 'Start the E2E Runner web dashboard. Provides a real-time UI for running tests, viewing results, screenshots, history, and pool status.',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {
173
+ port: {
174
+ type: 'number',
175
+ description: 'Dashboard port (default 8484)',
176
+ },
177
+ cwd: {
178
+ type: 'string',
179
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
180
+ },
181
+ },
182
+ },
183
+ },
184
+ {
185
+ name: 'e2e_dashboard_stop',
186
+ description: 'Stop the E2E Runner web dashboard.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {},
190
+ },
191
+ },
192
+ {
193
+ name: 'e2e_issue',
194
+ description:
195
+ 'Fetch a GitHub/GitLab issue and prepare E2E test generation. Returns issue details and a prompt for test creation. Use mode "verify" to auto-generate and run tests (requires ANTHROPIC_API_KEY).',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {
199
+ url: {
200
+ type: 'string',
201
+ description: 'Issue URL (GitHub or GitLab)',
202
+ },
203
+ mode: {
204
+ type: 'string',
205
+ enum: ['prompt', 'verify'],
206
+ description:
207
+ 'prompt = return issue + prompt for Claude Code to create tests (default). verify = auto-generate tests via Claude API and run them.',
208
+ },
209
+ cwd: {
210
+ type: 'string',
211
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
212
+ },
213
+ },
214
+ required: ['url'],
215
+ },
216
+ },
217
+ ];
218
+
219
+ /** Tools exposed on the dashboard — excludes dashboard start/stop (already running). */
220
+ export const DASHBOARD_TOOLS = TOOLS.filter(
221
+ t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop'
222
+ );
223
+
224
+ // ── Dashboard broadcast helper ────────────────────────────────────────────────
225
+
226
+ function createDashboardBroadcaster(dashboardPort) {
227
+ const broadcaster = function broadcast(data) {
228
+ const body = JSON.stringify(data);
229
+ broadcaster._last = new Promise((resolve) => {
230
+ const req = http.request({
231
+ hostname: '127.0.0.1',
232
+ port: dashboardPort,
233
+ path: '/api/broadcast',
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
236
+ timeout: 1000,
237
+ });
238
+ req.on('error', () => resolve());
239
+ req.on('close', () => resolve());
240
+ req.end(body);
241
+ });
242
+ };
243
+ broadcaster._last = null;
244
+ broadcaster.flush = () => broadcaster._last || Promise.resolve();
245
+ return broadcaster;
246
+ }
247
+
248
+ async function detectDashboardPort() {
249
+ for (const port of [8484]) {
250
+ try {
251
+ const res = await fetch(`http://127.0.0.1:${port}/api/status`);
252
+ if (res.ok) return port;
253
+ } catch { /* not running */ }
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
259
+
260
+ async function handleRun(args) {
261
+ const configOverrides = {};
262
+ if (args.concurrency) configOverrides.concurrency = args.concurrency;
263
+ if (args.baseUrl) configOverrides.baseUrl = args.baseUrl;
264
+ if (args.retries !== undefined) configOverrides.retries = args.retries;
265
+
266
+ const config = await loadConfig(configOverrides, args.cwd);
267
+
268
+ await waitForPool(config.poolUrl);
269
+
270
+ let tests, hooks;
271
+
272
+ if (args.all) {
273
+ ({ tests, hooks } = loadAllSuites(config.testsDir));
274
+ } else if (args.suite) {
275
+ ({ tests, hooks } = loadTestSuite(args.suite, config.testsDir));
276
+ } else if (args.file) {
277
+ const cwd = args.cwd || process.cwd();
278
+ const filePath = path.isAbsolute(args.file) ? args.file : path.resolve(cwd, args.file);
279
+ ({ tests, hooks } = loadTestFile(filePath));
280
+ } else {
281
+ return errorResult('Provide one of: all (true), suite (name), or file (path)');
282
+ }
283
+
284
+ if (tests.length === 0) {
285
+ return errorResult('No tests found');
286
+ }
287
+
288
+ // Wire up live progress to dashboard if it's running
289
+ const dashboardPort = await detectDashboardPort();
290
+ if (dashboardPort) {
291
+ config.onProgress = createDashboardBroadcaster(dashboardPort);
292
+ }
293
+
294
+ const results = await runTestsParallel(tests, config, hooks || {});
295
+
296
+ // Flush the run:complete broadcast before building the response
297
+ if (config.onProgress?.flush) await config.onProgress.flush();
298
+
299
+ const report = generateReport(results);
300
+ saveReport(report, config.screenshotsDir, config);
301
+ persistRun(report, config, args.suite || null);
302
+
303
+ const failures = report.results
304
+ .filter(r => !r.success)
305
+ .map(r => ({
306
+ name: r.name,
307
+ error: r.error,
308
+ errorScreenshot: r.errorScreenshot || null,
309
+ }));
310
+
311
+ const flaky = report.results
312
+ .filter(r => r.success && r.attempt > 1)
313
+ .map(r => ({ name: r.name, attempts: r.attempt }));
314
+
315
+ const summary = {
316
+ ...report.summary,
317
+ reportPath: path.join(config.screenshotsDir, 'report.json'),
318
+ };
319
+
320
+ const consoleErrors = report.results
321
+ .filter(r => r.consoleLogs?.some(l => l.type === 'error' || l.type === 'warning'))
322
+ .map(r => ({ name: r.name, logs: r.consoleLogs.filter(l => l.type === 'error' || l.type === 'warning') }));
323
+ const networkErrors = report.results
324
+ .filter(r => r.networkErrors?.length > 0)
325
+ .map(r => ({ name: r.name, errors: r.networkErrors }));
326
+
327
+ if (flaky.length > 0) summary.flaky = flaky;
328
+ if (failures.length > 0) summary.failures = failures;
329
+ if (consoleErrors.length > 0) summary.consoleErrors = consoleErrors;
330
+ if (networkErrors.length > 0) summary.networkErrors = networkErrors;
331
+
332
+ return textResult(JSON.stringify(summary, null, 2));
333
+ }
334
+
335
+ async function handleList(args) {
336
+ const config = await loadConfig({}, args.cwd);
337
+ const suites = listSuites(config.testsDir);
338
+
339
+ if (suites.length === 0) {
340
+ return textResult('No test suites found in ' + config.testsDir);
341
+ }
342
+
343
+ const lines = suites.map(s =>
344
+ `${s.name} (${s.testCount} tests): ${s.tests.join(', ')}`
345
+ );
346
+
347
+ return textResult(lines.join('\n'));
348
+ }
349
+
350
+ async function handleCreateTest(args) {
351
+ const config = await loadConfig({}, args.cwd);
352
+
353
+ if (!fs.existsSync(config.testsDir)) {
354
+ fs.mkdirSync(config.testsDir, { recursive: true });
355
+ }
356
+
357
+ const safeName = path.basename(args.name);
358
+ const filename = safeName.endsWith('.json') ? safeName : `${safeName}.json`;
359
+ const filePath = path.join(config.testsDir, filename);
360
+
361
+ if (fs.existsSync(filePath)) {
362
+ return errorResult(`File already exists: ${filePath}`);
363
+ }
364
+
365
+ let content;
366
+ if (args.hooks && Object.values(args.hooks).some(h => h?.length > 0)) {
367
+ content = { hooks: args.hooks, tests: args.tests };
368
+ } else {
369
+ content = args.tests;
370
+ }
371
+
372
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
373
+ return textResult(`Created test file: ${filePath}\n\n${args.tests.length} test(s) defined.`);
374
+ }
375
+
376
+ async function handlePoolStatus(args) {
377
+ const config = await loadConfig({}, args.cwd);
378
+ const status = await getPoolStatus(config.poolUrl);
379
+
380
+ const lines = [
381
+ `Available: ${status.available ? 'yes' : 'no'}`,
382
+ `Running: ${status.running}/${status.maxConcurrent}`,
383
+ `Queued: ${status.queued}`,
384
+ `Sessions: ${status.sessions.length}`,
385
+ ];
386
+
387
+ if (status.error) {
388
+ lines.push(`Error: ${status.error}`);
389
+ }
390
+
391
+ return textResult(lines.join('\n'));
392
+ }
393
+
394
+ async function handleScreenshot(args) {
395
+ if (!args.hash) return errorResult('Missing required parameter: hash');
396
+
397
+ const row = lookupScreenshotHash(args.hash);
398
+ if (!row) return errorResult(`Screenshot not found for hash: ${args.hash}`);
399
+
400
+ if (!fs.existsSync(row.file_path)) {
401
+ return errorResult(`Screenshot file no longer exists: ${row.file_path}`);
402
+ }
403
+
404
+ const data = fs.readFileSync(row.file_path);
405
+ const base64 = data.toString('base64');
406
+ const ext = path.extname(row.file_path).toLowerCase();
407
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
408
+ const mimeType = mimeTypes[ext] || 'image/png';
409
+ const filename = path.basename(row.file_path);
410
+ const hash = row.hash;
411
+
412
+ return {
413
+ content: [
414
+ { type: 'text', text: `Screenshot ss:${hash} (${filename})` },
415
+ { type: 'image', data: base64, mimeType },
416
+ ],
417
+ };
418
+ }
419
+
420
+ async function handleIssue(args) {
421
+ if (!args.url) return errorResult('Missing required parameter: url');
422
+
423
+ const mode = args.mode || 'prompt';
424
+ const config = await loadConfig({}, args.cwd);
425
+
426
+ // Check provider and auth
427
+ let provider;
428
+ try {
429
+ provider = detectProvider(args.url);
430
+ } catch (err) {
431
+ return errorResult(err.message);
432
+ }
433
+
434
+ const auth = checkCliAuth(provider);
435
+ if (!auth.authenticated) {
436
+ return errorResult(auth.error);
437
+ }
438
+
439
+ if (mode === 'verify') {
440
+ if (!hasApiKey(config)) {
441
+ return errorResult('ANTHROPIC_API_KEY is required for verify mode. Set it as an environment variable.');
442
+ }
443
+
444
+ const result = await verifyIssue(args.url, config);
445
+ const status = result.bugConfirmed ? 'BUG CONFIRMED' : 'NOT REPRODUCIBLE';
446
+ const summary = {
447
+ status,
448
+ bugConfirmed: result.bugConfirmed,
449
+ issue: {
450
+ title: result.issue.title,
451
+ url: result.issue.url,
452
+ number: result.issue.number,
453
+ labels: result.issue.labels,
454
+ },
455
+ testResults: result.report.summary,
456
+ testsGenerated: result.tests.length,
457
+ suiteName: result.suiteName,
458
+ };
459
+
460
+ return textResult(JSON.stringify(summary, null, 2));
461
+ }
462
+
463
+ // Default: prompt mode
464
+ const issue = fetchIssue(args.url);
465
+ const promptData = buildPrompt(issue, config);
466
+
467
+ return textResult(promptData.prompt);
468
+ }
469
+
470
+ // Module-level state for stdio path only
471
+ let dashboardHandle = null;
472
+
473
+ async function handleDashboardStart(args) {
474
+ if (dashboardHandle) {
475
+ return errorResult('Dashboard is already running on port ' + dashboardHandle.port);
476
+ }
477
+ const overrides = {};
478
+ if (args.port) overrides.dashboardPort = args.port;
479
+ const config = await loadConfig(overrides, args.cwd);
480
+ dashboardHandle = await startDashboard(config);
481
+ return textResult(`Dashboard started at http://localhost:${dashboardHandle.port}`);
482
+ }
483
+
484
+ async function handleDashboardStop() {
485
+ if (!dashboardHandle) {
486
+ return errorResult('Dashboard is not running');
487
+ }
488
+ stopDashboard(dashboardHandle);
489
+ dashboardHandle = null;
490
+ return textResult('Dashboard stopped');
491
+ }
492
+
493
+ // ── Helpers ───────────────────────────────────────────────────────────────────
494
+
495
+ export function textResult(text) {
496
+ return { content: [{ type: 'text', text }] };
497
+ }
498
+
499
+ export function errorResult(message) {
500
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
501
+ }
502
+
503
+ // ── Dispatcher ────────────────────────────────────────────────────────────────
504
+
505
+ /** Routes a tool call to its handler. Used by both stdio and HTTP transports. */
506
+ export async function dispatchTool(name, args = {}) {
507
+ switch (name) {
508
+ case 'e2e_run':
509
+ return await handleRun(args);
510
+ case 'e2e_list':
511
+ return await handleList(args);
512
+ case 'e2e_create_test':
513
+ return await handleCreateTest(args);
514
+ case 'e2e_pool_status':
515
+ return await handlePoolStatus(args);
516
+ case 'e2e_screenshot':
517
+ return await handleScreenshot(args);
518
+ case 'e2e_dashboard_start':
519
+ return await handleDashboardStart(args);
520
+ case 'e2e_dashboard_stop':
521
+ return await handleDashboardStop();
522
+ case 'e2e_issue':
523
+ return await handleIssue(args);
524
+ default:
525
+ return errorResult(`Unknown tool: ${name}`);
526
+ }
527
+ }
package/src/reporter.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { colors as C } from './logger.js';
8
+ import { ensureProject, saveRun as saveRunToDb } from './db.js';
8
9
 
9
10
  function escapeXml(str) {
10
11
  return String(str)
@@ -14,6 +15,10 @@ function escapeXml(str) {
14
15
  .replace(/"/g, '"');
15
16
  }
16
17
 
18
+ function escapeCdata(str) {
19
+ return String(str).replace(/\]\]>/g, ']]]]><![CDATA[>');
20
+ }
21
+
17
22
  /** Generates a report object from test results */
18
23
  export function generateReport(results) {
19
24
  const passed = results.filter(r => r.success).length;
@@ -52,12 +57,12 @@ export function generateJUnitXML(report) {
52
57
 
53
58
  const logs = (result.consoleLogs || []).map(l => `[${l.type}] ${l.text}`).join('\n');
54
59
  if (logs) {
55
- xml += ` <system-out><![CDATA[${logs}]]></system-out>\n`;
60
+ xml += ` <system-out><![CDATA[${escapeCdata(logs)}]]></system-out>\n`;
56
61
  }
57
62
 
58
63
  const netErrors = (result.networkErrors || []).map(e => `[${e.error || 'unknown'}] ${e.url}`).join('\n');
59
64
  if (netErrors) {
60
- xml += ` <system-err><![CDATA[${netErrors}]]></system-err>\n`;
65
+ xml += ` <system-err><![CDATA[${escapeCdata(netErrors)}]]></system-err>\n`;
61
66
  }
62
67
 
63
68
  xml += ' </testcase>\n';
@@ -89,6 +94,72 @@ export function saveReport(report, screenshotsDir, config = {}) {
89
94
  return saved.length === 1 ? saved[0] : saved;
90
95
  }
91
96
 
97
+ /** Saves a run to history */
98
+ export function saveHistory(report, screenshotsDir, maxRuns = 100) {
99
+ const historyDir = path.join(screenshotsDir, 'history');
100
+ if (!fs.existsSync(historyDir)) {
101
+ fs.mkdirSync(historyDir, { recursive: true });
102
+ }
103
+
104
+ const runId = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '');
105
+ const filename = `run-${runId}.json`;
106
+ const entry = { ...report, runId };
107
+ fs.writeFileSync(path.join(historyDir, filename), JSON.stringify(entry, null, 2));
108
+
109
+ // Auto-prune old runs
110
+ const files = fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json')).sort();
111
+ while (files.length > maxRuns) {
112
+ fs.unlinkSync(path.join(historyDir, files.shift()));
113
+ }
114
+
115
+ return runId;
116
+ }
117
+
118
+ /** Loads history summaries (newest first) */
119
+ export function loadHistory(screenshotsDir) {
120
+ const historyDir = path.join(screenshotsDir, 'history');
121
+ if (!fs.existsSync(historyDir)) return [];
122
+
123
+ return fs.readdirSync(historyDir)
124
+ .filter(f => f.startsWith('run-') && f.endsWith('.json'))
125
+ .sort()
126
+ .reverse()
127
+ .map(f => {
128
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
129
+ return { runId: data.runId, summary: data.summary, generatedAt: data.generatedAt };
130
+ });
131
+ }
132
+
133
+ /** Loads a full history run by runId */
134
+ export function loadHistoryRun(screenshotsDir, runId) {
135
+ const historyDir = path.join(screenshotsDir, 'history');
136
+ const files = fs.existsSync(historyDir)
137
+ ? fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json'))
138
+ : [];
139
+
140
+ const match = files.find(f => {
141
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
142
+ return data.runId === runId;
143
+ });
144
+
145
+ if (!match) return null;
146
+ return JSON.parse(fs.readFileSync(path.join(historyDir, match), 'utf-8'));
147
+ }
148
+
149
+ /** Persists a run to both filesystem history and SQLite (never throws). */
150
+ export function persistRun(report, config, suiteName) {
151
+ const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
152
+
153
+ try {
154
+ const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
155
+ saveRunToDb(projectId, report, runId, suiteName || null);
156
+ } catch (err) {
157
+ process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
158
+ }
159
+
160
+ return runId;
161
+ }
162
+
92
163
  /** Prints a formatted report summary to the console */
93
164
  export function printReport(report, screenshotsDir) {
94
165
  const { summary } = report;