@matware/e2e-runner 1.2.1 → 1.3.1
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 +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- 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/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- 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 +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -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 +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -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 +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -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 +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/runner.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
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, getCachedDriver, disconnectFromPool } from './pool.js';
|
|
13
|
+
import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
|
|
14
|
+
import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
|
|
11
15
|
import { executeAction } from './actions.js';
|
|
12
16
|
import { narrateAction } from './narrate.js';
|
|
13
17
|
import { log, colors as C } from './logger.js';
|
|
14
|
-
import { resolveTestData } from './module-resolver.js';
|
|
18
|
+
import { resolveTestData, validateActionTypes } from './module-resolver.js';
|
|
19
|
+
import { compareImages } from './visual-diff.js';
|
|
20
|
+
import { ensureProject, getVariables } from './db.js';
|
|
15
21
|
|
|
16
22
|
function sleep(ms) {
|
|
17
23
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -44,6 +50,46 @@ function mergeHooks(configHooks, suiteHooks) {
|
|
|
44
50
|
};
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
|
|
54
|
+
* Skips {{param}} patterns (no dot) — those are module params handled by module-resolver. */
|
|
55
|
+
function resolveVarsInAction(action, vars) {
|
|
56
|
+
const resolved = { ...action };
|
|
57
|
+
for (const key of Object.keys(resolved)) {
|
|
58
|
+
if (typeof resolved[key] !== 'string') continue;
|
|
59
|
+
resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
|
|
60
|
+
if (ns === 'env') {
|
|
61
|
+
if (process.env[name] !== undefined) return process.env[name];
|
|
62
|
+
throw new Error(`Unresolved variable: {{env.${name}}} — environment variable not set`);
|
|
63
|
+
}
|
|
64
|
+
// ns === 'var'
|
|
65
|
+
if (vars[name] !== undefined) return vars[name];
|
|
66
|
+
throw new Error(`Unresolved variable: {{var.${name}}} — not found in project or suite variables`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Loads merged variables for a test (project scope + suite scope overlay). */
|
|
73
|
+
function loadVarsForTest(config, suiteName) {
|
|
74
|
+
try {
|
|
75
|
+
const cwd = config._cwd || process.cwd();
|
|
76
|
+
const projectName = config.projectName || cwd.split('/').pop() || 'default';
|
|
77
|
+
const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
|
|
78
|
+
const projectVars = getVariables(projectId, 'project');
|
|
79
|
+
if (!suiteName) return projectVars;
|
|
80
|
+
const suiteVars = getVariables(projectId, suiteName);
|
|
81
|
+
return { ...projectVars, ...suiteVars };
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolves variables in an array of actions. */
|
|
88
|
+
function resolveVarsInActions(actions, vars) {
|
|
89
|
+
if (!actions || !actions.length) return actions;
|
|
90
|
+
return actions.map(a => resolveVarsInAction(a, vars));
|
|
91
|
+
}
|
|
92
|
+
|
|
47
93
|
/** Executes an array of hook actions on a page */
|
|
48
94
|
async function executeHookActions(page, actions, config) {
|
|
49
95
|
for (const action of actions) {
|
|
@@ -65,24 +111,45 @@ function normalizeTestData(data) {
|
|
|
65
111
|
return { tests: data.tests || [], hooks };
|
|
66
112
|
}
|
|
67
113
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
/** Extracts a value from an object using a dot-path (e.g. "data.token"). */
|
|
115
|
+
function getByPath(obj, dotPath) {
|
|
116
|
+
return dotPath.split('.').reduce((o, key) => o?.[key], obj);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Fetches an auth token by POSTing credentials to a login endpoint. */
|
|
120
|
+
export function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const url = new URL(endpoint);
|
|
123
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
124
|
+
const body = JSON.stringify(credentials);
|
|
125
|
+
|
|
126
|
+
const req = transport.request(url, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Accept': '*/*', 'User-Agent': '@matware/e2e-runner' },
|
|
129
|
+
timeout: 15000,
|
|
130
|
+
}, (res) => {
|
|
131
|
+
let data = '';
|
|
132
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
133
|
+
res.on('end', () => {
|
|
134
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
135
|
+
return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const json = JSON.parse(data);
|
|
139
|
+
const token = getByPath(json, tokenPath);
|
|
140
|
+
if (!token) {
|
|
141
|
+
return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
|
|
142
|
+
}
|
|
143
|
+
resolve(token);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
|
|
150
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
|
|
151
|
+
req.end(body);
|
|
152
|
+
});
|
|
86
153
|
}
|
|
87
154
|
|
|
88
155
|
/** Runs a single test end-to-end */
|
|
@@ -90,6 +157,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
90
157
|
let browser = null;
|
|
91
158
|
let context = null;
|
|
92
159
|
let page = null;
|
|
160
|
+
let cdpSession = null;
|
|
161
|
+
let appFork = null;
|
|
162
|
+
|
|
163
|
+
// ── Multi-tab registry ────────────────────────────────────────────────────
|
|
164
|
+
// Maps label → page. The "default" label is the initial page.
|
|
165
|
+
// activePage tracks the current tab; page always points to it.
|
|
166
|
+
const tabRegistry = new Map();
|
|
167
|
+
let activeTabLabel = 'default';
|
|
93
168
|
|
|
94
169
|
const result = {
|
|
95
170
|
name: test.name,
|
|
@@ -104,18 +179,76 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
104
179
|
const pendingBodies = [];
|
|
105
180
|
|
|
106
181
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
182
|
+
// Fork an isolated app instance if app pool is enabled
|
|
183
|
+
let effectiveConfig = config;
|
|
184
|
+
if (isAppPoolEnabled(config)) {
|
|
185
|
+
appFork = await forkAppInstance(config, test.name);
|
|
186
|
+
// Override baseUrl to point to this test's isolated app instance
|
|
187
|
+
// Use dockerBaseUrl when Chrome runs inside Docker (default setup)
|
|
188
|
+
effectiveConfig = { ...config, baseUrl: appFork.dockerBaseUrl };
|
|
189
|
+
result.appFork = { forkId: appFork.forkId, baseUrl: appFork.baseUrl, port: appFork.port, forkTimeMs: appFork.forkTimeMs };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
|
|
193
|
+
const chosenPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
|
|
194
|
+
result.poolUrl = chosenPool;
|
|
195
|
+
result.poolDriver = getCachedDriver(chosenPool);
|
|
196
|
+
const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
|
|
197
|
+
const isMultiPool = getPoolUrls(config).length > 1;
|
|
198
|
+
if (isMultiPool) {
|
|
199
|
+
log('🔗', `${C.cyan}${test.name}${C.reset} ${C.dim}→ ${poolLabel}${C.reset}`);
|
|
200
|
+
}
|
|
201
|
+
progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
|
|
202
|
+
browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
|
|
109
203
|
// Use incognito context for cookie isolation between concurrent tests
|
|
110
204
|
context = await browser.createBrowserContext();
|
|
111
205
|
page = await context.newPage();
|
|
112
206
|
await page.setViewport(config.viewport);
|
|
207
|
+
tabRegistry.set('default', page);
|
|
208
|
+
|
|
209
|
+
// CDP screencast — streams browser frames as JPEG to the dashboard
|
|
210
|
+
// Only attempt on browserless pools; generic CDP pools (Lightpanda) break on createCDPSession
|
|
211
|
+
const poolDriver = getCachedDriver(chosenPool);
|
|
212
|
+
if (config.screencast && poolDriver !== 'cdp') {
|
|
213
|
+
try {
|
|
214
|
+
const raceTimeout = (promise, ms) => Promise.race([
|
|
215
|
+
promise,
|
|
216
|
+
new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('CDP timeout')), ms); t.unref(); }),
|
|
217
|
+
]);
|
|
218
|
+
cdpSession = await raceTimeout(page.createCDPSession(), 5000);
|
|
219
|
+
let frameCount = 0;
|
|
220
|
+
const everyNth = config.screencastEveryNthFrame || 1;
|
|
221
|
+
cdpSession.on('Page.screencastFrame', (frame) => {
|
|
222
|
+
frameCount++;
|
|
223
|
+
cdpSession.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
|
|
224
|
+
if (everyNth > 1 && frameCount % everyNth !== 0) return;
|
|
225
|
+
progressFn({
|
|
226
|
+
event: 'test:frame',
|
|
227
|
+
name: test.name,
|
|
228
|
+
data: frame.data,
|
|
229
|
+
metadata: frame.metadata,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
await raceTimeout(cdpSession.send('Page.startScreencast', {
|
|
233
|
+
format: 'jpeg',
|
|
234
|
+
quality: config.screencastQuality || 60,
|
|
235
|
+
maxWidth: config.screencastMaxWidth || 800,
|
|
236
|
+
maxHeight: config.screencastMaxHeight || 600,
|
|
237
|
+
everyNthFrame: 1,
|
|
238
|
+
}), 5000);
|
|
239
|
+
} catch {
|
|
240
|
+
cdpSession = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
113
243
|
|
|
114
244
|
page.on('console', (msg) => {
|
|
115
245
|
result.consoleLogs.push({ type: msg.type(), text: msg.text() });
|
|
116
246
|
});
|
|
117
247
|
page.on('requestfailed', (req) => {
|
|
118
|
-
|
|
248
|
+
const url = req.url();
|
|
249
|
+
const ignoreDomains = config.networkIgnoreDomains || [];
|
|
250
|
+
if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
|
|
251
|
+
result.networkErrors.push({ url, error: req.failure()?.errorText });
|
|
119
252
|
});
|
|
120
253
|
|
|
121
254
|
const requestTimings = new Map();
|
|
@@ -149,15 +282,42 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
149
282
|
}
|
|
150
283
|
});
|
|
151
284
|
|
|
285
|
+
// Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
|
|
286
|
+
if (effectiveConfig.authToken) {
|
|
287
|
+
const storageKey = effectiveConfig.authStorageKey || 'accessToken';
|
|
288
|
+
await page.goto(effectiveConfig.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
289
|
+
await page.evaluate((key, token) => {
|
|
290
|
+
localStorage.setItem(key, token);
|
|
291
|
+
}, storageKey, config.authToken);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Resolve {{var.X}} and {{env.X}} in test actions and hooks
|
|
295
|
+
const vars = loadVarsForTest(config, config._suiteName);
|
|
296
|
+
if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
|
|
297
|
+
test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
|
|
298
|
+
if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
|
|
299
|
+
if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
|
|
300
|
+
}
|
|
301
|
+
|
|
152
302
|
// Run beforeEach hook
|
|
153
303
|
if (hooks.beforeEach?.length) {
|
|
154
|
-
await executeHookActions(page, hooks.beforeEach,
|
|
304
|
+
await executeHookActions(page, hooks.beforeEach, effectiveConfig);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
|
|
308
|
+
if (test.expect && page) {
|
|
309
|
+
try {
|
|
310
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
311
|
+
const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
|
|
312
|
+
await page.screenshot({ path: baselinePath, fullPage: true });
|
|
313
|
+
result.baselineScreenshot = baselinePath;
|
|
314
|
+
} catch { /* page may not be ready */ }
|
|
155
315
|
}
|
|
156
316
|
|
|
157
317
|
for (let i = 0; i < test.actions.length; i++) {
|
|
158
318
|
const action = test.actions[i];
|
|
159
|
-
const maxActionRetries = action.retries ??
|
|
160
|
-
const actionRetryDelay =
|
|
319
|
+
const maxActionRetries = action.retries ?? effectiveConfig.actionRetries ?? 0;
|
|
320
|
+
const actionRetryDelay = effectiveConfig.actionRetryDelay ?? 500;
|
|
161
321
|
let lastError = null;
|
|
162
322
|
|
|
163
323
|
for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
|
|
@@ -171,8 +331,113 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
171
331
|
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
172
332
|
}
|
|
173
333
|
actionResult = null;
|
|
334
|
+
|
|
335
|
+
// ── Multi-tab actions (intercepted here, not in actions.js) ──────
|
|
336
|
+
} else if (action.type === 'open_tab') {
|
|
337
|
+
const label = action.text || `tab-${tabRegistry.size}`;
|
|
338
|
+
const newPage = await context.newPage();
|
|
339
|
+
await newPage.setViewport(config.viewport);
|
|
340
|
+
tabRegistry.set(label, newPage);
|
|
341
|
+
activeTabLabel = label;
|
|
342
|
+
page = newPage;
|
|
343
|
+
// Navigate inside the new tab
|
|
344
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
345
|
+
|
|
346
|
+
} else if (action.type === 'switch_tab') {
|
|
347
|
+
// value: label, title regex, URL substring, or numeric index
|
|
348
|
+
const target = action.value;
|
|
349
|
+
let found = false;
|
|
350
|
+
|
|
351
|
+
// 1. By label (exact match)
|
|
352
|
+
if (tabRegistry.has(target)) {
|
|
353
|
+
page = tabRegistry.get(target);
|
|
354
|
+
activeTabLabel = target;
|
|
355
|
+
found = true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 2. By numeric index
|
|
359
|
+
if (!found && /^\d+$/.test(target)) {
|
|
360
|
+
const idx = parseInt(target);
|
|
361
|
+
const labels = [...tabRegistry.keys()];
|
|
362
|
+
if (idx >= 0 && idx < labels.length) {
|
|
363
|
+
activeTabLabel = labels[idx];
|
|
364
|
+
page = tabRegistry.get(activeTabLabel);
|
|
365
|
+
found = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 3. By title or URL match (substring or regex)
|
|
370
|
+
if (!found) {
|
|
371
|
+
for (const [label, p] of tabRegistry) {
|
|
372
|
+
try {
|
|
373
|
+
const title = await p.title();
|
|
374
|
+
const url = p.url();
|
|
375
|
+
const regex = new RegExp(target, 'i');
|
|
376
|
+
if (regex.test(title) || regex.test(url) || url.includes(target)) {
|
|
377
|
+
page = p;
|
|
378
|
+
activeTabLabel = label;
|
|
379
|
+
found = true;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
} catch { /* page may be closed */ }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!found) {
|
|
387
|
+
throw new Error(`switch_tab failed: no tab matching "${target}" (labels: ${[...tabRegistry.keys()].join(', ')})`);
|
|
388
|
+
}
|
|
389
|
+
// Bring tab to front
|
|
390
|
+
await page.bringToFront();
|
|
391
|
+
actionResult = null;
|
|
392
|
+
|
|
393
|
+
} else if (action.type === 'close_tab') {
|
|
394
|
+
const targetLabel = action.value || activeTabLabel;
|
|
395
|
+
if (targetLabel === 'default' && tabRegistry.size > 1) {
|
|
396
|
+
throw new Error('close_tab: cannot close the default tab while other tabs are open');
|
|
397
|
+
}
|
|
398
|
+
const targetPage = tabRegistry.get(targetLabel);
|
|
399
|
+
if (!targetPage) {
|
|
400
|
+
throw new Error(`close_tab failed: no tab with label "${targetLabel}"`);
|
|
401
|
+
}
|
|
402
|
+
tabRegistry.delete(targetLabel);
|
|
403
|
+
if (!targetPage.isClosed()) {
|
|
404
|
+
await targetPage.close();
|
|
405
|
+
}
|
|
406
|
+
// Switch to the last remaining tab
|
|
407
|
+
if (activeTabLabel === targetLabel) {
|
|
408
|
+
const remaining = [...tabRegistry.keys()];
|
|
409
|
+
activeTabLabel = remaining[remaining.length - 1] || 'default';
|
|
410
|
+
page = tabRegistry.get(activeTabLabel);
|
|
411
|
+
if (page) await page.bringToFront();
|
|
412
|
+
}
|
|
413
|
+
actionResult = null;
|
|
414
|
+
|
|
415
|
+
} else if (action.type === 'assert_tab_count') {
|
|
416
|
+
action.__tabCount = tabRegistry.size;
|
|
417
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
418
|
+
|
|
419
|
+
} else if (action.type === 'wait_for_tab') {
|
|
420
|
+
// Wait for a new tab/popup to be opened (e.g. by window.open, target=_blank)
|
|
421
|
+
const label = action.text || `tab-${tabRegistry.size}`;
|
|
422
|
+
const waitTimeout = action.timeout || config.defaultTimeout || 10000;
|
|
423
|
+
const newTarget = await new Promise((resolve, reject) => {
|
|
424
|
+
const timer = setTimeout(() => reject(new Error(`wait_for_tab: no new tab appeared after ${waitTimeout}ms`)), waitTimeout);
|
|
425
|
+
context.once('targetcreated', (target) => {
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
resolve(target);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
const newPage = await newTarget.page();
|
|
431
|
+
if (newPage) {
|
|
432
|
+
await newPage.setViewport(config.viewport);
|
|
433
|
+
tabRegistry.set(label, newPage);
|
|
434
|
+
activeTabLabel = label;
|
|
435
|
+
page = newPage;
|
|
436
|
+
}
|
|
437
|
+
actionResult = null;
|
|
438
|
+
|
|
174
439
|
} else {
|
|
175
|
-
actionResult = await executeAction(page, action,
|
|
440
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
176
441
|
}
|
|
177
442
|
const actionDuration = Date.now() - actionStart;
|
|
178
443
|
const actionEntry = {
|
|
@@ -221,15 +486,40 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
221
486
|
result.expect = test.expect;
|
|
222
487
|
try {
|
|
223
488
|
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
224
|
-
const verifyPath = path.join(
|
|
489
|
+
const verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
|
|
225
490
|
await page.screenshot({ path: verifyPath, fullPage: true });
|
|
226
491
|
result.verificationScreenshot = verifyPath;
|
|
492
|
+
|
|
493
|
+
// Auto visual comparison: compare baseline vs verification screenshot
|
|
494
|
+
if (result.baselineScreenshot && result.verificationScreenshot) {
|
|
495
|
+
try {
|
|
496
|
+
const diffPath = path.join(effectiveConfig.screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
|
|
497
|
+
const threshold = effectiveConfig.verificationThreshold ?? 0.02;
|
|
498
|
+
const visualResult = compareImages(result.baselineScreenshot, result.verificationScreenshot, {
|
|
499
|
+
threshold: 0.1,
|
|
500
|
+
diffOutputPath: diffPath,
|
|
501
|
+
maskRegions: test.expect?.maskRegions || [],
|
|
502
|
+
});
|
|
503
|
+
result.visualDiff = {
|
|
504
|
+
diffPercentage: visualResult.diffPercentage,
|
|
505
|
+
differentPixels: visualResult.differentPixels,
|
|
506
|
+
totalPixels: visualResult.totalPixels,
|
|
507
|
+
matchPercentage: visualResult.matchPercentage,
|
|
508
|
+
diffImagePath: visualResult.diffImagePath,
|
|
509
|
+
threshold,
|
|
510
|
+
passed: visualResult.diffPercentage <= threshold,
|
|
511
|
+
};
|
|
512
|
+
if (result.visualDiff.diffImagePath) {
|
|
513
|
+
result.diffScreenshot = result.visualDiff.diffImagePath;
|
|
514
|
+
}
|
|
515
|
+
} catch { /* visual diff is best-effort, never blocks the test */ }
|
|
516
|
+
}
|
|
227
517
|
} catch { /* page may be dead */ }
|
|
228
518
|
}
|
|
229
519
|
|
|
230
520
|
// Run afterEach hook (success path)
|
|
231
521
|
if (hooks.afterEach?.length) {
|
|
232
|
-
await executeHookActions(page, hooks.afterEach,
|
|
522
|
+
await executeHookActions(page, hooks.afterEach, effectiveConfig);
|
|
233
523
|
}
|
|
234
524
|
} catch (error) {
|
|
235
525
|
result.success = false;
|
|
@@ -237,7 +527,7 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
237
527
|
|
|
238
528
|
// Run afterEach hook (failure path)
|
|
239
529
|
if (page && hooks.afterEach?.length) {
|
|
240
|
-
try { await executeHookActions(page, hooks.afterEach,
|
|
530
|
+
try { await executeHookActions(page, hooks.afterEach, effectiveConfig); } catch { /* */ }
|
|
241
531
|
}
|
|
242
532
|
|
|
243
533
|
if (page) {
|
|
@@ -249,6 +539,11 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
249
539
|
} catch { /* page may be dead */ }
|
|
250
540
|
}
|
|
251
541
|
} finally {
|
|
542
|
+
// Stop screencast before disconnecting
|
|
543
|
+
if (cdpSession) {
|
|
544
|
+
try { await cdpSession.send('Page.stopScreencast'); } catch { /* */ }
|
|
545
|
+
try { await cdpSession.detach(); } catch { /* */ }
|
|
546
|
+
}
|
|
252
547
|
// Flush pending response body reads before disconnecting
|
|
253
548
|
if (pendingBodies.length > 0) {
|
|
254
549
|
try { await Promise.allSettled(pendingBodies); } catch { /* */ }
|
|
@@ -261,16 +556,77 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
261
556
|
try { await context.close(); } catch { /* */ }
|
|
262
557
|
}
|
|
263
558
|
if (browser) {
|
|
264
|
-
try { browser.
|
|
559
|
+
try { await disconnectFromPool(browser, result.poolUrl); } catch { /* */ }
|
|
560
|
+
}
|
|
561
|
+
// Release local pending counter so selectPool() knows this slot is free
|
|
562
|
+
if (result.poolUrl) {
|
|
563
|
+
releasePending(result.poolUrl);
|
|
564
|
+
}
|
|
565
|
+
// Destroy the app fork after the test completes
|
|
566
|
+
if (appFork) {
|
|
567
|
+
try { await destroyFork(appFork.forkId); } catch { /* best effort */ }
|
|
265
568
|
}
|
|
266
569
|
}
|
|
267
570
|
|
|
268
571
|
return result;
|
|
269
572
|
}
|
|
270
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Majority voting — runs a test N times in parallel and uses majority vote for pass/fail.
|
|
576
|
+
* If majority passes but not unanimously, marks as flaky.
|
|
577
|
+
*/
|
|
578
|
+
async function runTestWithVoting(test, config, hooks, votingCount, testTimeout, progressFn) {
|
|
579
|
+
const votes = [];
|
|
580
|
+
const promises = [];
|
|
581
|
+
|
|
582
|
+
for (let v = 0; v < votingCount; v++) {
|
|
583
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
584
|
+
const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
|
|
585
|
+
timer.unref();
|
|
586
|
+
});
|
|
587
|
+
promises.push(
|
|
588
|
+
Promise.race([runTest(test, config, hooks, progressFn), timeoutPromise])
|
|
589
|
+
.catch(error => ({
|
|
590
|
+
name: test.name,
|
|
591
|
+
startTime: new Date().toISOString(),
|
|
592
|
+
endTime: new Date().toISOString(),
|
|
593
|
+
actions: [],
|
|
594
|
+
success: false,
|
|
595
|
+
error: error.message,
|
|
596
|
+
consoleLogs: [],
|
|
597
|
+
networkErrors: [],
|
|
598
|
+
networkLogs: [],
|
|
599
|
+
}))
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const results = await Promise.all(promises);
|
|
604
|
+
const passCount = results.filter(r => r.success).length;
|
|
605
|
+
const majorityPassed = passCount > votingCount / 2;
|
|
606
|
+
|
|
607
|
+
// Pick the representative result: a passing one if majority passed, failing one otherwise
|
|
608
|
+
const representative = majorityPassed
|
|
609
|
+
? results.find(r => r.success) || results[0]
|
|
610
|
+
: results.find(r => !r.success) || results[0];
|
|
611
|
+
|
|
612
|
+
const result = { ...representative };
|
|
613
|
+
result.success = majorityPassed;
|
|
614
|
+
result.voting = { total: votingCount, passed: passCount, failed: votingCount - passCount };
|
|
615
|
+
result.attempt = 1;
|
|
616
|
+
result.maxAttempts = 1;
|
|
617
|
+
|
|
618
|
+
// Non-unanimous pass = flaky
|
|
619
|
+
if (majorityPassed && passCount < votingCount) {
|
|
620
|
+
result.flaky = true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
|
|
271
626
|
/** Runs tests in parallel with limited concurrency, retries, timeouts, and hooks */
|
|
272
627
|
export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
273
628
|
const hooks = mergeHooks(config.hooks, suiteHooks);
|
|
629
|
+
const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
|
|
274
630
|
|
|
275
631
|
// Run beforeAll hook
|
|
276
632
|
if (hooks.beforeAll?.length) {
|
|
@@ -283,7 +639,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
283
639
|
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
284
640
|
let browser = null;
|
|
285
641
|
try {
|
|
286
|
-
|
|
642
|
+
const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
|
|
643
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
287
644
|
const page = await browser.newPage();
|
|
288
645
|
await page.setViewport(config.viewport);
|
|
289
646
|
await executeHookActions(page, hooks.beforeAll, config);
|
|
@@ -292,7 +649,42 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
292
649
|
log('❌', `${C.red}beforeAll hook failed: ${error.message}${C.reset}`);
|
|
293
650
|
throw error;
|
|
294
651
|
} finally {
|
|
295
|
-
if (browser) try { browser
|
|
652
|
+
if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Auto-login: fetch auth token via API if configured and not already provided
|
|
657
|
+
if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
|
|
658
|
+
log('🔑', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
|
|
659
|
+
try {
|
|
660
|
+
config.authToken = await fetchAuthToken(
|
|
661
|
+
config.authLoginEndpoint,
|
|
662
|
+
config.authCredentials,
|
|
663
|
+
config.authTokenPath || 'token'
|
|
664
|
+
);
|
|
665
|
+
log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
// Docker-internal hostname (nginx, api, etc.) → retry with localhost from host machine
|
|
668
|
+
if (error.message && error.message.includes('ENOTFOUND')) {
|
|
669
|
+
const url = new URL(config.authLoginEndpoint);
|
|
670
|
+
if (!url.hostname.includes('.')) {
|
|
671
|
+
const localhostUrl = `http://localhost${url.port && url.port !== '80' ? ':' + url.port : ''}${url.pathname}${url.search}`;
|
|
672
|
+
log('🔄', `${C.dim}Docker hostname "${url.hostname}" not reachable from host, retrying with ${localhostUrl}...${C.reset}`);
|
|
673
|
+
try {
|
|
674
|
+
config.authToken = await fetchAuthToken(localhostUrl, config.authCredentials, config.authTokenPath || 'token');
|
|
675
|
+
log('✅', `${C.dim}Auth token acquired via localhost fallback (${config.authToken.length} chars)${C.reset}`);
|
|
676
|
+
} catch (retryErr) {
|
|
677
|
+
log('❌', `${C.red}Auth auto-login failed (localhost fallback): ${retryErr.message}${C.reset}`);
|
|
678
|
+
throw retryErr;
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
296
688
|
}
|
|
297
689
|
}
|
|
298
690
|
|
|
@@ -323,54 +715,65 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
323
715
|
log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
324
716
|
_progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
|
|
325
717
|
|
|
326
|
-
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
327
718
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
719
|
+
const votingCount = test.voting ?? config.voting ?? 0;
|
|
720
|
+
const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
|
|
328
721
|
let result;
|
|
329
722
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
723
|
+
if (votingCount > 1) {
|
|
724
|
+
// Majority voting: run N times in parallel, majority wins
|
|
725
|
+
log('🗳️', `${C.dim}${test.name}: voting ${votingCount}x in parallel${C.reset}`);
|
|
726
|
+
result = await runTestWithVoting(test, config, testHooks, votingCount, testTimeout, _progress);
|
|
727
|
+
} else {
|
|
728
|
+
// Standard sequential retry
|
|
729
|
+
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
730
|
+
|
|
731
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
732
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
733
|
+
const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
|
|
734
|
+
timer.unref();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
try {
|
|
738
|
+
result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
result = {
|
|
741
|
+
name: test.name,
|
|
742
|
+
startTime: new Date().toISOString(),
|
|
743
|
+
endTime: new Date().toISOString(),
|
|
744
|
+
actions: [],
|
|
745
|
+
success: false,
|
|
746
|
+
error: error.message,
|
|
747
|
+
consoleLogs: [],
|
|
748
|
+
networkErrors: [],
|
|
749
|
+
networkLogs: [],
|
|
750
|
+
};
|
|
751
|
+
}
|
|
352
752
|
|
|
353
|
-
|
|
354
|
-
|
|
753
|
+
result.attempt = attempt;
|
|
754
|
+
result.maxAttempts = maxAttempts;
|
|
355
755
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
756
|
+
if (result.success || attempt === maxAttempts) break;
|
|
757
|
+
log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
|
|
758
|
+
_progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
|
|
759
|
+
await sleep(config.retryDelay || 1000);
|
|
760
|
+
}
|
|
360
761
|
}
|
|
361
762
|
|
|
362
763
|
results.push(result);
|
|
363
764
|
activeCount--;
|
|
364
765
|
|
|
365
766
|
const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
|
|
366
|
-
_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 });
|
|
767
|
+
_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 });
|
|
367
768
|
|
|
368
769
|
if (result.success) {
|
|
369
|
-
const
|
|
370
|
-
|
|
770
|
+
const votingInfo = result.voting ? ` ${C.yellow}(voting: ${result.voting.passed}/${result.voting.total} passed${result.flaky ? ', flaky' : ''})${C.reset}` : '';
|
|
771
|
+
const retryInfo = !result.voting && result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
772
|
+
log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${votingInfo}${retryInfo}`);
|
|
371
773
|
} else {
|
|
372
|
-
const
|
|
373
|
-
|
|
774
|
+
const votingInfo = result.voting ? ` (voting: ${result.voting.passed}/${result.voting.total} passed)` : '';
|
|
775
|
+
const attempts = !result.voting && result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
|
|
776
|
+
log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${votingInfo}${attempts}`);
|
|
374
777
|
}
|
|
375
778
|
|
|
376
779
|
const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
|
|
@@ -401,7 +804,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
401
804
|
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
402
805
|
let browser = null;
|
|
403
806
|
try {
|
|
404
|
-
|
|
807
|
+
const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
|
|
808
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
405
809
|
const page = await browser.newPage();
|
|
406
810
|
await page.setViewport(config.viewport);
|
|
407
811
|
await executeHookActions(page, hooks.afterAll, config);
|
|
@@ -409,7 +813,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
409
813
|
} catch (error) {
|
|
410
814
|
log('⚠️', `${C.yellow}afterAll hook failed: ${error.message}${C.reset}`);
|
|
411
815
|
} finally {
|
|
412
|
-
if (browser) try { browser
|
|
816
|
+
if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
|
|
413
817
|
}
|
|
414
818
|
}
|
|
415
819
|
|
|
@@ -429,7 +833,9 @@ export function loadTestFile(filePath, modulesDir) {
|
|
|
429
833
|
}
|
|
430
834
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
431
835
|
const normalized = normalizeTestData(data);
|
|
432
|
-
|
|
836
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
837
|
+
validateActionTypes(resolved, path.basename(filePath));
|
|
838
|
+
return resolved;
|
|
433
839
|
}
|
|
434
840
|
|
|
435
841
|
/** Loads a test suite by name — returns { tests, hooks } */
|
|
@@ -446,7 +852,9 @@ export function loadTestSuite(suiteName, testsDir, modulesDir) {
|
|
|
446
852
|
|
|
447
853
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
448
854
|
const normalized = normalizeTestData(data);
|
|
449
|
-
|
|
855
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
856
|
+
validateActionTypes(resolved, match);
|
|
857
|
+
return resolved;
|
|
450
858
|
}
|
|
451
859
|
|
|
452
860
|
/** Loads all test suites from the tests directory — returns { tests, hooks } */
|
|
@@ -466,6 +874,7 @@ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
|
|
|
466
874
|
if (modulesDir) {
|
|
467
875
|
({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
|
|
468
876
|
}
|
|
877
|
+
validateActionTypes({ tests, hooks }, file);
|
|
469
878
|
// Tag each test with its own suite's hooks so they're preserved
|
|
470
879
|
for (const t of tests) {
|
|
471
880
|
t._suiteHooks = hooks;
|