@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 +64 -6
- package/bin/cli.js +81 -1
- package/package.json +2 -2
- package/src/actions.js +11 -3
- package/src/ai-generate.js +35 -4
- package/src/config.js +30 -0
- package/src/dashboard.js +17 -4
- package/src/db.js +28 -7
- package/src/mcp-tools.js +131 -2
- package/src/reporter.js +13 -1
- package/src/runner.js +69 -3
- package/src/verify.js +16 -4
- package/templates/dashboard.html +248 -11
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
|
-
|
|
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.
|
|
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
|
-
|
|
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}`);
|
package/src/ai-generate.js
CHANGED
|
@@ -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:
|
|
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
|