@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/src/runner.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Test Execution Engine
|
|
3
|
+
*
|
|
4
|
+
* Runs tests in parallel using a pool of Chrome instances.
|
|
5
|
+
* Supports retries, test-level timeouts, and before/after hooks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { connectToPool, waitForPool } from './pool.js';
|
|
11
|
+
import { executeAction } from './actions.js';
|
|
12
|
+
import { log, colors as C } from './logger.js';
|
|
13
|
+
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function timeDiff(start, end) {
|
|
19
|
+
const ms = new Date(end) - new Date(start);
|
|
20
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Merges suite-level hooks with global config hooks. Non-empty suite hooks win per key. */
|
|
24
|
+
function mergeHooks(configHooks, suiteHooks) {
|
|
25
|
+
const base = configHooks || { beforeAll: [], afterAll: [], beforeEach: [], afterEach: [] };
|
|
26
|
+
if (!suiteHooks) return { ...base };
|
|
27
|
+
return {
|
|
28
|
+
beforeAll: suiteHooks.beforeAll?.length ? suiteHooks.beforeAll : base.beforeAll || [],
|
|
29
|
+
afterAll: suiteHooks.afterAll?.length ? suiteHooks.afterAll : base.afterAll || [],
|
|
30
|
+
beforeEach: suiteHooks.beforeEach?.length ? suiteHooks.beforeEach : base.beforeEach || [],
|
|
31
|
+
afterEach: suiteHooks.afterEach?.length ? suiteHooks.afterEach : base.afterEach || [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Executes an array of hook actions on a page */
|
|
36
|
+
async function executeHookActions(page, actions, config) {
|
|
37
|
+
for (const action of actions) {
|
|
38
|
+
await executeAction(page, action, config);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Normalizes raw JSON (array or object with hooks) into { tests, hooks } */
|
|
43
|
+
function normalizeTestData(data) {
|
|
44
|
+
if (Array.isArray(data)) {
|
|
45
|
+
return { tests: data, hooks: {} };
|
|
46
|
+
}
|
|
47
|
+
return { tests: data.tests || [], hooks: data.hooks || {} };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs a single test end-to-end */
|
|
51
|
+
export async function runTest(test, config, hooks = {}) {
|
|
52
|
+
let browser = null;
|
|
53
|
+
let page = null;
|
|
54
|
+
|
|
55
|
+
const result = {
|
|
56
|
+
name: test.name,
|
|
57
|
+
startTime: new Date().toISOString(),
|
|
58
|
+
actions: [],
|
|
59
|
+
success: true,
|
|
60
|
+
error: null,
|
|
61
|
+
consoleLogs: [],
|
|
62
|
+
networkErrors: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
67
|
+
page = await browser.newPage();
|
|
68
|
+
await page.setViewport(config.viewport);
|
|
69
|
+
|
|
70
|
+
page.on('console', (msg) => {
|
|
71
|
+
result.consoleLogs.push({ type: msg.type(), text: msg.text() });
|
|
72
|
+
});
|
|
73
|
+
page.on('requestfailed', (req) => {
|
|
74
|
+
result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Run beforeEach hook
|
|
78
|
+
if (hooks.beforeEach?.length) {
|
|
79
|
+
await executeHookActions(page, hooks.beforeEach, config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const action of test.actions) {
|
|
83
|
+
const actionStart = Date.now();
|
|
84
|
+
try {
|
|
85
|
+
const actionResult = await executeAction(page, action, config);
|
|
86
|
+
result.actions.push({
|
|
87
|
+
...action,
|
|
88
|
+
success: true,
|
|
89
|
+
duration: Date.now() - actionStart,
|
|
90
|
+
result: actionResult,
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
result.actions.push({
|
|
94
|
+
...action,
|
|
95
|
+
success: false,
|
|
96
|
+
duration: Date.now() - actionStart,
|
|
97
|
+
error: error.message,
|
|
98
|
+
});
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Run afterEach hook (success path)
|
|
104
|
+
if (hooks.afterEach?.length) {
|
|
105
|
+
await executeHookActions(page, hooks.afterEach, config);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
result.success = false;
|
|
109
|
+
result.error = error.message;
|
|
110
|
+
|
|
111
|
+
// Run afterEach hook (failure path)
|
|
112
|
+
if (page && hooks.afterEach?.length) {
|
|
113
|
+
try { await executeHookActions(page, hooks.afterEach, config); } catch { /* */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (page) {
|
|
117
|
+
try {
|
|
118
|
+
const errorScreenshot = path.join(config.screenshotsDir, `error-${test.name}-${Date.now()}.png`);
|
|
119
|
+
await page.screenshot({ path: errorScreenshot, fullPage: true });
|
|
120
|
+
result.errorScreenshot = errorScreenshot;
|
|
121
|
+
} catch { /* page may be dead */ }
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
result.endTime = new Date().toISOString();
|
|
125
|
+
if (page) {
|
|
126
|
+
try { result.finalUrl = page.url(); } catch { /* */ }
|
|
127
|
+
}
|
|
128
|
+
if (browser) {
|
|
129
|
+
try { browser.disconnect(); } catch { /* */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Runs tests in parallel with limited concurrency, retries, timeouts, and hooks */
|
|
137
|
+
export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
138
|
+
const hooks = mergeHooks(config.hooks, suiteHooks);
|
|
139
|
+
|
|
140
|
+
// Run beforeAll hook
|
|
141
|
+
if (hooks.beforeAll?.length) {
|
|
142
|
+
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
143
|
+
let browser = null;
|
|
144
|
+
try {
|
|
145
|
+
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
146
|
+
const page = await browser.newPage();
|
|
147
|
+
await page.setViewport(config.viewport);
|
|
148
|
+
await executeHookActions(page, hooks.beforeAll, config);
|
|
149
|
+
await page.close();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
log('❌', `${C.red}beforeAll hook failed: ${error.message}${C.reset}`);
|
|
152
|
+
throw error;
|
|
153
|
+
} finally {
|
|
154
|
+
if (browser) try { browser.disconnect(); } catch { /* */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const results = [];
|
|
159
|
+
const queue = [...tests];
|
|
160
|
+
let activeCount = 0;
|
|
161
|
+
|
|
162
|
+
const worker = async () => {
|
|
163
|
+
while (queue.length > 0) {
|
|
164
|
+
const test = queue.shift();
|
|
165
|
+
activeCount++;
|
|
166
|
+
log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
167
|
+
|
|
168
|
+
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
169
|
+
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
170
|
+
let result;
|
|
171
|
+
|
|
172
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
173
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
174
|
+
const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
|
|
175
|
+
timer.unref();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
result = await Promise.race([runTest(test, config, hooks), timeoutPromise]);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
result = {
|
|
182
|
+
name: test.name,
|
|
183
|
+
startTime: new Date().toISOString(),
|
|
184
|
+
endTime: new Date().toISOString(),
|
|
185
|
+
actions: [],
|
|
186
|
+
success: false,
|
|
187
|
+
error: error.message,
|
|
188
|
+
consoleLogs: [],
|
|
189
|
+
networkErrors: [],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
result.attempt = attempt;
|
|
194
|
+
result.maxAttempts = maxAttempts;
|
|
195
|
+
|
|
196
|
+
if (result.success || attempt === maxAttempts) break;
|
|
197
|
+
log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
|
|
198
|
+
await sleep(config.retryDelay || 1000);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
results.push(result);
|
|
202
|
+
activeCount--;
|
|
203
|
+
|
|
204
|
+
if (result.success) {
|
|
205
|
+
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
206
|
+
log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
|
|
207
|
+
} else {
|
|
208
|
+
const attempts = result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
|
|
209
|
+
log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${attempts}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const concurrency = config.concurrency || 3;
|
|
215
|
+
const workers = [];
|
|
216
|
+
for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
|
|
217
|
+
workers.push(worker());
|
|
218
|
+
}
|
|
219
|
+
await Promise.all(workers);
|
|
220
|
+
|
|
221
|
+
// Run afterAll hook
|
|
222
|
+
if (hooks.afterAll?.length) {
|
|
223
|
+
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
224
|
+
let browser = null;
|
|
225
|
+
try {
|
|
226
|
+
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
227
|
+
const page = await browser.newPage();
|
|
228
|
+
await page.setViewport(config.viewport);
|
|
229
|
+
await executeHookActions(page, hooks.afterAll, config);
|
|
230
|
+
await page.close();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
log('⚠️', `${C.yellow}afterAll hook failed: ${error.message}${C.reset}`);
|
|
233
|
+
} finally {
|
|
234
|
+
if (browser) try { browser.disconnect(); } catch { /* */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Loads tests from a JSON file — returns { tests, hooks } */
|
|
242
|
+
export function loadTestFile(filePath) {
|
|
243
|
+
if (!fs.existsSync(filePath)) {
|
|
244
|
+
throw new Error(`File not found: ${filePath}`);
|
|
245
|
+
}
|
|
246
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
247
|
+
return normalizeTestData(data);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Loads a test suite by name — returns { tests, hooks } */
|
|
251
|
+
export function loadTestSuite(suiteName, testsDir) {
|
|
252
|
+
// Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
|
|
253
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
|
|
254
|
+
const exact = files.find(f => f === `${suiteName}.json`);
|
|
255
|
+
const prefixed = files.find(f => f.replace(/^\d+-/, '') === `${suiteName}.json`);
|
|
256
|
+
const match = exact || prefixed;
|
|
257
|
+
|
|
258
|
+
if (!match) {
|
|
259
|
+
throw new Error(`Suite not found: ${suiteName} in ${testsDir}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
263
|
+
return normalizeTestData(data);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Loads all test suites from the tests directory — returns { tests, hooks } */
|
|
267
|
+
export function loadAllSuites(testsDir) {
|
|
268
|
+
if (!fs.existsSync(testsDir)) {
|
|
269
|
+
throw new Error(`Tests directory not found: ${testsDir}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
|
|
273
|
+
let allTests = [];
|
|
274
|
+
let mergedHooks = {};
|
|
275
|
+
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
|
|
278
|
+
const { tests, hooks } = normalizeTestData(data);
|
|
279
|
+
allTests = allTests.concat(tests);
|
|
280
|
+
// Last suite's hooks win for each non-empty key
|
|
281
|
+
mergedHooks = mergeHooks(mergedHooks, hooks);
|
|
282
|
+
log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { tests: allTests, hooks: mergedHooks };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Lists all available test suites */
|
|
289
|
+
export function listSuites(testsDir) {
|
|
290
|
+
if (!fs.existsSync(testsDir)) {
|
|
291
|
+
throw new Error(`Tests directory not found: ${testsDir}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
|
|
295
|
+
const suites = [];
|
|
296
|
+
|
|
297
|
+
for (const file of files) {
|
|
298
|
+
const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
|
|
299
|
+
const { tests } = normalizeTestData(data);
|
|
300
|
+
suites.push({
|
|
301
|
+
name: file.replace('.json', ''),
|
|
302
|
+
file,
|
|
303
|
+
testCount: tests.length,
|
|
304
|
+
tests: tests.map(t => t.name),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return suites;
|
|
309
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
services:
|
|
2
|
+
chrome-pool:
|
|
3
|
+
image: browserless/chrome:1-puppeteer-21.3.6
|
|
4
|
+
container_name: e2e-chrome-pool
|
|
5
|
+
restart: unless-stopped
|
|
6
|
+
ports:
|
|
7
|
+
- "${PORT}:3000"
|
|
8
|
+
environment:
|
|
9
|
+
- MAX_CONCURRENT_SESSIONS=${MAX_SESSIONS}
|
|
10
|
+
- MAX_QUEUE_LENGTH=10
|
|
11
|
+
- CONNECTION_TIMEOUT=300000
|
|
12
|
+
- PREBOOT_CHROME=false
|
|
13
|
+
- ENABLE_CORS=true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
// App URL (from inside Docker, use host.docker.internal to reach the host)
|
|
3
|
+
baseUrl: 'http://host.docker.internal:3000',
|
|
4
|
+
|
|
5
|
+
// Chrome Pool WebSocket URL
|
|
6
|
+
poolUrl: 'ws://localhost:3333',
|
|
7
|
+
|
|
8
|
+
// Directory containing JSON test files
|
|
9
|
+
testsDir: 'e2e/tests',
|
|
10
|
+
|
|
11
|
+
// Directory for screenshots and reports
|
|
12
|
+
screenshotsDir: 'e2e/screenshots',
|
|
13
|
+
|
|
14
|
+
// Parallel test workers
|
|
15
|
+
concurrency: 3,
|
|
16
|
+
|
|
17
|
+
// Browser viewport
|
|
18
|
+
viewport: { width: 1280, height: 720 },
|
|
19
|
+
|
|
20
|
+
// Timeout per action (ms)
|
|
21
|
+
defaultTimeout: 10000,
|
|
22
|
+
|
|
23
|
+
// Chrome Pool port (for pool start/stop)
|
|
24
|
+
poolPort: 3333,
|
|
25
|
+
|
|
26
|
+
// Max concurrent pool sessions
|
|
27
|
+
maxSessions: 5,
|
|
28
|
+
|
|
29
|
+
// Retry failed tests N times (0 = no retries)
|
|
30
|
+
retries: 0,
|
|
31
|
+
|
|
32
|
+
// Delay between retries (ms)
|
|
33
|
+
retryDelay: 1000,
|
|
34
|
+
|
|
35
|
+
// Per-test timeout (ms) — kills the test if it exceeds this
|
|
36
|
+
testTimeout: 60000,
|
|
37
|
+
|
|
38
|
+
// Report output format: 'json', 'junit', or 'both'
|
|
39
|
+
outputFormat: 'json',
|
|
40
|
+
|
|
41
|
+
// Global hooks — run actions before/after all tests or each test
|
|
42
|
+
// hooks: {
|
|
43
|
+
// beforeAll: [{ type: 'goto', value: '/login' }, { type: 'type', selector: '#email', value: 'admin@example.com' }],
|
|
44
|
+
// afterAll: [],
|
|
45
|
+
// beforeEach: [{ type: 'goto', value: '/' }],
|
|
46
|
+
// afterEach: [{ type: 'screenshot', value: 'after-test.png' }],
|
|
47
|
+
// },
|
|
48
|
+
|
|
49
|
+
// Environment profiles — override any config key per environment
|
|
50
|
+
// Use with --env <name> or E2E_ENV=<name>
|
|
51
|
+
// environments: {
|
|
52
|
+
// staging: { baseUrl: 'https://staging.example.com' },
|
|
53
|
+
// production: { baseUrl: 'https://example.com', concurrency: 5 },
|
|
54
|
+
// },
|
|
55
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "homepage-loads",
|
|
4
|
+
"actions": [
|
|
5
|
+
{ "type": "goto", "value": "/" },
|
|
6
|
+
{ "type": "wait", "value": "1000" },
|
|
7
|
+
{ "type": "screenshot", "value": "homepage.png" },
|
|
8
|
+
{ "type": "assert_url", "value": "/" }
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "homepage-has-title",
|
|
13
|
+
"actions": [
|
|
14
|
+
{ "type": "goto", "value": "/" },
|
|
15
|
+
{ "type": "wait", "value": "1000" },
|
|
16
|
+
{ "type": "assert_text", "text": "Welcome" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
]
|