@matware/e2e-runner 1.1.0 → 1.2.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/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +505 -279
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +275 -7
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +11 -3
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +280 -17
- package/src/ai-generate.js +122 -11
- package/src/config.js +58 -0
- package/src/dashboard.js +173 -10
- package/src/db.js +232 -17
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +575 -16
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +47 -2
- package/src/runner.js +180 -40
- package/src/verify.js +19 -5
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +1091 -268
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
package/src/runner.js
CHANGED
|
@@ -9,12 +9,24 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
|
|
11
11
|
import { executeAction } from './actions.js';
|
|
12
|
+
import { narrateAction } from './narrate.js';
|
|
12
13
|
import { log, colors as C } from './logger.js';
|
|
14
|
+
import { resolveTestData } from './module-resolver.js';
|
|
13
15
|
|
|
14
16
|
function sleep(ms) {
|
|
15
17
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** Simple glob matching with * wildcards for exclude patterns. */
|
|
21
|
+
function matchesExclude(filename, excludePatterns) {
|
|
22
|
+
if (!excludePatterns?.length) return false;
|
|
23
|
+
const name = filename.replace('.json', '');
|
|
24
|
+
return excludePatterns.some(pattern => {
|
|
25
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
26
|
+
return regex.test(name) || regex.test(filename);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
function timeDiff(start, end) {
|
|
19
31
|
const ms = new Date(end) - new Date(start);
|
|
20
32
|
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -44,7 +56,13 @@ function normalizeTestData(data) {
|
|
|
44
56
|
if (Array.isArray(data)) {
|
|
45
57
|
return { tests: data, hooks: {} };
|
|
46
58
|
}
|
|
47
|
-
|
|
59
|
+
// Support hooks nested under "hooks" key or directly at root level
|
|
60
|
+
const hooks = data.hooks || {};
|
|
61
|
+
if (!hooks.beforeAll && data.beforeAll) hooks.beforeAll = data.beforeAll;
|
|
62
|
+
if (!hooks.afterAll && data.afterAll) hooks.afterAll = data.afterAll;
|
|
63
|
+
if (!hooks.beforeEach && data.beforeEach) hooks.beforeEach = data.beforeEach;
|
|
64
|
+
if (!hooks.afterEach && data.afterEach) hooks.afterEach = data.afterEach;
|
|
65
|
+
return { tests: data.tests || [], hooks };
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
/** Waits until the pool has capacity before connecting */
|
|
@@ -70,6 +88,7 @@ async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
|
|
|
70
88
|
/** Runs a single test end-to-end */
|
|
71
89
|
export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
72
90
|
let browser = null;
|
|
91
|
+
let context = null;
|
|
73
92
|
let page = null;
|
|
74
93
|
|
|
75
94
|
const result = {
|
|
@@ -80,12 +99,16 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
80
99
|
error: null,
|
|
81
100
|
consoleLogs: [],
|
|
82
101
|
networkErrors: [],
|
|
102
|
+
networkLogs: [],
|
|
83
103
|
};
|
|
104
|
+
const pendingBodies = [];
|
|
84
105
|
|
|
85
106
|
try {
|
|
86
107
|
await waitForSlot(config.poolUrl);
|
|
87
108
|
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
88
|
-
|
|
109
|
+
// Use incognito context for cookie isolation between concurrent tests
|
|
110
|
+
context = await browser.createBrowserContext();
|
|
111
|
+
page = await context.newPage();
|
|
89
112
|
await page.setViewport(config.viewport);
|
|
90
113
|
|
|
91
114
|
page.on('console', (msg) => {
|
|
@@ -95,6 +118,37 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
95
118
|
result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
|
|
96
119
|
});
|
|
97
120
|
|
|
121
|
+
const requestTimings = new Map();
|
|
122
|
+
page.on('request', (req) => {
|
|
123
|
+
const rt = req.resourceType();
|
|
124
|
+
if (rt === 'xhr' || rt === 'fetch') requestTimings.set(req, Date.now());
|
|
125
|
+
});
|
|
126
|
+
page.on('response', (resp) => {
|
|
127
|
+
const req = resp.request();
|
|
128
|
+
const startMs = requestTimings.get(req);
|
|
129
|
+
if (startMs !== undefined) {
|
|
130
|
+
requestTimings.delete(req);
|
|
131
|
+
const entry = {
|
|
132
|
+
url: req.url(),
|
|
133
|
+
method: req.method(),
|
|
134
|
+
status: resp.status(),
|
|
135
|
+
statusText: resp.statusText(),
|
|
136
|
+
duration: Date.now() - startMs,
|
|
137
|
+
requestHeaders: req.headers(),
|
|
138
|
+
requestBody: null,
|
|
139
|
+
responseHeaders: resp.headers(),
|
|
140
|
+
responseBody: null,
|
|
141
|
+
};
|
|
142
|
+
try { entry.requestBody = req.postData() || null; } catch { /* */ }
|
|
143
|
+
result.networkLogs.push(entry);
|
|
144
|
+
// Read response body async — collect promise for later flush
|
|
145
|
+
const bodyPromise = resp.text().then(body => {
|
|
146
|
+
entry.responseBody = body && body.length > 51200 ? body.slice(0, 51200) + '\n...[truncated]' : body;
|
|
147
|
+
}).catch(() => { /* response may be unavailable */ });
|
|
148
|
+
pendingBodies.push(bodyPromise);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
98
152
|
// Run beforeEach hook
|
|
99
153
|
if (hooks.beforeEach?.length) {
|
|
100
154
|
await executeHookActions(page, hooks.beforeEach, config);
|
|
@@ -102,30 +156,77 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
102
156
|
|
|
103
157
|
for (let i = 0; i < test.actions.length; i++) {
|
|
104
158
|
const action = test.actions[i];
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
159
|
+
const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
|
|
160
|
+
const actionRetryDelay = config.actionRetryDelay ?? 500;
|
|
161
|
+
let lastError = null;
|
|
162
|
+
|
|
163
|
+
for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
|
|
164
|
+
const actionStart = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
let actionResult;
|
|
167
|
+
if (action.type === 'assert_no_network_errors') {
|
|
168
|
+
// Handled inline — needs access to result.networkErrors
|
|
169
|
+
if (result.networkErrors.length > 0) {
|
|
170
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
171
|
+
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
172
|
+
}
|
|
173
|
+
actionResult = null;
|
|
174
|
+
} else {
|
|
175
|
+
actionResult = await executeAction(page, action, config);
|
|
176
|
+
}
|
|
177
|
+
const actionDuration = Date.now() - actionStart;
|
|
178
|
+
const actionEntry = {
|
|
179
|
+
...action,
|
|
180
|
+
success: true,
|
|
181
|
+
duration: actionDuration,
|
|
182
|
+
result: actionResult,
|
|
183
|
+
};
|
|
184
|
+
if (attempt > 0) actionEntry.actionRetries = attempt;
|
|
185
|
+
actionEntry.narrative = narrateAction(action, actionEntry);
|
|
186
|
+
result.actions.push(actionEntry);
|
|
187
|
+
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 });
|
|
188
|
+
lastError = null;
|
|
189
|
+
break;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
lastError = error;
|
|
192
|
+
if (attempt < maxActionRetries) {
|
|
193
|
+
log('🔄', `${C.dim}Action ${action.type} retry ${attempt + 1}/${maxActionRetries} (${error.message})${C.reset}`);
|
|
194
|
+
await sleep(actionRetryDelay);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const actionDuration = Date.now() - actionStart;
|
|
198
|
+
const failedEntry = {
|
|
199
|
+
...action,
|
|
200
|
+
success: false,
|
|
201
|
+
duration: actionDuration,
|
|
202
|
+
error: error.message,
|
|
203
|
+
};
|
|
204
|
+
if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
|
|
205
|
+
failedEntry.narrative = narrateAction(action, failedEntry);
|
|
206
|
+
result.actions.push(failedEntry);
|
|
207
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
126
210
|
}
|
|
127
211
|
}
|
|
128
212
|
|
|
213
|
+
// Fail the test if failOnNetworkError is enabled and network errors occurred
|
|
214
|
+
if (config.failOnNetworkError && result.networkErrors.length > 0) {
|
|
215
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
216
|
+
throw new Error(`Network errors detected (failOnNetworkError=true): ${result.networkErrors.length} error(s): ${summary}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Auto-capture verification screenshot if test has "expect"
|
|
220
|
+
if (test.expect && page) {
|
|
221
|
+
result.expect = test.expect;
|
|
222
|
+
try {
|
|
223
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
224
|
+
const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
|
|
225
|
+
await page.screenshot({ path: verifyPath, fullPage: true });
|
|
226
|
+
result.verificationScreenshot = verifyPath;
|
|
227
|
+
} catch { /* page may be dead */ }
|
|
228
|
+
}
|
|
229
|
+
|
|
129
230
|
// Run afterEach hook (success path)
|
|
130
231
|
if (hooks.afterEach?.length) {
|
|
131
232
|
await executeHookActions(page, hooks.afterEach, config);
|
|
@@ -148,10 +249,17 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
148
249
|
} catch { /* page may be dead */ }
|
|
149
250
|
}
|
|
150
251
|
} finally {
|
|
252
|
+
// Flush pending response body reads before disconnecting
|
|
253
|
+
if (pendingBodies.length > 0) {
|
|
254
|
+
try { await Promise.allSettled(pendingBodies); } catch { /* */ }
|
|
255
|
+
}
|
|
151
256
|
result.endTime = new Date().toISOString();
|
|
152
257
|
if (page) {
|
|
153
258
|
try { result.finalUrl = page.url(); } catch { /* */ }
|
|
154
259
|
}
|
|
260
|
+
if (context) {
|
|
261
|
+
try { await context.close(); } catch { /* */ }
|
|
262
|
+
}
|
|
155
263
|
if (browser) {
|
|
156
264
|
try { browser.disconnect(); } catch { /* */ }
|
|
157
265
|
}
|
|
@@ -166,6 +274,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
166
274
|
|
|
167
275
|
// Run beforeAll hook
|
|
168
276
|
if (hooks.beforeAll?.length) {
|
|
277
|
+
const stateActions = hooks.beforeAll.filter(a =>
|
|
278
|
+
['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
|
|
279
|
+
);
|
|
280
|
+
if (stateActions.length > 0) {
|
|
281
|
+
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}`);
|
|
282
|
+
}
|
|
169
283
|
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
170
284
|
let browser = null;
|
|
171
285
|
try {
|
|
@@ -186,11 +300,20 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
186
300
|
const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
187
301
|
const _proj = config.projectName || null;
|
|
188
302
|
const _cwd = config._cwd || null;
|
|
189
|
-
const
|
|
303
|
+
const _triggeredBy = config.triggeredBy || 'unknown';
|
|
304
|
+
const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
|
|
305
|
+
|
|
306
|
+
// Split serial and parallel tests
|
|
307
|
+
const parallelTests = tests.filter(t => !t.serial);
|
|
308
|
+
const serialTests = tests.filter(t => t.serial);
|
|
309
|
+
if (serialTests.length > 0) {
|
|
310
|
+
log('🔒', `${C.dim}${serialTests.length} serial test(s) will run after parallel batch${C.reset}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
190
313
|
_progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
|
|
191
314
|
|
|
192
315
|
const results = [];
|
|
193
|
-
const queue = [...
|
|
316
|
+
const queue = [...parallelTests];
|
|
194
317
|
let activeCount = 0;
|
|
195
318
|
|
|
196
319
|
const worker = async () => {
|
|
@@ -198,7 +321,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
198
321
|
const test = queue.shift();
|
|
199
322
|
activeCount++;
|
|
200
323
|
log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
201
|
-
_progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
|
|
324
|
+
_progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
|
|
202
325
|
|
|
203
326
|
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
204
327
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
@@ -211,7 +334,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
211
334
|
});
|
|
212
335
|
|
|
213
336
|
try {
|
|
214
|
-
|
|
337
|
+
const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
|
|
338
|
+
result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
|
|
215
339
|
} catch (error) {
|
|
216
340
|
result = {
|
|
217
341
|
name: test.name,
|
|
@@ -222,6 +346,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
222
346
|
error: error.message,
|
|
223
347
|
consoleLogs: [],
|
|
224
348
|
networkErrors: [],
|
|
349
|
+
networkLogs: [],
|
|
225
350
|
};
|
|
226
351
|
}
|
|
227
352
|
|
|
@@ -238,7 +363,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
238
363
|
activeCount--;
|
|
239
364
|
|
|
240
365
|
const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
|
|
241
|
-
_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, errorScreenshot: result.errorScreenshot, screenshots });
|
|
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 });
|
|
242
367
|
|
|
243
368
|
if (result.success) {
|
|
244
369
|
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
@@ -259,11 +384,18 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
259
384
|
};
|
|
260
385
|
|
|
261
386
|
const workers = [];
|
|
262
|
-
for (let i = 0; i < Math.min(concurrency,
|
|
387
|
+
for (let i = 0; i < Math.min(concurrency, parallelTests.length); i++) {
|
|
263
388
|
workers.push(worker());
|
|
264
389
|
}
|
|
265
390
|
await Promise.all(workers);
|
|
266
391
|
|
|
392
|
+
// Run serial tests one at a time
|
|
393
|
+
if (serialTests.length > 0) {
|
|
394
|
+
log('🔒', `${C.dim}Running ${serialTests.length} serial test(s)...${C.reset}`);
|
|
395
|
+
queue.push(...serialTests);
|
|
396
|
+
await worker();
|
|
397
|
+
}
|
|
398
|
+
|
|
267
399
|
// Run afterAll hook
|
|
268
400
|
if (hooks.afterAll?.length) {
|
|
269
401
|
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
@@ -291,16 +423,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
291
423
|
}
|
|
292
424
|
|
|
293
425
|
/** Loads tests from a JSON file — returns { tests, hooks } */
|
|
294
|
-
export function loadTestFile(filePath) {
|
|
426
|
+
export function loadTestFile(filePath, modulesDir) {
|
|
295
427
|
if (!fs.existsSync(filePath)) {
|
|
296
428
|
throw new Error(`File not found: ${filePath}`);
|
|
297
429
|
}
|
|
298
430
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
299
|
-
|
|
431
|
+
const normalized = normalizeTestData(data);
|
|
432
|
+
return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
300
433
|
}
|
|
301
434
|
|
|
302
435
|
/** Loads a test suite by name — returns { tests, hooks } */
|
|
303
|
-
export function loadTestSuite(suiteName, testsDir) {
|
|
436
|
+
export function loadTestSuite(suiteName, testsDir, modulesDir) {
|
|
304
437
|
// Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
|
|
305
438
|
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
|
|
306
439
|
const exact = files.find(f => f === `${suiteName}.json`);
|
|
@@ -312,29 +445,36 @@ export function loadTestSuite(suiteName, testsDir) {
|
|
|
312
445
|
}
|
|
313
446
|
|
|
314
447
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
315
|
-
|
|
448
|
+
const normalized = normalizeTestData(data);
|
|
449
|
+
return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
316
450
|
}
|
|
317
451
|
|
|
318
452
|
/** Loads all test suites from the tests directory — returns { tests, hooks } */
|
|
319
|
-
export function loadAllSuites(testsDir) {
|
|
453
|
+
export function loadAllSuites(testsDir, modulesDir, exclude = []) {
|
|
320
454
|
if (!fs.existsSync(testsDir)) {
|
|
321
455
|
throw new Error(`Tests directory not found: ${testsDir}`);
|
|
322
456
|
}
|
|
323
457
|
|
|
324
|
-
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
458
|
+
const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
|
|
459
|
+
.filter(f => !matchesExclude(f, exclude));
|
|
325
460
|
let allTests = [];
|
|
326
|
-
let mergedHooks = {};
|
|
327
461
|
|
|
328
462
|
for (const file of files) {
|
|
329
463
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
|
|
330
|
-
|
|
464
|
+
let { tests, hooks } = normalizeTestData(data);
|
|
465
|
+
// Resolve modules per-suite before concatenating
|
|
466
|
+
if (modulesDir) {
|
|
467
|
+
({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
|
|
468
|
+
}
|
|
469
|
+
// Tag each test with its own suite's hooks so they're preserved
|
|
470
|
+
for (const t of tests) {
|
|
471
|
+
t._suiteHooks = hooks;
|
|
472
|
+
}
|
|
331
473
|
allTests = allTests.concat(tests);
|
|
332
|
-
// Last suite's hooks win for each non-empty key
|
|
333
|
-
mergedHooks = mergeHooks(mergedHooks, hooks);
|
|
334
474
|
log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
|
|
335
475
|
}
|
|
336
476
|
|
|
337
|
-
return { tests: allTests, hooks:
|
|
477
|
+
return { tests: allTests, hooks: {} };
|
|
338
478
|
}
|
|
339
479
|
|
|
340
480
|
/** Lists all available test suites */
|
package/src/verify.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function verifyIssue(url, config) {
|
|
|
25
25
|
const issue = fetchIssue(url);
|
|
26
26
|
|
|
27
27
|
// 2. Generate tests via Claude API
|
|
28
|
-
const { tests, suiteName } = await generateTests(issue, config);
|
|
28
|
+
const { tests, suiteName } = await generateTests(issue, config, config.testType || 'e2e');
|
|
29
29
|
|
|
30
30
|
// 3. Save tests to a temp file (underscore prefix for cleanup identification)
|
|
31
31
|
const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
|
|
@@ -35,19 +35,33 @@ export async function verifyIssue(url, config) {
|
|
|
35
35
|
fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
// 4.
|
|
38
|
+
// 4. Build hooks (inject auth if provided)
|
|
39
|
+
const hooks = {};
|
|
40
|
+
if (config.authToken) {
|
|
41
|
+
const esc = s => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
42
|
+
const storageKey = esc(config.authStorageKey || 'accessToken');
|
|
43
|
+
const token = esc(config.authToken);
|
|
44
|
+
hooks.beforeEach = [
|
|
45
|
+
{ type: 'goto', value: '/' },
|
|
46
|
+
{ type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${token}')` },
|
|
47
|
+
{ type: 'goto', value: '/' },
|
|
48
|
+
{ type: 'wait', value: '1000' },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 5. Wait for pool and run
|
|
39
53
|
await waitForPool(config.poolUrl);
|
|
40
|
-
const results = await runTestsParallel(tests, config,
|
|
54
|
+
const results = await runTestsParallel(tests, config, hooks);
|
|
41
55
|
const report = generateReport(results);
|
|
42
56
|
saveReport(report, config.screenshotsDir, config);
|
|
43
57
|
persistRun(report, config, suiteName);
|
|
44
58
|
|
|
45
|
-
//
|
|
59
|
+
// 6. Interpret results
|
|
46
60
|
const bugConfirmed = report.summary.failed > 0;
|
|
47
61
|
|
|
48
62
|
return { issue, report, bugConfirmed, tests, suiteName };
|
|
49
63
|
} finally {
|
|
50
|
-
//
|
|
64
|
+
// 7. Clean up temp file
|
|
51
65
|
try { fs.unlinkSync(testFile); } catch { /* already gone */ }
|
|
52
66
|
}
|
|
53
67
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build script — concatenates dashboard/template.html + dashboard/styles.css + dashboard/app.js
|
|
4
|
+
* into a single dashboard.html file.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node templates/build-dashboard.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const dashDir = path.join(__dirname, 'dashboard');
|
|
15
|
+
|
|
16
|
+
const template = fs.readFileSync(path.join(dashDir, 'template.html'), 'utf-8');
|
|
17
|
+
const styles = fs.readFileSync(path.join(dashDir, 'styles.css'), 'utf-8');
|
|
18
|
+
const script = fs.readFileSync(path.join(dashDir, 'app.js'), 'utf-8');
|
|
19
|
+
|
|
20
|
+
const output = template
|
|
21
|
+
.replace('/* __STYLES__ */', () => styles)
|
|
22
|
+
.replace('/* __SCRIPT__ */', () => script);
|
|
23
|
+
|
|
24
|
+
const outPath = path.join(__dirname, 'dashboard.html');
|
|
25
|
+
fs.writeFileSync(outPath, output);
|
|
26
|
+
|
|
27
|
+
const lines = output.split('\n').length;
|
|
28
|
+
console.log(`Built ${outPath} (${lines} lines)`);
|