@matware/e2e-runner 1.1.1 β 1.3.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/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- package/templates/sample-test.json +0 -8
package/src/runner.js
CHANGED
|
@@ -7,14 +7,30 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import https from 'https';
|
|
12
|
+
import { connectToPool } from './pool.js';
|
|
13
|
+
import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
|
|
11
14
|
import { executeAction } from './actions.js';
|
|
15
|
+
import { narrateAction } from './narrate.js';
|
|
12
16
|
import { log, colors as C } from './logger.js';
|
|
17
|
+
import { resolveTestData, validateActionTypes } from './module-resolver.js';
|
|
18
|
+
import { ensureProject, getVariables } from './db.js';
|
|
13
19
|
|
|
14
20
|
function sleep(ms) {
|
|
15
21
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/** Simple glob matching with * wildcards for exclude patterns. */
|
|
25
|
+
function matchesExclude(filename, excludePatterns) {
|
|
26
|
+
if (!excludePatterns?.length) return false;
|
|
27
|
+
const name = filename.replace('.json', '');
|
|
28
|
+
return excludePatterns.some(pattern => {
|
|
29
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
30
|
+
return regex.test(name) || regex.test(filename);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function timeDiff(start, end) {
|
|
19
35
|
const ms = new Date(end) - new Date(start);
|
|
20
36
|
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -32,6 +48,46 @@ function mergeHooks(configHooks, suiteHooks) {
|
|
|
32
48
|
};
|
|
33
49
|
}
|
|
34
50
|
|
|
51
|
+
/** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
|
|
52
|
+
* Skips {{param}} patterns (no dot) β those are module params handled by module-resolver. */
|
|
53
|
+
function resolveVarsInAction(action, vars) {
|
|
54
|
+
const resolved = { ...action };
|
|
55
|
+
for (const key of Object.keys(resolved)) {
|
|
56
|
+
if (typeof resolved[key] !== 'string') continue;
|
|
57
|
+
resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
|
|
58
|
+
if (ns === 'env') {
|
|
59
|
+
if (process.env[name] !== undefined) return process.env[name];
|
|
60
|
+
throw new Error(`Unresolved variable: {{env.${name}}} β environment variable not set`);
|
|
61
|
+
}
|
|
62
|
+
// ns === 'var'
|
|
63
|
+
if (vars[name] !== undefined) return vars[name];
|
|
64
|
+
throw new Error(`Unresolved variable: {{var.${name}}} β not found in project or suite variables`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Loads merged variables for a test (project scope + suite scope overlay). */
|
|
71
|
+
function loadVarsForTest(config, suiteName) {
|
|
72
|
+
try {
|
|
73
|
+
const cwd = config._cwd || process.cwd();
|
|
74
|
+
const projectName = config.projectName || cwd.split('/').pop() || 'default';
|
|
75
|
+
const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
|
|
76
|
+
const projectVars = getVariables(projectId, 'project');
|
|
77
|
+
if (!suiteName) return projectVars;
|
|
78
|
+
const suiteVars = getVariables(projectId, suiteName);
|
|
79
|
+
return { ...projectVars, ...suiteVars };
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolves variables in an array of actions. */
|
|
86
|
+
function resolveVarsInActions(actions, vars) {
|
|
87
|
+
if (!actions || !actions.length) return actions;
|
|
88
|
+
return actions.map(a => resolveVarsInAction(a, vars));
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
/** Executes an array of hook actions on a page */
|
|
36
92
|
async function executeHookActions(page, actions, config) {
|
|
37
93
|
for (const action of actions) {
|
|
@@ -44,32 +100,60 @@ function normalizeTestData(data) {
|
|
|
44
100
|
if (Array.isArray(data)) {
|
|
45
101
|
return { tests: data, hooks: {} };
|
|
46
102
|
}
|
|
47
|
-
|
|
103
|
+
// Support hooks nested under "hooks" key or directly at root level
|
|
104
|
+
const hooks = data.hooks || {};
|
|
105
|
+
if (!hooks.beforeAll && data.beforeAll) hooks.beforeAll = data.beforeAll;
|
|
106
|
+
if (!hooks.afterAll && data.afterAll) hooks.afterAll = data.afterAll;
|
|
107
|
+
if (!hooks.beforeEach && data.beforeEach) hooks.beforeEach = data.beforeEach;
|
|
108
|
+
if (!hooks.afterEach && data.afterEach) hooks.afterEach = data.afterEach;
|
|
109
|
+
return { tests: data.tests || [], hooks };
|
|
48
110
|
}
|
|
49
111
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
/** Extracts a value from an object using a dot-path (e.g. "data.token"). */
|
|
113
|
+
function getByPath(obj, dotPath) {
|
|
114
|
+
return dotPath.split('.').reduce((o, key) => o?.[key], obj);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Fetches an auth token by POSTing credentials to a login endpoint. */
|
|
118
|
+
function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const url = new URL(endpoint);
|
|
121
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
122
|
+
const body = JSON.stringify(credentials);
|
|
123
|
+
|
|
124
|
+
const req = transport.request(url, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
127
|
+
timeout: 15000,
|
|
128
|
+
}, (res) => {
|
|
129
|
+
let data = '';
|
|
130
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
131
|
+
res.on('end', () => {
|
|
132
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
133
|
+
return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const json = JSON.parse(data);
|
|
137
|
+
const token = getByPath(json, tokenPath);
|
|
138
|
+
if (!token) {
|
|
139
|
+
return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
|
|
140
|
+
}
|
|
141
|
+
resolve(token);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
|
|
148
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
|
|
149
|
+
req.end(body);
|
|
150
|
+
});
|
|
68
151
|
}
|
|
69
152
|
|
|
70
153
|
/** Runs a single test end-to-end */
|
|
71
154
|
export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
72
155
|
let browser = null;
|
|
156
|
+
let context = null;
|
|
73
157
|
let page = null;
|
|
74
158
|
|
|
75
159
|
const result = {
|
|
@@ -85,16 +169,28 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
85
169
|
const pendingBodies = [];
|
|
86
170
|
|
|
87
171
|
try {
|
|
88
|
-
await
|
|
89
|
-
|
|
90
|
-
|
|
172
|
+
const chosenPool = await selectPool(getPoolUrls(config));
|
|
173
|
+
result.poolUrl = chosenPool;
|
|
174
|
+
const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
|
|
175
|
+
const isMultiPool = getPoolUrls(config).length > 1;
|
|
176
|
+
if (isMultiPool) {
|
|
177
|
+
log('π', `${C.cyan}${test.name}${C.reset} ${C.dim}β ${poolLabel}${C.reset}`);
|
|
178
|
+
}
|
|
179
|
+
progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
|
|
180
|
+
browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
|
|
181
|
+
// Use incognito context for cookie isolation between concurrent tests
|
|
182
|
+
context = await browser.createBrowserContext();
|
|
183
|
+
page = await context.newPage();
|
|
91
184
|
await page.setViewport(config.viewport);
|
|
92
185
|
|
|
93
186
|
page.on('console', (msg) => {
|
|
94
187
|
result.consoleLogs.push({ type: msg.type(), text: msg.text() });
|
|
95
188
|
});
|
|
96
189
|
page.on('requestfailed', (req) => {
|
|
97
|
-
|
|
190
|
+
const url = req.url();
|
|
191
|
+
const ignoreDomains = config.networkIgnoreDomains || [];
|
|
192
|
+
if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
|
|
193
|
+
result.networkErrors.push({ url, error: req.failure()?.errorText });
|
|
98
194
|
});
|
|
99
195
|
|
|
100
196
|
const requestTimings = new Map();
|
|
@@ -128,44 +224,91 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
128
224
|
}
|
|
129
225
|
});
|
|
130
226
|
|
|
227
|
+
// Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
|
|
228
|
+
if (config.authToken) {
|
|
229
|
+
const storageKey = config.authStorageKey || 'accessToken';
|
|
230
|
+
await page.goto(config.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
231
|
+
await page.evaluate((key, token) => {
|
|
232
|
+
localStorage.setItem(key, token);
|
|
233
|
+
}, storageKey, config.authToken);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Resolve {{var.X}} and {{env.X}} in test actions and hooks
|
|
237
|
+
const vars = loadVarsForTest(config, config._suiteName);
|
|
238
|
+
if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
|
|
239
|
+
test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
|
|
240
|
+
if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
|
|
241
|
+
if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
|
|
242
|
+
}
|
|
243
|
+
|
|
131
244
|
// Run beforeEach hook
|
|
132
245
|
if (hooks.beforeEach?.length) {
|
|
133
246
|
await executeHookActions(page, hooks.beforeEach, config);
|
|
134
247
|
}
|
|
135
248
|
|
|
249
|
+
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
|
|
250
|
+
if (test.expect && page) {
|
|
251
|
+
try {
|
|
252
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
253
|
+
const baselinePath = path.join(config.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
|
|
254
|
+
await page.screenshot({ path: baselinePath, fullPage: true });
|
|
255
|
+
result.baselineScreenshot = baselinePath;
|
|
256
|
+
} catch { /* page may not be ready */ }
|
|
257
|
+
}
|
|
258
|
+
|
|
136
259
|
for (let i = 0; i < test.actions.length; i++) {
|
|
137
260
|
const action = test.actions[i];
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
261
|
+
const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
|
|
262
|
+
const actionRetryDelay = config.actionRetryDelay ?? 500;
|
|
263
|
+
let lastError = null;
|
|
264
|
+
|
|
265
|
+
for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
|
|
266
|
+
const actionStart = Date.now();
|
|
267
|
+
try {
|
|
268
|
+
let actionResult;
|
|
269
|
+
if (action.type === 'assert_no_network_errors') {
|
|
270
|
+
// Handled inline β needs access to result.networkErrors
|
|
271
|
+
if (result.networkErrors.length > 0) {
|
|
272
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
273
|
+
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
274
|
+
}
|
|
275
|
+
actionResult = null;
|
|
276
|
+
} else {
|
|
277
|
+
actionResult = await executeAction(page, action, config);
|
|
278
|
+
}
|
|
279
|
+
const actionDuration = Date.now() - actionStart;
|
|
280
|
+
const actionEntry = {
|
|
281
|
+
...action,
|
|
282
|
+
success: true,
|
|
283
|
+
duration: actionDuration,
|
|
284
|
+
result: actionResult,
|
|
285
|
+
};
|
|
286
|
+
if (attempt > 0) actionEntry.actionRetries = attempt;
|
|
287
|
+
actionEntry.narrative = narrateAction(action, actionEntry);
|
|
288
|
+
result.actions.push(actionEntry);
|
|
289
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null });
|
|
290
|
+
lastError = null;
|
|
291
|
+
break;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
lastError = error;
|
|
294
|
+
if (attempt < maxActionRetries) {
|
|
295
|
+
log('π', `${C.dim}Action ${action.type} retry ${attempt + 1}/${maxActionRetries} (${error.message})${C.reset}`);
|
|
296
|
+
await sleep(actionRetryDelay);
|
|
297
|
+
continue;
|
|
146
298
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
299
|
+
const actionDuration = Date.now() - actionStart;
|
|
300
|
+
const failedEntry = {
|
|
301
|
+
...action,
|
|
302
|
+
success: false,
|
|
303
|
+
duration: actionDuration,
|
|
304
|
+
error: error.message,
|
|
305
|
+
};
|
|
306
|
+
if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
|
|
307
|
+
failedEntry.narrative = narrateAction(action, failedEntry);
|
|
308
|
+
result.actions.push(failedEntry);
|
|
309
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
|
|
310
|
+
throw error;
|
|
150
311
|
}
|
|
151
|
-
const actionDuration = Date.now() - actionStart;
|
|
152
|
-
result.actions.push({
|
|
153
|
-
...action,
|
|
154
|
-
success: true,
|
|
155
|
-
duration: actionDuration,
|
|
156
|
-
result: actionResult,
|
|
157
|
-
});
|
|
158
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
|
|
159
|
-
} catch (error) {
|
|
160
|
-
const actionDuration = Date.now() - actionStart;
|
|
161
|
-
result.actions.push({
|
|
162
|
-
...action,
|
|
163
|
-
success: false,
|
|
164
|
-
duration: actionDuration,
|
|
165
|
-
error: error.message,
|
|
166
|
-
});
|
|
167
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
|
|
168
|
-
throw error;
|
|
169
312
|
}
|
|
170
313
|
}
|
|
171
314
|
|
|
@@ -216,9 +359,16 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
216
359
|
if (page) {
|
|
217
360
|
try { result.finalUrl = page.url(); } catch { /* */ }
|
|
218
361
|
}
|
|
362
|
+
if (context) {
|
|
363
|
+
try { await context.close(); } catch { /* */ }
|
|
364
|
+
}
|
|
219
365
|
if (browser) {
|
|
220
366
|
try { browser.disconnect(); } catch { /* */ }
|
|
221
367
|
}
|
|
368
|
+
// Release local pending counter so selectPool() knows this slot is free
|
|
369
|
+
if (result.poolUrl) {
|
|
370
|
+
releasePending(result.poolUrl);
|
|
371
|
+
}
|
|
222
372
|
}
|
|
223
373
|
|
|
224
374
|
return result;
|
|
@@ -230,10 +380,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
230
380
|
|
|
231
381
|
// Run beforeAll hook
|
|
232
382
|
if (hooks.beforeAll?.length) {
|
|
383
|
+
const stateActions = hooks.beforeAll.filter(a =>
|
|
384
|
+
['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
|
|
385
|
+
);
|
|
386
|
+
if (stateActions.length > 0) {
|
|
387
|
+
log('β οΈ', `${C.yellow}beforeAll runs on a separate browser β state from ${stateActions.map(a => a.type).join(', ')} will NOT carry over to tests. Use beforeEach instead.${C.reset}`);
|
|
388
|
+
}
|
|
233
389
|
log('πͺ', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
234
390
|
let browser = null;
|
|
235
391
|
try {
|
|
236
|
-
|
|
392
|
+
const hookPool = await selectPool(getPoolUrls(config));
|
|
393
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
237
394
|
const page = await browser.newPage();
|
|
238
395
|
await page.setViewport(config.viewport);
|
|
239
396
|
await executeHookActions(page, hooks.beforeAll, config);
|
|
@@ -246,16 +403,40 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
246
403
|
}
|
|
247
404
|
}
|
|
248
405
|
|
|
406
|
+
// Auto-login: fetch auth token via API if configured and not already provided
|
|
407
|
+
if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
|
|
408
|
+
log('π', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
|
|
409
|
+
try {
|
|
410
|
+
config.authToken = await fetchAuthToken(
|
|
411
|
+
config.authLoginEndpoint,
|
|
412
|
+
config.authCredentials,
|
|
413
|
+
config.authTokenPath || 'token'
|
|
414
|
+
);
|
|
415
|
+
log('β
', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
log('β', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
249
422
|
const concurrency = config.concurrency || 3;
|
|
250
423
|
const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
251
424
|
const _proj = config.projectName || null;
|
|
252
425
|
const _cwd = config._cwd || null;
|
|
253
426
|
const _triggeredBy = config.triggeredBy || 'unknown';
|
|
254
427
|
const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
|
|
428
|
+
|
|
429
|
+
// Split serial and parallel tests
|
|
430
|
+
const parallelTests = tests.filter(t => !t.serial);
|
|
431
|
+
const serialTests = tests.filter(t => t.serial);
|
|
432
|
+
if (serialTests.length > 0) {
|
|
433
|
+
log('π', `${C.dim}${serialTests.length} serial test(s) will run after parallel batch${C.reset}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
255
436
|
_progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
|
|
256
437
|
|
|
257
438
|
const results = [];
|
|
258
|
-
const queue = [...
|
|
439
|
+
const queue = [...parallelTests];
|
|
259
440
|
let activeCount = 0;
|
|
260
441
|
|
|
261
442
|
const worker = async () => {
|
|
@@ -263,7 +444,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
263
444
|
const test = queue.shift();
|
|
264
445
|
activeCount++;
|
|
265
446
|
log('βΆβΆβΆ', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
266
|
-
_progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
|
|
447
|
+
_progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
|
|
267
448
|
|
|
268
449
|
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
269
450
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
@@ -276,7 +457,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
276
457
|
});
|
|
277
458
|
|
|
278
459
|
try {
|
|
279
|
-
|
|
460
|
+
const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
|
|
461
|
+
result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
|
|
280
462
|
} catch (error) {
|
|
281
463
|
result = {
|
|
282
464
|
name: test.name,
|
|
@@ -304,7 +486,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
304
486
|
activeCount--;
|
|
305
487
|
|
|
306
488
|
const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
|
|
307
|
-
_progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
|
|
489
|
+
_progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots, poolUrl: result.poolUrl || null });
|
|
308
490
|
|
|
309
491
|
if (result.success) {
|
|
310
492
|
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
@@ -325,17 +507,25 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
325
507
|
};
|
|
326
508
|
|
|
327
509
|
const workers = [];
|
|
328
|
-
for (let i = 0; i < Math.min(concurrency,
|
|
510
|
+
for (let i = 0; i < Math.min(concurrency, parallelTests.length); i++) {
|
|
329
511
|
workers.push(worker());
|
|
330
512
|
}
|
|
331
513
|
await Promise.all(workers);
|
|
332
514
|
|
|
515
|
+
// Run serial tests one at a time
|
|
516
|
+
if (serialTests.length > 0) {
|
|
517
|
+
log('π', `${C.dim}Running ${serialTests.length} serial test(s)...${C.reset}`);
|
|
518
|
+
queue.push(...serialTests);
|
|
519
|
+
await worker();
|
|
520
|
+
}
|
|
521
|
+
|
|
333
522
|
// Run afterAll hook
|
|
334
523
|
if (hooks.afterAll?.length) {
|
|
335
524
|
log('πͺ', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
336
525
|
let browser = null;
|
|
337
526
|
try {
|
|
338
|
-
|
|
527
|
+
const hookPool = await selectPool(getPoolUrls(config));
|
|
528
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
339
529
|
const page = await browser.newPage();
|
|
340
530
|
await page.setViewport(config.viewport);
|
|
341
531
|
await executeHookActions(page, hooks.afterAll, config);
|
|
@@ -357,16 +547,19 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
357
547
|
}
|
|
358
548
|
|
|
359
549
|
/** Loads tests from a JSON file β returns { tests, hooks } */
|
|
360
|
-
export function loadTestFile(filePath) {
|
|
550
|
+
export function loadTestFile(filePath, modulesDir) {
|
|
361
551
|
if (!fs.existsSync(filePath)) {
|
|
362
552
|
throw new Error(`File not found: ${filePath}`);
|
|
363
553
|
}
|
|
364
554
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
365
|
-
|
|
555
|
+
const normalized = normalizeTestData(data);
|
|
556
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
557
|
+
validateActionTypes(resolved, path.basename(filePath));
|
|
558
|
+
return resolved;
|
|
366
559
|
}
|
|
367
560
|
|
|
368
561
|
/** Loads a test suite by name β returns { tests, hooks } */
|
|
369
|
-
export function loadTestSuite(suiteName, testsDir) {
|
|
562
|
+
export function loadTestSuite(suiteName, testsDir, modulesDir) {
|
|
370
563
|
// Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
|
|
371
564
|
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
|
|
372
565
|
const exact = files.find(f => f === `${suiteName}.json`);
|
|
@@ -378,29 +571,39 @@ export function loadTestSuite(suiteName, testsDir) {
|
|
|
378
571
|
}
|
|
379
572
|
|
|
380
573
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
381
|
-
|
|
574
|
+
const normalized = normalizeTestData(data);
|
|
575
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
576
|
+
validateActionTypes(resolved, match);
|
|
577
|
+
return resolved;
|
|
382
578
|
}
|
|
383
579
|
|
|
384
580
|
/** Loads all test suites from the tests directory β returns { tests, hooks } */
|
|
385
|
-
export function loadAllSuites(testsDir) {
|
|
581
|
+
export function loadAllSuites(testsDir, modulesDir, exclude = []) {
|
|
386
582
|
if (!fs.existsSync(testsDir)) {
|
|
387
583
|
throw new Error(`Tests directory not found: ${testsDir}`);
|
|
388
584
|
}
|
|
389
585
|
|
|
390
|
-
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
586
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
587
|
+
.filter(f => !matchesExclude(f, exclude));
|
|
391
588
|
let allTests = [];
|
|
392
|
-
let mergedHooks = {};
|
|
393
589
|
|
|
394
590
|
for (const file of files) {
|
|
395
591
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
|
|
396
|
-
|
|
592
|
+
let { tests, hooks } = normalizeTestData(data);
|
|
593
|
+
// Resolve modules per-suite before concatenating
|
|
594
|
+
if (modulesDir) {
|
|
595
|
+
({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
|
|
596
|
+
}
|
|
597
|
+
validateActionTypes({ tests, hooks }, file);
|
|
598
|
+
// Tag each test with its own suite's hooks so they're preserved
|
|
599
|
+
for (const t of tests) {
|
|
600
|
+
t._suiteHooks = hooks;
|
|
601
|
+
}
|
|
397
602
|
allTests = allTests.concat(tests);
|
|
398
|
-
// Last suite's hooks win for each non-empty key
|
|
399
|
-
mergedHooks = mergeHooks(mergedHooks, hooks);
|
|
400
603
|
log('π', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
|
|
401
604
|
}
|
|
402
605
|
|
|
403
|
-
return { tests: allTests, hooks:
|
|
606
|
+
return { tests: allTests, hooks: {} };
|
|
404
607
|
}
|
|
405
608
|
|
|
406
609
|
/** Lists all available test suites */
|