@matware/e2e-runner 1.0.3 → 1.1.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/README.md +150 -8
- package/bin/cli.js +242 -2
- package/package.json +6 -3
- package/src/actions.js +28 -4
- package/src/ai-generate.js +216 -0
- package/src/config.js +44 -0
- package/src/dashboard.js +559 -0
- package/src/db.js +387 -0
- package/src/index.js +5 -1
- package/src/issues.js +152 -0
- package/src/mcp-server.js +8 -337
- package/src/mcp-tools.js +656 -0
- package/src/reporter.js +85 -2
- package/src/runner.js +119 -9
- package/src/verify.js +65 -0
- package/src/websocket.js +177 -0
- package/templates/dashboard.html +1281 -0
- package/templates/e2e.config.js +3 -0
package/src/reporter.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { colors as C } from './logger.js';
|
|
8
|
+
import { ensureProject, saveRun as saveRunToDb } from './db.js';
|
|
8
9
|
|
|
9
10
|
function escapeXml(str) {
|
|
10
11
|
return String(str)
|
|
@@ -14,6 +15,10 @@ function escapeXml(str) {
|
|
|
14
15
|
.replace(/"/g, '"');
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function escapeCdata(str) {
|
|
19
|
+
return String(str).replace(/\]\]>/g, ']]]]><![CDATA[>');
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/** Generates a report object from test results */
|
|
18
23
|
export function generateReport(results) {
|
|
19
24
|
const passed = results.filter(r => r.success).length;
|
|
@@ -52,12 +57,12 @@ export function generateJUnitXML(report) {
|
|
|
52
57
|
|
|
53
58
|
const logs = (result.consoleLogs || []).map(l => `[${l.type}] ${l.text}`).join('\n');
|
|
54
59
|
if (logs) {
|
|
55
|
-
xml += ` <system-out><![CDATA[${logs}]]></system-out>\n`;
|
|
60
|
+
xml += ` <system-out><![CDATA[${escapeCdata(logs)}]]></system-out>\n`;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
const netErrors = (result.networkErrors || []).map(e => `[${e.error || 'unknown'}] ${e.url}`).join('\n');
|
|
59
64
|
if (netErrors) {
|
|
60
|
-
xml += ` <system-err><![CDATA[${netErrors}]]></system-err>\n`;
|
|
65
|
+
xml += ` <system-err><![CDATA[${escapeCdata(netErrors)}]]></system-err>\n`;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
xml += ' </testcase>\n';
|
|
@@ -89,6 +94,72 @@ export function saveReport(report, screenshotsDir, config = {}) {
|
|
|
89
94
|
return saved.length === 1 ? saved[0] : saved;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/** Saves a run to history */
|
|
98
|
+
export function saveHistory(report, screenshotsDir, maxRuns = 100) {
|
|
99
|
+
const historyDir = path.join(screenshotsDir, 'history');
|
|
100
|
+
if (!fs.existsSync(historyDir)) {
|
|
101
|
+
fs.mkdirSync(historyDir, { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const runId = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, '');
|
|
105
|
+
const filename = `run-${runId}.json`;
|
|
106
|
+
const entry = { ...report, runId };
|
|
107
|
+
fs.writeFileSync(path.join(historyDir, filename), JSON.stringify(entry, null, 2));
|
|
108
|
+
|
|
109
|
+
// Auto-prune old runs
|
|
110
|
+
const files = fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json')).sort();
|
|
111
|
+
while (files.length > maxRuns) {
|
|
112
|
+
fs.unlinkSync(path.join(historyDir, files.shift()));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return runId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Loads history summaries (newest first) */
|
|
119
|
+
export function loadHistory(screenshotsDir) {
|
|
120
|
+
const historyDir = path.join(screenshotsDir, 'history');
|
|
121
|
+
if (!fs.existsSync(historyDir)) return [];
|
|
122
|
+
|
|
123
|
+
return fs.readdirSync(historyDir)
|
|
124
|
+
.filter(f => f.startsWith('run-') && f.endsWith('.json'))
|
|
125
|
+
.sort()
|
|
126
|
+
.reverse()
|
|
127
|
+
.map(f => {
|
|
128
|
+
const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
|
|
129
|
+
return { runId: data.runId, summary: data.summary, generatedAt: data.generatedAt };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Loads a full history run by runId */
|
|
134
|
+
export function loadHistoryRun(screenshotsDir, runId) {
|
|
135
|
+
const historyDir = path.join(screenshotsDir, 'history');
|
|
136
|
+
const files = fs.existsSync(historyDir)
|
|
137
|
+
? fs.readdirSync(historyDir).filter(f => f.startsWith('run-') && f.endsWith('.json'))
|
|
138
|
+
: [];
|
|
139
|
+
|
|
140
|
+
const match = files.find(f => {
|
|
141
|
+
const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), 'utf-8'));
|
|
142
|
+
return data.runId === runId;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!match) return null;
|
|
146
|
+
return JSON.parse(fs.readFileSync(path.join(historyDir, match), 'utf-8'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Persists a run to both filesystem history and SQLite (never throws). */
|
|
150
|
+
export function persistRun(report, config, suiteName) {
|
|
151
|
+
const runId = saveHistory(report, config.screenshotsDir, config.maxHistoryRuns);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
155
|
+
saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return runId;
|
|
161
|
+
}
|
|
162
|
+
|
|
92
163
|
/** Prints a formatted report summary to the console */
|
|
93
164
|
export function printReport(report, screenshotsDir) {
|
|
94
165
|
const { summary } = report;
|
|
@@ -139,6 +210,18 @@ export function printReport(report, screenshotsDir) {
|
|
|
139
210
|
});
|
|
140
211
|
}
|
|
141
212
|
|
|
213
|
+
const networkRequests = report.results.filter(r => r.networkLogs?.length > 0);
|
|
214
|
+
if (networkRequests.length > 0) {
|
|
215
|
+
console.log(`\n${C.cyan}${C.bold}NETWORK REQUESTS:${C.reset}`);
|
|
216
|
+
networkRequests.forEach(r => {
|
|
217
|
+
console.log(` ${C.cyan}▸${C.reset} ${r.name}:`);
|
|
218
|
+
r.networkLogs.forEach(n => {
|
|
219
|
+
const statusColor = n.status < 300 ? C.green : n.status < 400 ? C.yellow : C.red;
|
|
220
|
+
console.log(` ${C.dim}${n.method}${C.reset} ${statusColor}${n.status}${C.reset} ${n.url} ${C.dim}(${n.duration}ms)${C.reset}`);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
142
225
|
if (screenshotsDir) {
|
|
143
226
|
console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
|
|
144
227
|
console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
|
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
|
|
|
@@ -60,9 +80,12 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
60
80
|
error: null,
|
|
61
81
|
consoleLogs: [],
|
|
62
82
|
networkErrors: [],
|
|
83
|
+
networkLogs: [],
|
|
63
84
|
};
|
|
85
|
+
const pendingBodies = [];
|
|
64
86
|
|
|
65
87
|
try {
|
|
88
|
+
await waitForSlot(config.poolUrl);
|
|
66
89
|
browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
|
|
67
90
|
page = await browser.newPage();
|
|
68
91
|
await page.setViewport(config.viewport);
|
|
@@ -74,32 +97,95 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
74
97
|
result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
|
|
75
98
|
});
|
|
76
99
|
|
|
100
|
+
const requestTimings = new Map();
|
|
101
|
+
page.on('request', (req) => {
|
|
102
|
+
const rt = req.resourceType();
|
|
103
|
+
if (rt === 'xhr' || rt === 'fetch') requestTimings.set(req, Date.now());
|
|
104
|
+
});
|
|
105
|
+
page.on('response', (resp) => {
|
|
106
|
+
const req = resp.request();
|
|
107
|
+
const startMs = requestTimings.get(req);
|
|
108
|
+
if (startMs !== undefined) {
|
|
109
|
+
requestTimings.delete(req);
|
|
110
|
+
const entry = {
|
|
111
|
+
url: req.url(),
|
|
112
|
+
method: req.method(),
|
|
113
|
+
status: resp.status(),
|
|
114
|
+
statusText: resp.statusText(),
|
|
115
|
+
duration: Date.now() - startMs,
|
|
116
|
+
requestHeaders: req.headers(),
|
|
117
|
+
requestBody: null,
|
|
118
|
+
responseHeaders: resp.headers(),
|
|
119
|
+
responseBody: null,
|
|
120
|
+
};
|
|
121
|
+
try { entry.requestBody = req.postData() || null; } catch { /* */ }
|
|
122
|
+
result.networkLogs.push(entry);
|
|
123
|
+
// Read response body async — collect promise for later flush
|
|
124
|
+
const bodyPromise = resp.text().then(body => {
|
|
125
|
+
entry.responseBody = body && body.length > 51200 ? body.slice(0, 51200) + '\n...[truncated]' : body;
|
|
126
|
+
}).catch(() => { /* response may be unavailable */ });
|
|
127
|
+
pendingBodies.push(bodyPromise);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
77
131
|
// Run beforeEach hook
|
|
78
132
|
if (hooks.beforeEach?.length) {
|
|
79
133
|
await executeHookActions(page, hooks.beforeEach, config);
|
|
80
134
|
}
|
|
81
135
|
|
|
82
|
-
for (
|
|
136
|
+
for (let i = 0; i < test.actions.length; i++) {
|
|
137
|
+
const action = test.actions[i];
|
|
83
138
|
const actionStart = Date.now();
|
|
84
139
|
try {
|
|
85
|
-
|
|
140
|
+
let actionResult;
|
|
141
|
+
if (action.type === 'assert_no_network_errors') {
|
|
142
|
+
// Handled inline — needs access to result.networkErrors
|
|
143
|
+
if (result.networkErrors.length > 0) {
|
|
144
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
145
|
+
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
146
|
+
}
|
|
147
|
+
actionResult = null;
|
|
148
|
+
} else {
|
|
149
|
+
actionResult = await executeAction(page, action, config);
|
|
150
|
+
}
|
|
151
|
+
const actionDuration = Date.now() - actionStart;
|
|
86
152
|
result.actions.push({
|
|
87
153
|
...action,
|
|
88
154
|
success: true,
|
|
89
|
-
duration:
|
|
155
|
+
duration: actionDuration,
|
|
90
156
|
result: actionResult,
|
|
91
157
|
});
|
|
158
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
|
|
92
159
|
} catch (error) {
|
|
160
|
+
const actionDuration = Date.now() - actionStart;
|
|
93
161
|
result.actions.push({
|
|
94
162
|
...action,
|
|
95
163
|
success: false,
|
|
96
|
-
duration:
|
|
164
|
+
duration: actionDuration,
|
|
97
165
|
error: error.message,
|
|
98
166
|
});
|
|
167
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
|
|
99
168
|
throw error;
|
|
100
169
|
}
|
|
101
170
|
}
|
|
102
171
|
|
|
172
|
+
// Fail the test if failOnNetworkError is enabled and network errors occurred
|
|
173
|
+
if (config.failOnNetworkError && result.networkErrors.length > 0) {
|
|
174
|
+
const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
|
|
175
|
+
throw new Error(`Network errors detected (failOnNetworkError=true): ${result.networkErrors.length} error(s): ${summary}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Auto-capture verification screenshot if test has "expect"
|
|
179
|
+
if (test.expect && page) {
|
|
180
|
+
result.expect = test.expect;
|
|
181
|
+
try {
|
|
182
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
183
|
+
const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
|
|
184
|
+
await page.screenshot({ path: verifyPath, fullPage: true });
|
|
185
|
+
result.verificationScreenshot = verifyPath;
|
|
186
|
+
} catch { /* page may be dead */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
103
189
|
// Run afterEach hook (success path)
|
|
104
190
|
if (hooks.afterEach?.length) {
|
|
105
191
|
await executeHookActions(page, hooks.afterEach, config);
|
|
@@ -115,12 +201,17 @@ export async function runTest(test, config, hooks = {}) {
|
|
|
115
201
|
|
|
116
202
|
if (page) {
|
|
117
203
|
try {
|
|
118
|
-
const
|
|
204
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
205
|
+
const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
|
|
119
206
|
await page.screenshot({ path: errorScreenshot, fullPage: true });
|
|
120
207
|
result.errorScreenshot = errorScreenshot;
|
|
121
208
|
} catch { /* page may be dead */ }
|
|
122
209
|
}
|
|
123
210
|
} finally {
|
|
211
|
+
// Flush pending response body reads before disconnecting
|
|
212
|
+
if (pendingBodies.length > 0) {
|
|
213
|
+
try { await Promise.allSettled(pendingBodies); } catch { /* */ }
|
|
214
|
+
}
|
|
124
215
|
result.endTime = new Date().toISOString();
|
|
125
216
|
if (page) {
|
|
126
217
|
try { result.finalUrl = page.url(); } catch { /* */ }
|
|
@@ -155,6 +246,14 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
155
246
|
}
|
|
156
247
|
}
|
|
157
248
|
|
|
249
|
+
const concurrency = config.concurrency || 3;
|
|
250
|
+
const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
251
|
+
const _proj = config.projectName || null;
|
|
252
|
+
const _cwd = config._cwd || null;
|
|
253
|
+
const _triggeredBy = config.triggeredBy || 'unknown';
|
|
254
|
+
const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
|
|
255
|
+
_progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
|
|
256
|
+
|
|
158
257
|
const results = [];
|
|
159
258
|
const queue = [...tests];
|
|
160
259
|
let activeCount = 0;
|
|
@@ -164,6 +263,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
164
263
|
const test = queue.shift();
|
|
165
264
|
activeCount++;
|
|
166
265
|
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 });
|
|
167
267
|
|
|
168
268
|
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
169
269
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
@@ -176,7 +276,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
176
276
|
});
|
|
177
277
|
|
|
178
278
|
try {
|
|
179
|
-
result = await Promise.race([runTest(test, config, hooks), timeoutPromise]);
|
|
279
|
+
result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
|
|
180
280
|
} catch (error) {
|
|
181
281
|
result = {
|
|
182
282
|
name: test.name,
|
|
@@ -187,6 +287,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
187
287
|
error: error.message,
|
|
188
288
|
consoleLogs: [],
|
|
189
289
|
networkErrors: [],
|
|
290
|
+
networkLogs: [],
|
|
190
291
|
};
|
|
191
292
|
}
|
|
192
293
|
|
|
@@ -195,12 +296,16 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
195
296
|
|
|
196
297
|
if (result.success || attempt === maxAttempts) break;
|
|
197
298
|
log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
|
|
299
|
+
_progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
|
|
198
300
|
await sleep(config.retryDelay || 1000);
|
|
199
301
|
}
|
|
200
302
|
|
|
201
303
|
results.push(result);
|
|
202
304
|
activeCount--;
|
|
203
305
|
|
|
306
|
+
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 });
|
|
308
|
+
|
|
204
309
|
if (result.success) {
|
|
205
310
|
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
206
311
|
log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
|
|
@@ -219,7 +324,6 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
219
324
|
}
|
|
220
325
|
};
|
|
221
326
|
|
|
222
|
-
const concurrency = config.concurrency || 3;
|
|
223
327
|
const workers = [];
|
|
224
328
|
for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
|
|
225
329
|
workers.push(worker());
|
|
@@ -243,6 +347,12 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
243
347
|
}
|
|
244
348
|
}
|
|
245
349
|
|
|
350
|
+
{
|
|
351
|
+
const passed = results.filter(r => r.success).length;
|
|
352
|
+
const failed = results.filter(r => !r.success).length;
|
|
353
|
+
_progress({ event: 'run:complete', summary: { total: results.length, passed, failed } });
|
|
354
|
+
}
|
|
355
|
+
|
|
246
356
|
return results;
|
|
247
357
|
}
|
|
248
358
|
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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. Build hooks (inject auth if provided)
|
|
39
|
+
const hooks = {};
|
|
40
|
+
if (config.authToken) {
|
|
41
|
+
const storageKey = config.authStorageKey || 'accessToken';
|
|
42
|
+
hooks.beforeEach = [
|
|
43
|
+
{ type: 'goto', value: '/' },
|
|
44
|
+
{ type: 'evaluate', value: `localStorage.setItem('${storageKey}', '${config.authToken}')` },
|
|
45
|
+
{ type: 'goto', value: '/' },
|
|
46
|
+
{ type: 'wait', value: '1000' },
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 5. Wait for pool and run
|
|
51
|
+
await waitForPool(config.poolUrl);
|
|
52
|
+
const results = await runTestsParallel(tests, config, hooks);
|
|
53
|
+
const report = generateReport(results);
|
|
54
|
+
saveReport(report, config.screenshotsDir, config);
|
|
55
|
+
persistRun(report, config, suiteName);
|
|
56
|
+
|
|
57
|
+
// 6. Interpret results
|
|
58
|
+
const bugConfirmed = report.summary.failed > 0;
|
|
59
|
+
|
|
60
|
+
return { issue, report, bugConfirmed, tests, suiteName };
|
|
61
|
+
} finally {
|
|
62
|
+
// 7. Clean up temp file
|
|
63
|
+
try { fs.unlinkSync(testFile); } catch { /* already gone */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
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
|
+
}
|