@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/README.md +396 -0
- package/bin/cli.js +332 -0
- package/bin/mcp-server.js +15 -0
- package/package.json +43 -0
- package/src/actions.js +159 -0
- package/src/config.js +104 -0
- package/src/index.js +75 -0
- package/src/logger.js +19 -0
- package/src/mcp-server.js +345 -0
- package/src/pool.js +144 -0
- package/src/reporter.js +116 -0
- package/src/runner.js +309 -0
- package/templates/docker-compose.yml +13 -0
- package/templates/e2e.config.js +55 -0
- package/templates/sample-test.json +19 -0
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
|
+
}
|