@matware/e2e-runner 1.1.0 → 1.1.1

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 CHANGED
@@ -37,18 +37,38 @@ JSON-driven E2E test runner. Define browser tests as simple JSON action arrays,
37
37
 
38
38
  ## Quick Start
39
39
 
40
+ **One-liner** (requires Node.js >= 20 and Docker):
41
+
42
+ ```bash
43
+ curl -fsSL https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/scripts/quickstart.sh | bash
44
+ ```
45
+
46
+ This checks prerequisites, installs the package, scaffolds the project, starts the Chrome pool, and runs the sample tests.
47
+
48
+ **Step by step:**
49
+
40
50
  ```bash
41
- # Install
42
- npm install @matware/e2e-runner
51
+ # 1. Install
52
+ npm install --save-dev @matware/e2e-runner
43
53
 
44
- # Scaffold project structure
54
+ # 2. Scaffold project structure
45
55
  npx e2e-runner init
46
56
 
47
- # Start Chrome pool (requires Docker)
57
+ # 3. Start Chrome pool (requires Docker)
48
58
  npx e2e-runner pool start
49
59
 
50
- # Run all tests
60
+ # 4. Run all tests
51
61
  npx e2e-runner run --all
62
+
63
+ # 5. Open the dashboard
64
+ npx e2e-runner dashboard
65
+ ```
66
+
67
+ **Add to Claude Code** (once, available in all projects):
68
+
69
+ ```bash
70
+ claude mcp add --transport stdio --scope user e2e-runner \
71
+ -- npx -y -p @matware/e2e-runner e2e-runner-mcp
52
72
  ```
53
73
 
54
74
  The `init` command creates:
@@ -436,7 +456,45 @@ e2e-runner dashboard # Start on default port 8484
436
456
  e2e-runner dashboard --port 9090 # Custom port
437
457
  ```
438
458
 
439
- Features: live test execution, screenshot viewer with copy-to-clipboard hashes (`ss:a3f2b1c9`), multi-project support via SQLite, run history with auto-pruning.
459
+ ### Live Execution
460
+
461
+ Monitor tests in real-time as they run. Each test shows its steps with individual durations, pass/fail status, and active connection count.
462
+
463
+ <p align="center">
464
+ <img src="https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/docs/screenshots/blog-dashboard-live-running.png" alt="Dashboard - Live test execution" width="900" />
465
+ </p>
466
+
467
+ ### Test Suites
468
+
469
+ Browse all test suites across multiple projects. Run a single suite or all tests with one click.
470
+
471
+ <p align="center">
472
+ <img src="https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/docs/screenshots/blog-dashboard-suites.png" alt="Dashboard - Test suites grid" width="900" />
473
+ </p>
474
+
475
+ ### Run History
476
+
477
+ Track pass rate trends over time with the bar chart. Click any row to expand the full run detail with per-test results, screenshots, and console errors.
478
+
479
+ <p align="center">
480
+ <img src="https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/docs/screenshots/blog-dashboard-runs.png" alt="Dashboard - Run history with trend chart" width="900" />
481
+ </p>
482
+
483
+ ### Run Detail
484
+
485
+ Expanded view shows each test with PASS/FAIL badge, screenshot thumbnails with copyable hashes (`ss:77c28b5a`), and formatted console errors.
486
+
487
+ <p align="center">
488
+ <img src="https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/docs/screenshots/blog-dashboard-run-detail.png" alt="Dashboard - Run detail with screenshot hashes" width="900" />
489
+ </p>
490
+
491
+ ### Screenshot Gallery
492
+
493
+ Browse all captured screenshots per project. Includes both manual captures and error screenshots.
494
+
495
+ <p align="center">
496
+ <img src="https://raw.githubusercontent.com/fastslack/mtw-e2e-runner/main/docs/screenshots/blog-dashboard-screenshots-gallery.png" alt="Dashboard - Screenshot gallery" width="900" />
497
+ </p>
440
498
 
441
499
  ## Architecture
442
500
 
package/bin/cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * e2e-runner pool status Show pool status
15
15
  * e2e-runner pool restart Restart the pool
16
16
  * e2e-runner dashboard Start the web dashboard
17
+ * e2e-runner capture <url> Capture a screenshot of any URL
17
18
  * e2e-runner issue <url> Fetch issue and show details
18
19
  * e2e-runner issue <url> --generate Generate test file via Claude API
19
20
  * e2e-runner issue <url> --verify Generate + run + report bug status
@@ -28,13 +29,14 @@ import path from 'path';
28
29
  import http from 'http';
29
30
  import { fileURLToPath } from 'url';
30
31
  import { loadConfig } from '../src/config.js';
31
- import { startPool, stopPool, restartPool, getPoolStatus, waitForPool } from '../src/pool.js';
32
+ import { startPool, stopPool, restartPool, getPoolStatus, waitForPool, connectToPool } from '../src/pool.js';
32
33
  import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from '../src/runner.js';
33
34
  import { generateReport, saveReport, printReport, persistRun } from '../src/reporter.js';
34
35
  import { startDashboard } from '../src/dashboard.js';
35
36
  import { fetchIssue } from '../src/issues.js';
36
37
  import { buildPrompt, generateTests, hasApiKey } from '../src/ai-generate.js';
37
38
  import { verifyIssue } from '../src/verify.js';
39
+ import { ensureProject, computeScreenshotHash, registerScreenshotHash } from '../src/db.js';
38
40
  import { log, colors as C } from '../src/logger.js';
39
41
 
40
42
  const __filename = fileURLToPath(import.meta.url);
@@ -72,6 +74,9 @@ function parseCLIConfig() {
72
74
  if (getFlag('--port')) cliArgs.dashboardPort = parseInt(getFlag('--port'));
73
75
  if (getFlag('--dashboard-port')) cliArgs.dashboardPort = parseInt(getFlag('--dashboard-port'));
74
76
  if (getFlag('--project-name')) cliArgs.projectName = getFlag('--project-name');
77
+ if (hasFlag('--fail-on-network-error')) cliArgs.failOnNetworkError = true;
78
+ if (getFlag('--auth-token')) cliArgs.authToken = getFlag('--auth-token');
79
+ if (getFlag('--auth-storage-key')) cliArgs.authStorageKey = getFlag('--auth-storage-key');
75
80
  return cliArgs;
76
81
  }
77
82
 
@@ -91,6 +96,12 @@ ${C.bold}Usage:${C.reset}
91
96
  e2e-runner dashboard Start the web dashboard
92
97
  e2e-runner dashboard --port <port> Custom port (default: 8484)
93
98
 
99
+ e2e-runner capture <url> Capture a screenshot of any URL
100
+ e2e-runner capture <url> --full-page Capture full scrollable page
101
+ e2e-runner capture <url> --selector <sel> Wait for selector before capture
102
+ e2e-runner capture <url> --delay <ms> Wait before capturing
103
+ e2e-runner capture <url> --filename <name> Custom filename
104
+
94
105
  e2e-runner issue <url> Fetch issue and show details
95
106
  e2e-runner issue <url> --generate Generate test file via Claude API
96
107
  e2e-runner issue <url> --verify Generate + run + report bug status
@@ -118,6 +129,7 @@ ${C.bold}Options:${C.reset}
118
129
  --output <format> Report format: json, junit, both (default: json)
119
130
  --env <name> Environment profile from config (default: default)
120
131
  --project-name <name> Project display name for dashboard (default: directory name)
132
+ --fail-on-network-error Fail tests when network requests fail (e.g. ERR_CONNECTION_REFUSED)
121
133
 
122
134
  ${C.bold}Config:${C.reset}
123
135
  Looks for e2e.config.js or e2e.config.json in the current directory.
@@ -128,6 +140,7 @@ ${C.bold}Config:${C.reset}
128
140
  async function cmdRun() {
129
141
  const cliArgs = parseCLIConfig();
130
142
  const config = await loadConfig(cliArgs);
143
+ config.triggeredBy = 'cli';
131
144
  let tests = [];
132
145
  let hooks = {};
133
146
 
@@ -351,6 +364,69 @@ async function cmdDashboard() {
351
364
  process.on('SIGTERM', shutdown);
352
365
  }
353
366
 
367
+ async function cmdCapture() {
368
+ const url = args[1];
369
+ if (!url || url.startsWith('--')) {
370
+ console.error(`${C.red}Usage: e2e-runner capture <url> [--filename <name>] [--full-page] [--selector <sel>] [--delay <ms>]${C.reset}`);
371
+ process.exit(1);
372
+ }
373
+
374
+ const cliArgs = parseCLIConfig();
375
+ const config = await loadConfig(cliArgs);
376
+
377
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
378
+
379
+ log('🔌', 'Checking Chrome Pool...');
380
+ await waitForPool(config.poolUrl);
381
+
382
+ let browser;
383
+ try {
384
+ browser = await connectToPool(config.poolUrl);
385
+ const page = await browser.newPage();
386
+ await page.setViewport(config.viewport);
387
+
388
+ log('📸', `Navigating to ${C.cyan}${url}${C.reset}`);
389
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
390
+
391
+ const selector = getFlag('--selector');
392
+ if (selector) {
393
+ log('⏳', `Waiting for selector: ${C.dim}${selector}${C.reset}`);
394
+ await page.waitForSelector(selector, { timeout: 10000 });
395
+ }
396
+
397
+ const delay = getFlag('--delay');
398
+ if (delay) {
399
+ await new Promise(r => setTimeout(r, parseInt(delay)));
400
+ }
401
+
402
+ // Build filename
403
+ let filename = getFlag('--filename') || `capture-${Date.now()}.png`;
404
+ filename = path.basename(filename);
405
+ if (!filename.endsWith('.png')) filename += '.png';
406
+
407
+ if (!fs.existsSync(config.screenshotsDir)) {
408
+ fs.mkdirSync(config.screenshotsDir, { recursive: true });
409
+ }
410
+
411
+ const screenshotPath = path.join(config.screenshotsDir, filename);
412
+ const fullPage = hasFlag('--full-page');
413
+ await page.screenshot({ path: screenshotPath, fullPage });
414
+
415
+ // Register hash in SQLite
416
+ const cwd = process.cwd();
417
+ const projectName = config.projectName || path.basename(cwd);
418
+ const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
419
+ const hash = computeScreenshotHash(screenshotPath);
420
+ registerScreenshotHash(hash, screenshotPath, projectId, null);
421
+
422
+ log('✅', `Saved: ${C.cyan}${screenshotPath}${C.reset}`);
423
+ log('🏷️', `Hash: ${C.bold}ss:${hash}${C.reset}`);
424
+ console.log('');
425
+ } finally {
426
+ if (browser) browser.disconnect();
427
+ }
428
+ }
429
+
354
430
  async function cmdIssue() {
355
431
  const url = args[1];
356
432
  if (!url || url.startsWith('--')) {
@@ -472,6 +548,10 @@ async function main() {
472
548
  await cmdDashboard();
473
549
  break;
474
550
 
551
+ case 'capture':
552
+ await cmdCapture();
553
+ break;
554
+
475
555
  case 'issue':
476
556
  await cmdIssue();
477
557
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matware/e2e-runner",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "mcpName": "io.github.fastslack/e2e-runner",
5
5
  "description": "E2E test runner using Chrome Pool (browserless/chrome) with parallel execution",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "license": "Apache-2.0",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "https://github.com/fastslack/mtw-e2e-runner.git"
36
+ "url": "git+https://github.com/fastslack/mtw-e2e-runner.git"
37
37
  },
38
38
  "homepage": "https://github.com/fastslack/mtw-e2e-runner#readme",
39
39
  "dependencies": {
package/src/actions.js CHANGED
@@ -162,10 +162,18 @@ export async function executeAction(page, action, config) {
162
162
  break;
163
163
  }
164
164
 
165
- case 'evaluate':
165
+ case 'evaluate': {
166
166
  // Intentional: runs JS in browser page context (from test JSON files)
167
- await page.evaluate(value);
168
- break;
167
+ const evalResult = await page.evaluate(value);
168
+ // Check return value for failure signals
169
+ if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
170
+ throw new Error(`evaluate failed: ${evalResult}`);
171
+ }
172
+ if (evalResult === false) {
173
+ throw new Error('evaluate returned false');
174
+ }
175
+ return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
176
+ }
169
177
 
170
178
  default:
171
179
  log('⚠️', `Unknown action: ${type}`);
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import fs from 'fs';
10
+ import path from 'path';
10
11
  import { listSuites } from './runner.js';
11
12
 
12
13
  const SYSTEM_PROMPT = `You are an E2E test generator for a JSON-driven browser test runner.
@@ -49,7 +50,23 @@ Rules:
49
50
  - For bug reports: write tests that assert the CORRECT behavior. If the test fails, the bug is confirmed
50
51
  - Keep test names descriptive and kebab-case
51
52
  - Prefer CSS selectors that are stable (data-testid, name, role) over fragile ones (nth-child, classes)
52
- - If the issue description is vague, create a reasonable test that covers the described scenario`;
53
+ - If the issue description is vague, create a reasonable test that covers the described scenario
54
+ - If project context is provided (from CLAUDE.md), use the REAL routes, selectors, and UI patterns described there — never invent routes or selectors`;
55
+
56
+ /**
57
+ * Reads the project's CLAUDE.md for app context (routes, selectors, UI structure).
58
+ * Returns the content or empty string if not found.
59
+ */
60
+ function loadProjectContext(cwd) {
61
+ if (!cwd) return '';
62
+ const claudeMdPath = path.join(cwd, 'CLAUDE.md');
63
+ if (!fs.existsSync(claudeMdPath)) return '';
64
+ try {
65
+ return fs.readFileSync(claudeMdPath, 'utf-8');
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
53
70
 
54
71
  /**
55
72
  * Returns a structured prompt + issue data for Claude Code to consume.
@@ -66,6 +83,11 @@ export function buildPrompt(issue, config) {
66
83
  existingSuites = listSuites(config.testsDir).map(s => s.name);
67
84
  } catch { /* no suites yet */ }
68
85
 
86
+ const projectContext = loadProjectContext(config._cwd);
87
+ const contextBlock = projectContext
88
+ ? `\n## Project Context (from CLAUDE.md)\nUse these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
89
+ : '';
90
+
69
91
  const prompt = `Based on the following issue, generate E2E test actions using the e2e_create_test tool.
70
92
 
71
93
  ## Issue: ${issue.title}
@@ -76,7 +98,7 @@ export function buildPrompt(issue, config) {
76
98
 
77
99
  ### Description
78
100
  ${issue.body || 'No description provided.'}
79
-
101
+ ${contextBlock}
80
102
  ## Instructions
81
103
  1. Analyze the issue and determine what user flows to test
82
104
  2. Create one or more tests that verify the expected behavior
@@ -128,6 +150,11 @@ export async function generateTests(issue, config) {
128
150
  const model = config.anthropicModel || 'claude-sonnet-4-5-20250929';
129
151
  const suiteName = `issue-${issue.number}`;
130
152
 
153
+ const projectContext = loadProjectContext(config._cwd);
154
+ const contextBlock = projectContext
155
+ ? `\n## Project Context (from CLAUDE.md)\nIMPORTANT: Use these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
156
+ : '';
157
+
131
158
  const userMessage = `Generate E2E tests for this issue:
132
159
 
133
160
  Title: ${issue.title}
@@ -137,7 +164,7 @@ State: ${issue.state}
137
164
 
138
165
  Description:
139
166
  ${issue.body || 'No description provided.'}
140
-
167
+ ${contextBlock}
141
168
  Base URL: ${config.baseUrl}
142
169
 
143
170
  Output a JSON array of test objects. Nothing else.`;
@@ -151,7 +178,7 @@ Output a JSON array of test objects. Nothing else.`;
151
178
  },
152
179
  body: JSON.stringify({
153
180
  model,
154
- max_tokens: 4096,
181
+ max_tokens: 16384,
155
182
  system: SYSTEM_PROMPT,
156
183
  messages: [{ role: 'user', content: userMessage }],
157
184
  }),
@@ -168,6 +195,10 @@ Output a JSON array of test objects. Nothing else.`;
168
195
  throw new Error('Claude API returned empty response');
169
196
  }
170
197
 
198
+ if (result.stop_reason === 'max_tokens') {
199
+ throw new Error(`Claude API response was truncated (hit max_tokens). The issue may be too complex. Try simplifying the issue description or increasing anthropicMaxTokens.`);
200
+ }
201
+
171
202
  // Parse JSON — strip markdown fences if present
172
203
  const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
173
204
  let tests;
package/src/config.js CHANGED
@@ -33,8 +33,11 @@ const DEFAULTS = {
33
33
  dashboardPort: 8484,
34
34
  maxHistoryRuns: 100,
35
35
  projectName: null,
36
+ failOnNetworkError: false,
36
37
  anthropicApiKey: null,
37
38
  anthropicModel: 'claude-sonnet-4-5-20250929',
39
+ authToken: null,
40
+ authStorageKey: 'accessToken',
38
41
  };
39
42
 
40
43
  function loadEnvVars() {
@@ -53,8 +56,11 @@ function loadEnvVars() {
53
56
  if (process.env.OUTPUT_FORMAT) env.outputFormat = process.env.OUTPUT_FORMAT;
54
57
  if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
55
58
  if (process.env.PROJECT_NAME) env.projectName = process.env.PROJECT_NAME;
59
+ if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
56
60
  if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
57
61
  if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
62
+ if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
63
+ if (process.env.AUTH_STORAGE_KEY) env.authStorageKey = process.env.AUTH_STORAGE_KEY;
58
64
  return env;
59
65
  }
60
66
 
@@ -76,8 +82,32 @@ async function loadConfigFile(cwd) {
76
82
  return {};
77
83
  }
78
84
 
85
+ /** Load .env file from cwd into process.env (no deps, KEY=VALUE format). */
86
+ function loadDotEnv(cwd) {
87
+ const envPath = path.join(cwd, '.env');
88
+ if (!fs.existsSync(envPath)) return;
89
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
90
+ for (const line of lines) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed || trimmed.startsWith('#')) continue;
93
+ const eqIdx = trimmed.indexOf('=');
94
+ if (eqIdx === -1) continue;
95
+ const key = trimmed.slice(0, eqIdx).trim();
96
+ let val = trimmed.slice(eqIdx + 1).trim();
97
+ // Strip surrounding quotes
98
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
99
+ val = val.slice(1, -1);
100
+ }
101
+ // Don't override existing env vars
102
+ if (!(key in process.env)) {
103
+ process.env[key] = val;
104
+ }
105
+ }
106
+ }
107
+
79
108
  export async function loadConfig(cliArgs = {}, cwd = null) {
80
109
  cwd = cwd || process.cwd();
110
+ loadDotEnv(cwd);
81
111
  const fileConfig = await loadConfigFile(cwd);
82
112
  const envConfig = loadEnvVars();
83
113
 
package/src/dashboard.js CHANGED
@@ -422,20 +422,31 @@ export async function startDashboard(config) {
422
422
  function bufferLiveEvent(data) {
423
423
  const rid = data.runId;
424
424
  if (!rid) return;
425
- if (data.event === 'run:start') liveEventBuffers[rid] = [];
426
- if (!liveEventBuffers[rid]) liveEventBuffers[rid] = [];
427
- liveEventBuffers[rid].push(data);
425
+ if (data.event === 'run:start') liveEventBuffers[rid] = { events: [], ts: Date.now() };
426
+ if (!liveEventBuffers[rid]) liveEventBuffers[rid] = { events: [], ts: Date.now() };
427
+ liveEventBuffers[rid].events.push(data);
428
+ liveEventBuffers[rid].ts = Date.now();
428
429
  if (data.event === 'run:complete' || data.event === 'run:error') {
429
430
  setTimeout(() => { delete liveEventBuffers[rid]; }, 30000);
430
431
  }
431
432
  }
432
433
 
434
+ // Purge stale live event buffers (runs that never completed, max 5 min)
435
+ const bufferPurgeInterval = setInterval(() => {
436
+ const maxAge = 5 * 60 * 1000;
437
+ for (const rid of Object.keys(liveEventBuffers)) {
438
+ if (Date.now() - liveEventBuffers[rid].ts > maxAge) {
439
+ delete liveEventBuffers[rid];
440
+ }
441
+ }
442
+ }, 30000);
443
+
433
444
  const wss = createWebSocketServer(server, {
434
445
  allowedOrigins: [`http://localhost:${port}`, `http://127.0.0.1:${port}`],
435
446
  onConnect(socket) {
436
447
  // Replay live state for new/reconnected clients
437
448
  for (const rid of Object.keys(liveEventBuffers)) {
438
- for (const evt of liveEventBuffers[rid]) {
449
+ for (const evt of liveEventBuffers[rid].events) {
439
450
  wss.sendTo(socket, JSON.stringify(evt));
440
451
  }
441
452
  }
@@ -479,6 +490,7 @@ export async function startDashboard(config) {
479
490
  runConfig = { ...config };
480
491
  }
481
492
 
493
+ runConfig.triggeredBy = 'dashboard';
482
494
  if (params.concurrency) runConfig.concurrency = params.concurrency;
483
495
  if (params.baseUrl) runConfig.baseUrl = params.baseUrl;
484
496
 
@@ -521,6 +533,7 @@ export async function startDashboard(config) {
521
533
  close() {
522
534
  clearInterval(pollInterval);
523
535
  clearInterval(dbPollInterval);
536
+ clearInterval(bufferPurgeInterval);
524
537
  wss.close();
525
538
  server.close();
526
539
  closeDb();
package/src/db.js CHANGED
@@ -100,6 +100,20 @@ function migrate(db) {
100
100
  db.exec('ALTER TABLE test_results ADD COLUMN screenshots TEXT');
101
101
  }
102
102
 
103
+ // Add network_logs column if upgrading from older schema
104
+ try {
105
+ db.prepare('SELECT network_logs FROM test_results LIMIT 0').run();
106
+ } catch {
107
+ db.exec('ALTER TABLE test_results ADD COLUMN network_logs TEXT');
108
+ }
109
+
110
+ // Add triggered_by column if upgrading from older schema
111
+ try {
112
+ db.prepare('SELECT triggered_by FROM runs LIMIT 0').run();
113
+ } catch {
114
+ db.exec('ALTER TABLE runs ADD COLUMN triggered_by TEXT');
115
+ }
116
+
103
117
  // Screenshot hashes table
104
118
  db.exec(`
105
119
  CREATE TABLE IF NOT EXISTS screenshot_hashes (
@@ -182,18 +196,18 @@ export function getScreenshotHashes(filePaths) {
182
196
  }
183
197
 
184
198
  /** Save a run + its test results in a single transaction. Returns the run's DB id. */
185
- export function saveRun(projectId, report, runId, suiteName) {
199
+ export function saveRun(projectId, report, runId, suiteName, triggeredBy) {
186
200
  const d = getDb();
187
201
  const { summary, results, generatedAt } = report;
188
202
 
189
203
  const insertRun = d.prepare(`
190
- INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name)
191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
204
+ INSERT INTO runs (project_id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by)
205
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192
206
  `);
193
207
 
194
208
  const insertTest = d.prepare(`
195
- INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots)
196
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
+ INSERT INTO test_results (run_id, name, success, error, start_time, end_time, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors, screenshots, network_logs)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
197
211
  `);
198
212
 
199
213
  const insertHash = d.prepare('INSERT OR IGNORE INTO screenshot_hashes (hash, file_path, project_id, run_id) VALUES (?, ?, ?, ?)');
@@ -209,6 +223,7 @@ export function saveRun(projectId, report, runId, suiteName) {
209
223
  summary.duration,
210
224
  generatedAt,
211
225
  suiteName || null,
226
+ triggeredBy || null,
212
227
  );
213
228
  const runDbId = runInfo.lastInsertRowid;
214
229
 
@@ -236,6 +251,7 @@ export function saveRun(projectId, report, runId, suiteName) {
236
251
  r.consoleLogs ? JSON.stringify(r.consoleLogs) : null,
237
252
  r.networkErrors ? JSON.stringify(r.networkErrors) : null,
238
253
  screenshots.length ? JSON.stringify(screenshots) : null,
254
+ r.networkLogs?.length ? JSON.stringify(r.networkLogs) : null,
239
255
  );
240
256
 
241
257
  // Register screenshot hashes
@@ -245,6 +261,9 @@ export function saveRun(projectId, report, runId, suiteName) {
245
261
  if (r.errorScreenshot) {
246
262
  insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId);
247
263
  }
264
+ if (r.verificationScreenshot) {
265
+ insertHash.run(computeScreenshotHash(r.verificationScreenshot), r.verificationScreenshot, projectId, runDbId);
266
+ }
248
267
  }
249
268
 
250
269
  return runDbId;
@@ -273,7 +292,7 @@ export function listProjects() {
273
292
  export function getProjectRuns(projectId, limit = 50, offset = 0) {
274
293
  const d = getDb();
275
294
  return d.prepare(`
276
- SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name
295
+ SELECT id, run_id, total, passed, failed, pass_rate, duration, generated_at, suite_name, triggered_by
277
296
  FROM runs
278
297
  WHERE project_id = ?
279
298
  ORDER BY generated_at DESC
@@ -310,6 +329,7 @@ export function getRunDetail(runDbId) {
310
329
  },
311
330
  generatedAt: run.generated_at,
312
331
  suiteName: run.suite_name,
332
+ triggeredBy: run.triggered_by || null,
313
333
  results: tests.map(t => {
314
334
  const screenshots = t.screenshots ? JSON.parse(t.screenshots) : [];
315
335
  const testPaths = [...screenshots];
@@ -331,6 +351,7 @@ export function getRunDetail(runDbId) {
331
351
  screenshots,
332
352
  consoleLogs: t.console_logs ? JSON.parse(t.console_logs) : [],
333
353
  networkErrors: t.network_errors ? JSON.parse(t.network_errors) : [],
354
+ networkLogs: t.network_logs ? JSON.parse(t.network_logs) : [],
334
355
  screenshotHashes,
335
356
  };
336
357
  }),
@@ -342,7 +363,7 @@ export function getAllRuns(limit = 50, offset = 0) {
342
363
  const d = getDb();
343
364
  return d.prepare(`
344
365
  SELECT r.id, r.run_id, r.total, r.passed, r.failed, r.pass_rate, r.duration,
345
- r.generated_at, r.suite_name, p.name AS project_name, p.id AS project_id
366
+ r.generated_at, r.suite_name, r.triggered_by, p.name AS project_name, p.id AS project_id
346
367
  FROM runs r
347
368
  JOIN projects p ON p.id = r.project_id
348
369
  ORDER BY r.generated_at DESC