@matware/e2e-runner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @matware/e2e-runner CLI
5
+ *
6
+ * Commands:
7
+ * e2e-runner run --all Run all test suites
8
+ * e2e-runner run --suite <name> Run a specific suite
9
+ * e2e-runner run --tests <file.json> Run tests from a JSON file
10
+ * e2e-runner run --inline '<json>' Run inline JSON tests
11
+ * e2e-runner list List available suites
12
+ * e2e-runner pool start Start the Chrome Pool
13
+ * e2e-runner pool stop Stop the Chrome Pool
14
+ * e2e-runner pool status Show pool status
15
+ * e2e-runner pool restart Restart the pool
16
+ * e2e-runner init Scaffold e2e/ in the current project
17
+ * e2e-runner --help Show help
18
+ * e2e-runner --version Show version
19
+ */
20
+
21
+ import fs from 'fs';
22
+ import path from 'path';
23
+ import { fileURLToPath } from 'url';
24
+ import { loadConfig } from '../src/config.js';
25
+ import { startPool, stopPool, restartPool, getPoolStatus, waitForPool } from '../src/pool.js';
26
+ import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from '../src/runner.js';
27
+ import { generateReport, saveReport, printReport } from '../src/reporter.js';
28
+ import { log, colors as C } from '../src/logger.js';
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+ const pkgPath = path.join(__dirname, '..', 'package.json');
33
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
34
+
35
+ const args = process.argv.slice(2);
36
+
37
+ function getFlag(name) {
38
+ const idx = args.indexOf(name);
39
+ if (idx === -1) return null;
40
+ return args[idx + 1] || true;
41
+ }
42
+
43
+ function hasFlag(name) {
44
+ return args.includes(name);
45
+ }
46
+
47
+ function parseCLIConfig() {
48
+ const cliArgs = {};
49
+ if (getFlag('--base-url')) cliArgs.baseUrl = getFlag('--base-url');
50
+ if (getFlag('--pool-url')) cliArgs.poolUrl = getFlag('--pool-url');
51
+ if (getFlag('--tests-dir')) cliArgs.testsDir = getFlag('--tests-dir');
52
+ if (getFlag('--screenshots-dir')) cliArgs.screenshotsDir = getFlag('--screenshots-dir');
53
+ if (getFlag('--concurrency')) cliArgs.concurrency = parseInt(getFlag('--concurrency'));
54
+ if (getFlag('--pool-port')) cliArgs.poolPort = parseInt(getFlag('--pool-port'));
55
+ if (getFlag('--max-sessions')) cliArgs.maxSessions = parseInt(getFlag('--max-sessions'));
56
+ if (getFlag('--timeout')) cliArgs.defaultTimeout = parseInt(getFlag('--timeout'));
57
+ if (getFlag('--retries')) cliArgs.retries = parseInt(getFlag('--retries'));
58
+ if (getFlag('--retry-delay')) cliArgs.retryDelay = parseInt(getFlag('--retry-delay'));
59
+ if (getFlag('--test-timeout')) cliArgs.testTimeout = parseInt(getFlag('--test-timeout'));
60
+ if (getFlag('--output')) cliArgs.outputFormat = getFlag('--output');
61
+ if (getFlag('--env')) cliArgs.env = getFlag('--env');
62
+ return cliArgs;
63
+ }
64
+
65
+ function showHelp() {
66
+ console.log(`
67
+ ${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}
68
+ E2E test runner using Chrome Pool (browserless/chrome)
69
+
70
+ ${C.bold}Usage:${C.reset}
71
+ e2e-runner run --all Run all test suites
72
+ e2e-runner run --suite <name> Run a specific suite
73
+ e2e-runner run --tests <file.json> Run tests from a JSON file
74
+ e2e-runner run --inline '<json>' Run inline JSON tests
75
+
76
+ e2e-runner list List available suites
77
+
78
+ e2e-runner pool start Start the Chrome Pool
79
+ e2e-runner pool stop Stop the Chrome Pool
80
+ e2e-runner pool status Show pool status
81
+ e2e-runner pool restart Restart the Chrome Pool
82
+
83
+ e2e-runner init Scaffold e2e/ in the current project
84
+
85
+ ${C.bold}Options:${C.reset}
86
+ --base-url <url> App base URL (default: http://host.docker.internal:3000)
87
+ --pool-url <ws-url> Chrome Pool URL (default: ws://localhost:3333)
88
+ --tests-dir <dir> Tests directory (default: e2e/tests)
89
+ --screenshots-dir <dir> Screenshots directory (default: e2e/screenshots)
90
+ --concurrency <n> Parallel test workers (default: 3)
91
+ --pool-port <port> Chrome Pool port (default: 3333)
92
+ --max-sessions <n> Max pool sessions (default: 5)
93
+ --timeout <ms> Action timeout (default: 10000)
94
+ --retries <n> Retry failed tests N times (default: 0)
95
+ --retry-delay <ms> Delay between retries (default: 1000)
96
+ --test-timeout <ms> Per-test timeout (default: 60000)
97
+ --output <format> Report format: json, junit, both (default: json)
98
+ --env <name> Environment profile from config (default: default)
99
+
100
+ ${C.bold}Config:${C.reset}
101
+ Looks for e2e.config.js or e2e.config.json in the current directory.
102
+ Environment variables: BASE_URL, CHROME_POOL_URL, CONCURRENCY, etc.
103
+ `);
104
+ }
105
+
106
+ async function cmdRun() {
107
+ const cliArgs = parseCLIConfig();
108
+ const config = await loadConfig(cliArgs);
109
+ let tests = [];
110
+ let hooks = {};
111
+
112
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
113
+ console.log(`${C.dim}Pool: ${config.poolUrl} | Base: ${config.baseUrl} | Concurrency: ${config.concurrency}${C.reset}\n`);
114
+
115
+ if (hasFlag('--all')) {
116
+ const loaded = loadAllSuites(config.testsDir);
117
+ tests = loaded.tests;
118
+ hooks = loaded.hooks;
119
+ } else if (getFlag('--suite')) {
120
+ const name = getFlag('--suite');
121
+ const loaded = loadTestSuite(name, config.testsDir);
122
+ tests = loaded.tests;
123
+ hooks = loaded.hooks;
124
+ log('📋', `${C.cyan}${name}${C.reset} (${tests.length} tests)`);
125
+ } else if (getFlag('--tests')) {
126
+ const file = getFlag('--tests');
127
+ const loaded = loadTestFile(path.resolve(file));
128
+ tests = loaded.tests;
129
+ hooks = loaded.hooks;
130
+ log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
131
+ } else if (getFlag('--inline')) {
132
+ const data = JSON.parse(getFlag('--inline'));
133
+ if (Array.isArray(data)) {
134
+ tests = data;
135
+ } else {
136
+ tests = data.tests || [];
137
+ hooks = data.hooks || {};
138
+ }
139
+ } else {
140
+ console.error(`${C.red}No tests specified. Use --help to see available options.${C.reset}`);
141
+ process.exit(1);
142
+ }
143
+
144
+ if (tests.length === 0) {
145
+ console.error(`${C.red}No tests to run.${C.reset}`);
146
+ process.exit(1);
147
+ }
148
+
149
+ // Verify pool connectivity
150
+ log('🔌', 'Checking Chrome Pool...');
151
+ const pressure = await waitForPool(config.poolUrl);
152
+ log('✅', `Pool ready (${pressure.running}/${pressure.maxConcurrent} sessions, queued: ${pressure.queued})`);
153
+
154
+ // Execute tests
155
+ console.log('');
156
+ const results = await runTestsParallel(tests, config, hooks);
157
+ const report = generateReport(results);
158
+ saveReport(report, config.screenshotsDir, config);
159
+ printReport(report, config.screenshotsDir);
160
+
161
+ process.exit(report.summary.failed > 0 ? 1 : 0);
162
+ }
163
+
164
+ async function cmdList() {
165
+ const cliArgs = parseCLIConfig();
166
+ const config = await loadConfig(cliArgs);
167
+ const suites = listSuites(config.testsDir);
168
+
169
+ console.log(`\n${C.bold}Available suites:${C.reset}\n`);
170
+ for (const suite of suites) {
171
+ console.log(` ${C.cyan}${suite.name}${C.reset} (${suite.testCount} tests)`);
172
+ for (const test of suite.tests) {
173
+ console.log(` ${C.dim}- ${test}${C.reset}`);
174
+ }
175
+ }
176
+ console.log('');
177
+ }
178
+
179
+ async function cmdPool() {
180
+ const subCmd = args[1];
181
+ const cliArgs = parseCLIConfig();
182
+ const config = await loadConfig(cliArgs);
183
+
184
+ switch (subCmd) {
185
+ case 'start':
186
+ startPool(config);
187
+ break;
188
+
189
+ case 'stop':
190
+ stopPool(config);
191
+ break;
192
+
193
+ case 'restart':
194
+ restartPool(config);
195
+ break;
196
+
197
+ case 'status': {
198
+ const status = await getPoolStatus(config.poolUrl);
199
+ console.log(`\n${C.bold}Chrome Pool Status:${C.reset}\n`);
200
+ if (status.error) {
201
+ console.log(` ${C.red}Offline${C.reset}: ${status.error}`);
202
+ } else {
203
+ console.log(` Status: ${status.available ? `${C.green}Available${C.reset}` : `${C.red}Busy${C.reset}`}`);
204
+ console.log(` Running: ${status.running}/${status.maxConcurrent}`);
205
+ console.log(` Queued: ${status.queued}`);
206
+ console.log(` Sessions: ${status.sessions.length}`);
207
+ }
208
+ console.log('');
209
+ break;
210
+ }
211
+
212
+ default:
213
+ console.error(`${C.red}Unknown subcommand: ${subCmd}. Available: start, stop, restart, status${C.reset}`);
214
+ process.exit(1);
215
+ }
216
+ }
217
+
218
+ function cmdInit() {
219
+ const cwd = process.cwd();
220
+ const templatesDir = path.join(__dirname, '..', 'templates');
221
+
222
+ // Create directory structure
223
+ const dirs = [
224
+ path.join(cwd, 'e2e', 'tests'),
225
+ path.join(cwd, 'e2e', 'screenshots'),
226
+ ];
227
+
228
+ for (const dir of dirs) {
229
+ if (!fs.existsSync(dir)) {
230
+ fs.mkdirSync(dir, { recursive: true });
231
+ log('📁', `Created ${path.relative(cwd, dir)}/`);
232
+ }
233
+ }
234
+
235
+ // Copy config template
236
+ const configDest = path.join(cwd, 'e2e.config.js');
237
+ if (!fs.existsSync(configDest)) {
238
+ fs.copyFileSync(path.join(templatesDir, 'e2e.config.js'), configDest);
239
+ log('📄', 'Created e2e.config.js');
240
+ } else {
241
+ log('⏭️', 'e2e.config.js already exists, skipping');
242
+ }
243
+
244
+ // Copy sample test
245
+ const testDest = path.join(cwd, 'e2e', 'tests', 'sample.json');
246
+ if (!fs.existsSync(testDest)) {
247
+ fs.copyFileSync(path.join(templatesDir, 'sample-test.json'), testDest);
248
+ log('📄', 'Created e2e/tests/sample.json');
249
+ } else {
250
+ log('⏭️', 'e2e/tests/sample.json already exists, skipping');
251
+ }
252
+
253
+ // Create .gitkeep
254
+ const gitkeep = path.join(cwd, 'e2e', 'screenshots', '.gitkeep');
255
+ if (!fs.existsSync(gitkeep)) {
256
+ fs.writeFileSync(gitkeep, '');
257
+ }
258
+
259
+ // Update .gitignore
260
+ const gitignorePath = path.join(cwd, '.gitignore');
261
+ const ignoreLines = ['e2e/screenshots/*.png', 'e2e/screenshots/report.json', '.e2e-pool/'];
262
+ if (fs.existsSync(gitignorePath)) {
263
+ let content = fs.readFileSync(gitignorePath, 'utf-8');
264
+ let added = false;
265
+ for (const line of ignoreLines) {
266
+ if (!content.includes(line)) {
267
+ content += `\n${line}`;
268
+ added = true;
269
+ }
270
+ }
271
+ if (added) {
272
+ fs.writeFileSync(gitignorePath, content + '\n');
273
+ log('📄', 'Updated .gitignore');
274
+ }
275
+ } else {
276
+ fs.writeFileSync(gitignorePath, ignoreLines.join('\n') + '\n');
277
+ log('📄', 'Created .gitignore');
278
+ }
279
+
280
+ console.log(`
281
+ ${C.bold}${C.green}E2E structure created!${C.reset}
282
+
283
+ ${C.bold}Next steps:${C.reset}
284
+ 1. Edit ${C.cyan}e2e.config.js${C.reset} with your app URL
285
+ 2. Edit ${C.cyan}e2e/tests/sample.json${C.reset} with your tests
286
+ 3. Start the pool: ${C.cyan}e2e-runner pool start${C.reset}
287
+ 4. Run your tests: ${C.cyan}e2e-runner run --all${C.reset}
288
+ `);
289
+ }
290
+
291
+ // ==================== Main ====================
292
+
293
+ async function main() {
294
+ if (args.length === 0 || hasFlag('--help') || hasFlag('-h')) {
295
+ showHelp();
296
+ process.exit(0);
297
+ }
298
+
299
+ if (hasFlag('--version') || hasFlag('-v')) {
300
+ console.log(pkg.version);
301
+ process.exit(0);
302
+ }
303
+
304
+ const command = args[0];
305
+
306
+ switch (command) {
307
+ case 'run':
308
+ await cmdRun();
309
+ break;
310
+
311
+ case 'list':
312
+ await cmdList();
313
+ break;
314
+
315
+ case 'pool':
316
+ await cmdPool();
317
+ break;
318
+
319
+ case 'init':
320
+ cmdInit();
321
+ break;
322
+
323
+ default:
324
+ console.error(`${C.red}Unknown command: ${command}. Use --help to see available options.${C.reset}`);
325
+ process.exit(1);
326
+ }
327
+ }
328
+
329
+ main().catch(error => {
330
+ console.error(`${C.red}Fatal error: ${error.message}${C.reset}`);
331
+ process.exit(1);
332
+ });
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCP Server entry point for @matware/e2e-runner
5
+ *
6
+ * Install in Claude Code (once, available everywhere):
7
+ * claude mcp add --transport stdio --scope user e2e-runner -- npx -p @matware/e2e-runner e2e-runner-mcp
8
+ */
9
+
10
+ import { startMcpServer } from '../src/mcp-server.js';
11
+
12
+ startMcpServer().catch((error) => {
13
+ process.stderr.write(`MCP server error: ${error.message}\n`);
14
+ process.exit(1);
15
+ });
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@matware/e2e-runner",
3
+ "version": "1.0.0",
4
+ "description": "E2E test runner using Chrome Pool (browserless/chrome) with parallel execution",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "e2e-runner": "bin/cli.js",
12
+ "e2e-runner-mcp": "bin/mcp-server.js"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "templates/"
18
+ ],
19
+ "keywords": [
20
+ "e2e",
21
+ "testing",
22
+ "chrome",
23
+ "puppeteer",
24
+ "browserless",
25
+ "parallel",
26
+ "mcp",
27
+ "claude-code"
28
+ ],
29
+ "author": "Matware",
30
+ "license": "Apache-2.0",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/fastslack/mtw-e2e-runner.git"
34
+ },
35
+ "homepage": "https://github.com/fastslack/mtw-e2e-runner#readme",
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.12.1",
38
+ "puppeteer-core": "^24.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
42
+ }
43
+ }
package/src/actions.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * E2E Action Executor
3
+ *
4
+ * Each action maps to a browser page interaction via Puppeteer.
5
+ * The 'evaluate' type runs JS in the browser context — this is
6
+ * intentional and equivalent to Puppeteer's page.evaluate().
7
+ * The JS comes from team-authored JSON test files.
8
+ */
9
+
10
+ import { log } from './logger.js';
11
+
12
+ function sleep(ms) {
13
+ return new Promise(resolve => setTimeout(resolve, ms));
14
+ }
15
+
16
+ export async function executeAction(page, action, config) {
17
+ const { type, selector, value, text, timeout = config.defaultTimeout || 10000 } = action;
18
+ const baseUrl = config.baseUrl;
19
+ const screenshotsDir = config.screenshotsDir;
20
+
21
+ switch (type) {
22
+ case 'goto': {
23
+ const url = value.startsWith('http') ? value : `${baseUrl}${value}`;
24
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
25
+ break;
26
+ }
27
+
28
+ case 'click':
29
+ if (selector) {
30
+ await page.waitForSelector(selector, { timeout });
31
+ await page.click(selector);
32
+ } else if (text) {
33
+ await page.waitForFunction(
34
+ (t) => [...document.querySelectorAll('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span')]
35
+ .find(el => el.textContent.includes(t)),
36
+ { timeout },
37
+ text
38
+ );
39
+ await page.$$eval('button, a, [role="button"], [role="tab"], [role="menuitem"], div[class*="cursor"], span', (els, t) => {
40
+ const el = els.find(e => e.textContent.includes(t));
41
+ if (el) el.click();
42
+ }, text);
43
+ }
44
+ break;
45
+
46
+ case 'type':
47
+ case 'fill':
48
+ await page.waitForSelector(selector, { timeout });
49
+ await page.click(selector, { clickCount: 3 });
50
+ await page.keyboard.press('Backspace');
51
+ await page.type(selector, value, { delay: 20 });
52
+ break;
53
+
54
+ case 'wait':
55
+ if (selector) {
56
+ await page.waitForSelector(selector, { timeout });
57
+ } else if (text) {
58
+ await page.waitForFunction(
59
+ (t) => document.body.innerText.includes(t),
60
+ { timeout },
61
+ text
62
+ );
63
+ } else if (value) {
64
+ await sleep(parseInt(value));
65
+ }
66
+ break;
67
+
68
+ case 'screenshot': {
69
+ let filename = value || `screenshot-${Date.now()}.png`;
70
+ if (!/\.(png|jpg|jpeg|webp)$/i.test(filename)) {
71
+ filename += '.png';
72
+ }
73
+ const filepath = `${screenshotsDir}/${filename}`;
74
+ await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
75
+ return { screenshot: filepath };
76
+ }
77
+
78
+ case 'assert_text': {
79
+ const bodyText = await page.evaluate(() => document.body.innerText);
80
+ if (!bodyText.includes(text)) {
81
+ throw new Error(`assert_text failed: "${text}" not found`);
82
+ }
83
+ break;
84
+ }
85
+
86
+ case 'assert_url': {
87
+ const currentUrl = page.url();
88
+ if (!currentUrl.includes(value)) {
89
+ throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
90
+ }
91
+ break;
92
+ }
93
+
94
+ case 'assert_visible': {
95
+ const el = await page.$(selector);
96
+ if (!el) {
97
+ throw new Error(`assert_visible failed: "${selector}" not found`);
98
+ }
99
+ const visible = await page.$eval(selector, (e) => {
100
+ const style = window.getComputedStyle(e);
101
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
102
+ });
103
+ if (!visible) {
104
+ throw new Error(`assert_visible failed: "${selector}" is not visible`);
105
+ }
106
+ break;
107
+ }
108
+
109
+ case 'assert_count': {
110
+ const count = await page.$$eval(selector, els => els.length);
111
+ const expected = parseInt(value);
112
+ if (count !== expected) {
113
+ throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
114
+ }
115
+ break;
116
+ }
117
+
118
+ case 'select':
119
+ await page.waitForSelector(selector, { timeout });
120
+ await page.select(selector, value);
121
+ break;
122
+
123
+ case 'clear':
124
+ await page.waitForSelector(selector, { timeout });
125
+ await page.click(selector, { clickCount: 3 });
126
+ await page.keyboard.press('Backspace');
127
+ break;
128
+
129
+ case 'press':
130
+ await page.keyboard.press(value);
131
+ break;
132
+
133
+ case 'scroll':
134
+ if (selector) {
135
+ await page.$eval(selector, (el) => {
136
+ el.scrollIntoView({ behavior: 'smooth' });
137
+ });
138
+ } else {
139
+ await page.evaluate((y) => window.scrollBy(0, parseInt(y) || 300), value || '300');
140
+ }
141
+ await sleep(500);
142
+ break;
143
+
144
+ case 'hover':
145
+ await page.waitForSelector(selector, { timeout });
146
+ await page.hover(selector);
147
+ break;
148
+
149
+ case 'evaluate':
150
+ // Intentional: runs JS in browser page context (from test JSON files)
151
+ await page.evaluate(value);
152
+ break;
153
+
154
+ default:
155
+ log('⚠️', `Unknown action: ${type}`);
156
+ }
157
+
158
+ return null;
159
+ }
package/src/config.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Config Loader
3
+ *
4
+ * Priority order (highest to lowest):
5
+ * 1. CLI flags
6
+ * 2. Environment variables
7
+ * 3. Config file (e2e.config.js / e2e.config.json)
8
+ * 4. Defaults
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { pathToFileURL } from 'url';
14
+
15
+ const DEFAULTS = {
16
+ baseUrl: 'http://host.docker.internal:3000',
17
+ poolUrl: 'ws://localhost:3333',
18
+ testsDir: 'e2e/tests',
19
+ screenshotsDir: 'e2e/screenshots',
20
+ concurrency: 3,
21
+ viewport: { width: 1280, height: 720 },
22
+ defaultTimeout: 10000,
23
+ connectRetries: 3,
24
+ connectRetryDelay: 2000,
25
+ poolPort: 3333,
26
+ maxSessions: 10,
27
+ retries: 0,
28
+ retryDelay: 1000,
29
+ testTimeout: 60000,
30
+ outputFormat: 'json',
31
+ env: 'default',
32
+ hooks: { beforeAll: [], afterAll: [], beforeEach: [], afterEach: [] },
33
+ };
34
+
35
+ function loadEnvVars() {
36
+ const env = {};
37
+ if (process.env.BASE_URL) env.baseUrl = process.env.BASE_URL;
38
+ if (process.env.CHROME_POOL_URL) env.poolUrl = process.env.CHROME_POOL_URL;
39
+ if (process.env.TESTS_DIR) env.testsDir = process.env.TESTS_DIR;
40
+ if (process.env.SCREENSHOTS_DIR) env.screenshotsDir = process.env.SCREENSHOTS_DIR;
41
+ if (process.env.CONCURRENCY) env.concurrency = parseInt(process.env.CONCURRENCY);
42
+ if (process.env.DEFAULT_TIMEOUT) env.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT);
43
+ if (process.env.POOL_PORT) env.poolPort = parseInt(process.env.POOL_PORT);
44
+ if (process.env.MAX_SESSIONS) env.maxSessions = parseInt(process.env.MAX_SESSIONS);
45
+ if (process.env.RETRIES) env.retries = parseInt(process.env.RETRIES);
46
+ if (process.env.RETRY_DELAY) env.retryDelay = parseInt(process.env.RETRY_DELAY);
47
+ if (process.env.TEST_TIMEOUT) env.testTimeout = parseInt(process.env.TEST_TIMEOUT);
48
+ if (process.env.OUTPUT_FORMAT) env.outputFormat = process.env.OUTPUT_FORMAT;
49
+ if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
50
+ return env;
51
+ }
52
+
53
+ async function loadConfigFile(cwd) {
54
+ // Try e2e.config.js
55
+ const jsPath = path.join(cwd, 'e2e.config.js');
56
+ if (fs.existsSync(jsPath)) {
57
+ const fileUrl = pathToFileURL(jsPath).href;
58
+ const mod = await import(fileUrl);
59
+ return mod.default || mod;
60
+ }
61
+
62
+ // Try e2e.config.json
63
+ const jsonPath = path.join(cwd, 'e2e.config.json');
64
+ if (fs.existsSync(jsonPath)) {
65
+ return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
66
+ }
67
+
68
+ return {};
69
+ }
70
+
71
+ export async function loadConfig(cliArgs = {}) {
72
+ const cwd = process.cwd();
73
+ const fileConfig = await loadConfigFile(cwd);
74
+ const envConfig = loadEnvVars();
75
+
76
+ const config = {
77
+ ...DEFAULTS,
78
+ ...fileConfig,
79
+ ...envConfig,
80
+ ...cliArgs,
81
+ };
82
+
83
+ // Apply environment profile overrides
84
+ if (config.env && config.env !== 'default' && config.environments?.[config.env]) {
85
+ const profile = config.environments[config.env];
86
+ Object.assign(config, profile);
87
+ }
88
+ delete config.environments;
89
+
90
+ // Resolve relative paths against cwd
91
+ if (!path.isAbsolute(config.testsDir)) {
92
+ config.testsDir = path.join(cwd, config.testsDir);
93
+ }
94
+ if (!path.isAbsolute(config.screenshotsDir)) {
95
+ config.screenshotsDir = path.join(cwd, config.screenshotsDir);
96
+ }
97
+
98
+ // Ensure screenshots directory exists
99
+ if (!fs.existsSync(config.screenshotsDir)) {
100
+ fs.mkdirSync(config.screenshotsDir, { recursive: true });
101
+ }
102
+
103
+ return config;
104
+ }