@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/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 (const action of test.actions) {
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: Date.now() - actionStart,
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: Date.now() - actionStart,
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 errorScreenshot = path.join(config.screenshotsDir, `error-${test.name}-${Date.now()}.png`);
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
+ }
@@ -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
+ }