@matware/e2e-runner 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -3
- package/bin/cli.js +161 -1
- package/package.json +5 -2
- package/src/actions.js +17 -1
- package/src/ai-generate.js +185 -0
- package/src/config.js +14 -0
- package/src/dashboard.js +546 -0
- package/src/db.js +366 -0
- package/src/index.js +5 -1
- package/src/issues.js +152 -0
- package/src/mcp-server.js +8 -328
- package/src/mcp-tools.js +527 -0
- package/src/reporter.js +102 -1
- package/src/runner.js +60 -8
- package/src/verify.js +53 -0
- package/src/websocket.js +177 -0
- package/templates/dashboard.html +1044 -0
- package/templates/e2e.config.js +3 -0
package/src/runner.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { connectToPool, waitForPool } from './pool.js';
|
|
10
|
+
import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
|
|
11
11
|
import { executeAction } from './actions.js';
|
|
12
12
|
import { log, colors as C } from './logger.js';
|
|
13
13
|
|
|
@@ -47,8 +47,28 @@ function normalizeTestData(data) {
|
|
|
47
47
|
return { tests: data.tests || [], hooks: data.hooks || {} };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Waits until the pool has capacity before connecting */
|
|
51
|
+
async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
while (Date.now() - start < maxWaitMs) {
|
|
54
|
+
try {
|
|
55
|
+
const status = await getPoolStatus(poolUrl);
|
|
56
|
+
if (status.available && status.running < status.maxConcurrent) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
|
|
60
|
+
} catch {
|
|
61
|
+
// Pool unreachable, let connectToPool handle the error
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await sleep(pollIntervalMs);
|
|
65
|
+
}
|
|
66
|
+
// Timeout — proceed anyway and let connectToPool deal with it
|
|
67
|
+
log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
/** Runs a single test end-to-end */
|
|
51
|
-
export async function runTest(test, config, hooks = {}) {
|
|
71
|
+
export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
52
72
|
let browser = null;
|
|
53
73
|
let page = null;
|
|
54
74
|
|
|
@@ -63,6 +83,7 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
63
83
|
};
|
|
64
84
|
|
|
65
85
|
try {
|
|
86
|
+
await waitForSlot(config.poolUrl);
|
|
66
87
|
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
67
88
|
page = await browser.newPage();
|
|
68
89
|
await page.setViewport(config.viewport);
|
|
@@ -79,23 +100,28 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
79
100
|
await executeHookActions(page, hooks.beforeEach, config);
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
for (
|
|
103
|
+
for (let i = 0; i < test.actions.length; i++) {
|
|
104
|
+
const action = test.actions[i];
|
|
83
105
|
const actionStart = Date.now();
|
|
84
106
|
try {
|
|
85
107
|
const actionResult = await executeAction(page, action, config);
|
|
108
|
+
const actionDuration = Date.now() - actionStart;
|
|
86
109
|
result.actions.push({
|
|
87
110
|
...action,
|
|
88
111
|
success: true,
|
|
89
|
-
duration:
|
|
112
|
+
duration: actionDuration,
|
|
90
113
|
result: actionResult,
|
|
91
114
|
});
|
|
115
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
|
|
92
116
|
} catch (error) {
|
|
117
|
+
const actionDuration = Date.now() - actionStart;
|
|
93
118
|
result.actions.push({
|
|
94
119
|
...action,
|
|
95
120
|
success: false,
|
|
96
|
-
duration:
|
|
121
|
+
duration: actionDuration,
|
|
97
122
|
error: error.message,
|
|
98
123
|
});
|
|
124
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
|
|
99
125
|
throw error;
|
|
100
126
|
}
|
|
101
127
|
}
|
|
@@ -115,7 +141,8 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
115
141
|
|
|
116
142
|
if (page) {
|
|
117
143
|
try {
|
|
118
|
-
const
|
|
144
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
145
|
+
const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
|
|
119
146
|
await page.screenshot({ path: errorScreenshot, fullPage: true });
|
|
120
147
|
result.errorScreenshot = errorScreenshot;
|
|
121
148
|
} catch { /* page may be dead */ }
|
|
@@ -155,6 +182,13 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
155
182
|
}
|
|
156
183
|
}
|
|
157
184
|
|
|
185
|
+
const concurrency = config.concurrency || 3;
|
|
186
|
+
const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
187
|
+
const _proj = config.projectName || null;
|
|
188
|
+
const _cwd = config._cwd || null;
|
|
189
|
+
const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd });
|
|
190
|
+
_progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
|
|
191
|
+
|
|
158
192
|
const results = [];
|
|
159
193
|
const queue = [...tests];
|
|
160
194
|
let activeCount = 0;
|
|
@@ -164,6 +198,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
164
198
|
const test = queue.shift();
|
|
165
199
|
activeCount++;
|
|
166
200
|
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 });
|
|
167
202
|
|
|
168
203
|
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
169
204
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
@@ -176,7 +211,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
176
211
|
});
|
|
177
212
|
|
|
178
213
|
try {
|
|
179
|
-
result = await Promise.race([runTest(test, config, hooks), timeoutPromise]);
|
|
214
|
+
result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
|
|
180
215
|
} catch (error) {
|
|
181
216
|
result = {
|
|
182
217
|
name: test.name,
|
|
@@ -195,12 +230,16 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
195
230
|
|
|
196
231
|
if (result.success || attempt === maxAttempts) break;
|
|
197
232
|
log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
|
|
233
|
+
_progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
|
|
198
234
|
await sleep(config.retryDelay || 1000);
|
|
199
235
|
}
|
|
200
236
|
|
|
201
237
|
results.push(result);
|
|
202
238
|
activeCount--;
|
|
203
239
|
|
|
240
|
+
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 });
|
|
242
|
+
|
|
204
243
|
if (result.success) {
|
|
205
244
|
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
206
245
|
log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
|
|
@@ -208,10 +247,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
208
247
|
const attempts = result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
|
|
209
248
|
log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${attempts}`);
|
|
210
249
|
}
|
|
250
|
+
|
|
251
|
+
const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
|
|
252
|
+
if (consoleIssues > 0) {
|
|
253
|
+
log('⚠️', `${C.yellow}${test.name}: ${consoleIssues} console ${consoleIssues === 1 ? 'issue' : 'issues'}${C.reset}`);
|
|
254
|
+
}
|
|
255
|
+
if (result.networkErrors?.length > 0) {
|
|
256
|
+
log('⚠️', `${C.yellow}${test.name}: ${result.networkErrors.length} network ${result.networkErrors.length === 1 ? 'error' : 'errors'}${C.reset}`);
|
|
257
|
+
}
|
|
211
258
|
}
|
|
212
259
|
};
|
|
213
260
|
|
|
214
|
-
const concurrency = config.concurrency || 3;
|
|
215
261
|
const workers = [];
|
|
216
262
|
for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
|
|
217
263
|
workers.push(worker());
|
|
@@ -235,6 +281,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
235
281
|
}
|
|
236
282
|
}
|
|
237
283
|
|
|
284
|
+
{
|
|
285
|
+
const passed = results.filter(r => r.success).length;
|
|
286
|
+
const failed = results.filter(r => !r.success).length;
|
|
287
|
+
_progress({ event: 'run:complete', summary: { total: results.length, passed, failed } });
|
|
288
|
+
}
|
|
289
|
+
|
|
238
290
|
return results;
|
|
239
291
|
}
|
|
240
292
|
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bug Verification Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Combines issue fetch + AI test generation + test execution into a single pipeline.
|
|
5
|
+
* Tests assert CORRECT behavior: failures = bug confirmed, all pass = not reproducible.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fetchIssue } from './issues.js';
|
|
11
|
+
import { generateTests } from './ai-generate.js';
|
|
12
|
+
import { waitForPool } from './pool.js';
|
|
13
|
+
import { runTestsParallel } from './runner.js';
|
|
14
|
+
import { generateReport, saveReport, persistRun } from './reporter.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetches an issue, generates tests via Claude API, runs them, and reports whether the bug is confirmed.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} url - GitHub or GitLab issue URL
|
|
20
|
+
* @param {object} config - Loaded config (must include anthropicApiKey or ANTHROPIC_API_KEY env)
|
|
21
|
+
* @returns {Promise<{ issue: object, report: object, bugConfirmed: boolean, tests: object[], suiteName: string }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function verifyIssue(url, config) {
|
|
24
|
+
// 1. Fetch issue
|
|
25
|
+
const issue = fetchIssue(url);
|
|
26
|
+
|
|
27
|
+
// 2. Generate tests via Claude API
|
|
28
|
+
const { tests, suiteName } = await generateTests(issue, config);
|
|
29
|
+
|
|
30
|
+
// 3. Save tests to a temp file (underscore prefix for cleanup identification)
|
|
31
|
+
const testFile = path.join(config.testsDir, `_verify-${suiteName}.json`);
|
|
32
|
+
if (!fs.existsSync(config.testsDir)) {
|
|
33
|
+
fs.mkdirSync(config.testsDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// 4. Wait for pool and run
|
|
39
|
+
await waitForPool(config.poolUrl);
|
|
40
|
+
const results = await runTestsParallel(tests, config, {});
|
|
41
|
+
const report = generateReport(results);
|
|
42
|
+
saveReport(report, config.screenshotsDir, config);
|
|
43
|
+
persistRun(report, config, suiteName);
|
|
44
|
+
|
|
45
|
+
// 5. Interpret results
|
|
46
|
+
const bugConfirmed = report.summary.failed > 0;
|
|
47
|
+
|
|
48
|
+
return { issue, report, bugConfirmed, tests, suiteName };
|
|
49
|
+
} finally {
|
|
50
|
+
// 6. Clean up temp file
|
|
51
|
+
try { fs.unlinkSync(testFile); } catch { /* already gone */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/websocket.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal WebSocket server — RFC 6455 text frames only, no external deps.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const wss = createWebSocketServer(httpServer);
|
|
6
|
+
* wss.broadcast(JSON.stringify({ event: 'hello' }));
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
|
|
11
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
12
|
+
const OP_TEXT = 0x01;
|
|
13
|
+
const OP_CLOSE = 0x08;
|
|
14
|
+
const OP_PING = 0x09;
|
|
15
|
+
const OP_PONG = 0x0a;
|
|
16
|
+
|
|
17
|
+
function acceptKey(key) {
|
|
18
|
+
return crypto.createHash('sha1').update(key + GUID).digest('base64');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function encodeFrame(data, opcode = OP_TEXT) {
|
|
22
|
+
const buf = Buffer.from(data, 'utf-8');
|
|
23
|
+
const len = buf.length;
|
|
24
|
+
let header;
|
|
25
|
+
|
|
26
|
+
if (len < 126) {
|
|
27
|
+
header = Buffer.alloc(2);
|
|
28
|
+
header[0] = 0x80 | opcode;
|
|
29
|
+
header[1] = len;
|
|
30
|
+
} else if (len < 65536) {
|
|
31
|
+
header = Buffer.alloc(4);
|
|
32
|
+
header[0] = 0x80 | opcode;
|
|
33
|
+
header[1] = 126;
|
|
34
|
+
header.writeUInt16BE(len, 2);
|
|
35
|
+
} else {
|
|
36
|
+
header = Buffer.alloc(10);
|
|
37
|
+
header[0] = 0x80 | opcode;
|
|
38
|
+
header[1] = 127;
|
|
39
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Buffer.concat([header, buf]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decodeFrame(buffer) {
|
|
46
|
+
if (buffer.length < 2) return null;
|
|
47
|
+
|
|
48
|
+
const firstByte = buffer[0];
|
|
49
|
+
const opcode = firstByte & 0x0f;
|
|
50
|
+
const secondByte = buffer[1];
|
|
51
|
+
const masked = (secondByte & 0x80) !== 0;
|
|
52
|
+
let payloadLen = secondByte & 0x7f;
|
|
53
|
+
let offset = 2;
|
|
54
|
+
|
|
55
|
+
if (payloadLen === 126) {
|
|
56
|
+
if (buffer.length < 4) return null;
|
|
57
|
+
payloadLen = buffer.readUInt16BE(2);
|
|
58
|
+
offset = 4;
|
|
59
|
+
} else if (payloadLen === 127) {
|
|
60
|
+
if (buffer.length < 10) return null;
|
|
61
|
+
payloadLen = Number(buffer.readBigUInt64BE(2));
|
|
62
|
+
offset = 10;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (masked) {
|
|
66
|
+
if (buffer.length < offset + 4 + payloadLen) return null;
|
|
67
|
+
const mask = buffer.slice(offset, offset + 4);
|
|
68
|
+
offset += 4;
|
|
69
|
+
const payload = Buffer.alloc(payloadLen);
|
|
70
|
+
for (let i = 0; i < payloadLen; i++) {
|
|
71
|
+
payload[i] = buffer[offset + i] ^ mask[i % 4];
|
|
72
|
+
}
|
|
73
|
+
return { opcode, payload, totalLength: offset + payloadLen };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (buffer.length < offset + payloadLen) return null;
|
|
77
|
+
return { opcode, payload: buffer.slice(offset, offset + payloadLen), totalLength: offset + payloadLen };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createWebSocketServer(httpServer, options = {}) {
|
|
81
|
+
const clients = new Set();
|
|
82
|
+
|
|
83
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
84
|
+
// Validate Origin to prevent cross-site WebSocket hijacking
|
|
85
|
+
const origin = req.headers.origin;
|
|
86
|
+
if (origin && options.allowedOrigins) {
|
|
87
|
+
if (!options.allowedOrigins.includes(origin)) {
|
|
88
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
89
|
+
socket.destroy();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const key = req.headers['sec-websocket-key'];
|
|
95
|
+
if (!key) {
|
|
96
|
+
socket.destroy();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const accept = acceptKey(key);
|
|
101
|
+
socket.write(
|
|
102
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
103
|
+
'Upgrade: websocket\r\n' +
|
|
104
|
+
'Connection: Upgrade\r\n' +
|
|
105
|
+
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
106
|
+
'\r\n'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
clients.add(socket);
|
|
110
|
+
let recvBuffer = Buffer.alloc(0);
|
|
111
|
+
const MAX_BUFFER = 1024 * 1024; // 1MB max receive buffer
|
|
112
|
+
|
|
113
|
+
socket.on('data', (data) => {
|
|
114
|
+
recvBuffer = Buffer.concat([recvBuffer, data]);
|
|
115
|
+
if (recvBuffer.length > MAX_BUFFER) {
|
|
116
|
+
clients.delete(socket);
|
|
117
|
+
try { socket.write(encodeFrame('', OP_CLOSE)); } catch { /* */ }
|
|
118
|
+
socket.destroy();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
while (recvBuffer.length > 0) {
|
|
123
|
+
const frame = decodeFrame(recvBuffer);
|
|
124
|
+
if (!frame) break;
|
|
125
|
+
|
|
126
|
+
recvBuffer = recvBuffer.slice(frame.totalLength);
|
|
127
|
+
|
|
128
|
+
switch (frame.opcode) {
|
|
129
|
+
case OP_TEXT:
|
|
130
|
+
// We don't process client messages for now
|
|
131
|
+
break;
|
|
132
|
+
case OP_PING:
|
|
133
|
+
try { socket.write(encodeFrame(frame.payload.toString(), OP_PONG)); } catch { /* */ }
|
|
134
|
+
break;
|
|
135
|
+
case OP_CLOSE:
|
|
136
|
+
try { socket.write(encodeFrame('', OP_CLOSE)); } catch { /* */ }
|
|
137
|
+
socket.end();
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
socket.on('close', () => clients.delete(socket));
|
|
144
|
+
socket.on('error', () => clients.delete(socket));
|
|
145
|
+
|
|
146
|
+
// Send connected event
|
|
147
|
+
try {
|
|
148
|
+
socket.write(encodeFrame(JSON.stringify({ event: 'connected', timestamp: new Date().toISOString() })));
|
|
149
|
+
} catch { /* */ }
|
|
150
|
+
|
|
151
|
+
// Notify listener of new connection (for replaying state)
|
|
152
|
+
if (options.onConnect) {
|
|
153
|
+
try { options.onConnect(socket); } catch { /* */ }
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
broadcast(data) {
|
|
159
|
+
const frame = encodeFrame(data);
|
|
160
|
+
for (const client of clients) {
|
|
161
|
+
try { client.write(frame); } catch { clients.delete(client); }
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
sendTo(socket, data) {
|
|
165
|
+
try { socket.write(encodeFrame(data)); } catch { /* */ }
|
|
166
|
+
},
|
|
167
|
+
get clientCount() {
|
|
168
|
+
return clients.size;
|
|
169
|
+
},
|
|
170
|
+
close() {
|
|
171
|
+
for (const client of clients) {
|
|
172
|
+
try { client.write(encodeFrame('', OP_CLOSE)); client.end(); } catch { /* */ }
|
|
173
|
+
}
|
|
174
|
+
clients.clear();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|