@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/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
+ ]