@matware/e2e-runner 1.1.0 → 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/src/mcp-tools.js CHANGED
@@ -13,11 +13,11 @@ import path from 'path';
13
13
  import http from 'http';
14
14
 
15
15
  import { loadConfig } from './config.js';
16
- import { waitForPool, getPoolStatus } from './pool.js';
16
+ import { waitForPool, getPoolStatus, connectToPool } from './pool.js';
17
17
  import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
18
18
  import { generateReport, saveReport, persistRun } from './reporter.js';
19
19
  import { startDashboard, stopDashboard } from './dashboard.js';
20
- import { lookupScreenshotHash } from './db.js';
20
+ import { lookupScreenshotHash, ensureProject, computeScreenshotHash, registerScreenshotHash } from './db.js';
21
21
  import { fetchIssue, checkCliAuth, detectProvider } from './issues.js';
22
22
  import { buildPrompt, hasApiKey } from './ai-generate.js';
23
23
  import { verifyIssue } from './verify.js';
@@ -56,6 +56,10 @@ export const TOOLS = [
56
56
  type: 'number',
57
57
  description: 'Number of retries for failed tests',
58
58
  },
59
+ failOnNetworkError: {
60
+ type: 'boolean',
61
+ description: 'Fail tests when network requests fail (e.g. ERR_CONNECTION_REFUSED). Default: false.',
62
+ },
59
63
  cwd: {
60
64
  type: 'string',
61
65
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -95,6 +99,7 @@ export const TOOLS = [
95
99
  type: 'object',
96
100
  properties: {
97
101
  name: { type: 'string', description: 'Test name' },
102
+ expect: { type: 'string', description: 'Human-readable description of the expected visual outcome. After the test runs, a verification screenshot is captured and Claude Code judges pass/fail against this description.' },
98
103
  actions: {
99
104
  type: 'array',
100
105
  description: 'Sequential browser actions',
@@ -206,6 +211,49 @@ export const TOOLS = [
206
211
  description:
207
212
  'prompt = return issue + prompt for Claude Code to create tests (default). verify = auto-generate tests via Claude API and run them.',
208
213
  },
214
+ authToken: {
215
+ type: 'string',
216
+ description: 'JWT or auth token to inject into localStorage before running tests (for authenticated apps)',
217
+ },
218
+ authStorageKey: {
219
+ type: 'string',
220
+ description: 'localStorage key name for the auth token (default: "accessToken")',
221
+ },
222
+ cwd: {
223
+ type: 'string',
224
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
225
+ },
226
+ },
227
+ required: ['url'],
228
+ },
229
+ },
230
+ {
231
+ name: 'e2e_capture',
232
+ description:
233
+ 'Capture a screenshot of any URL on demand. Connects to the Chrome pool, navigates to the URL, takes a screenshot, and returns the image with its ss:HASH.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ url: {
238
+ type: 'string',
239
+ description: 'Full URL to capture (e.g. "https://example.com" or "http://host.docker.internal:3000/dashboard")',
240
+ },
241
+ filename: {
242
+ type: 'string',
243
+ description: 'Output filename (default: capture-<timestamp>.png)',
244
+ },
245
+ fullPage: {
246
+ type: 'boolean',
247
+ description: 'Capture full scrollable page (default: false)',
248
+ },
249
+ selector: {
250
+ type: 'string',
251
+ description: 'Wait for this CSS selector before capturing',
252
+ },
253
+ delay: {
254
+ type: 'number',
255
+ description: 'Wait N milliseconds after page load before capturing (default: 0)',
256
+ },
209
257
  cwd: {
210
258
  type: 'string',
211
259
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -262,8 +310,10 @@ async function handleRun(args) {
262
310
  if (args.concurrency) configOverrides.concurrency = args.concurrency;
263
311
  if (args.baseUrl) configOverrides.baseUrl = args.baseUrl;
264
312
  if (args.retries !== undefined) configOverrides.retries = args.retries;
313
+ if (args.failOnNetworkError !== undefined) configOverrides.failOnNetworkError = args.failOnNetworkError;
265
314
 
266
315
  const config = await loadConfig(configOverrides, args.cwd);
316
+ config.triggeredBy = 'mcp';
267
317
 
268
318
  await waitForPool(config.poolUrl);
269
319
 
@@ -324,10 +374,28 @@ async function handleRun(args) {
324
374
  .filter(r => r.networkErrors?.length > 0)
325
375
  .map(r => ({ name: r.name, errors: r.networkErrors }));
326
376
 
377
+ const networkLogs = report.results
378
+ .filter(r => r.networkLogs?.length > 0)
379
+ .map(r => ({ name: r.name, requests: r.networkLogs }));
380
+
381
+ const verifications = report.results
382
+ .filter(r => r.expect && r.verificationScreenshot)
383
+ .map(r => ({
384
+ name: r.name,
385
+ expect: r.expect,
386
+ success: r.success,
387
+ screenshotHash: 'ss:' + computeScreenshotHash(r.verificationScreenshot),
388
+ }));
389
+
327
390
  if (flaky.length > 0) summary.flaky = flaky;
328
391
  if (failures.length > 0) summary.failures = failures;
329
392
  if (consoleErrors.length > 0) summary.consoleErrors = consoleErrors;
330
393
  if (networkErrors.length > 0) summary.networkErrors = networkErrors;
394
+ if (networkLogs.length > 0) summary.networkLogs = networkLogs;
395
+ if (verifications.length > 0) {
396
+ summary.verifications = verifications;
397
+ summary.verificationInstructions = 'For each verification, call e2e_screenshot with the screenshotHash to view the screenshot. Then compare what you see against the "expect" description. Report any mismatches as FAIL.';
398
+ }
331
399
 
332
400
  return textResult(JSON.stringify(summary, null, 2));
333
401
  }
@@ -441,6 +509,9 @@ async function handleIssue(args) {
441
509
  return errorResult('ANTHROPIC_API_KEY is required for verify mode. Set it as an environment variable.');
442
510
  }
443
511
 
512
+ if (args.authToken) config.authToken = args.authToken;
513
+ if (args.authStorageKey) config.authStorageKey = args.authStorageKey;
514
+
444
515
  const result = await verifyIssue(args.url, config);
445
516
  const status = result.bugConfirmed ? 'BUG CONFIRMED' : 'NOT REPRODUCIBLE';
446
517
  const summary = {
@@ -467,6 +538,62 @@ async function handleIssue(args) {
467
538
  return textResult(promptData.prompt);
468
539
  }
469
540
 
541
+ async function handleCapture(args) {
542
+ if (!args.url) return errorResult('Missing required parameter: url');
543
+
544
+ const config = await loadConfig({}, args.cwd);
545
+
546
+ await waitForPool(config.poolUrl);
547
+
548
+ let browser;
549
+ try {
550
+ browser = await connectToPool(config.poolUrl);
551
+ const page = await browser.newPage();
552
+ await page.setViewport(config.viewport);
553
+ await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
554
+
555
+ if (args.selector) {
556
+ await page.waitForSelector(args.selector, { timeout: 10000 });
557
+ }
558
+
559
+ if (args.delay && args.delay > 0) {
560
+ await new Promise(r => setTimeout(r, args.delay));
561
+ }
562
+
563
+ // Build filename: sanitize and ensure .png
564
+ let filename = args.filename || `capture-${Date.now()}.png`;
565
+ filename = path.basename(filename);
566
+ if (!filename.endsWith('.png')) filename += '.png';
567
+
568
+ if (!fs.existsSync(config.screenshotsDir)) {
569
+ fs.mkdirSync(config.screenshotsDir, { recursive: true });
570
+ }
571
+
572
+ const screenshotPath = path.join(config.screenshotsDir, filename);
573
+ await page.screenshot({ path: screenshotPath, fullPage: !!args.fullPage });
574
+
575
+ // Register hash in SQLite
576
+ const cwd = args.cwd || process.cwd();
577
+ const projectName = config.projectName || path.basename(cwd);
578
+ const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
579
+ const hash = computeScreenshotHash(screenshotPath);
580
+ registerScreenshotHash(hash, screenshotPath, projectId, null);
581
+
582
+ // Read image for response
583
+ const data = fs.readFileSync(screenshotPath);
584
+ const base64 = data.toString('base64');
585
+
586
+ return {
587
+ content: [
588
+ { type: 'text', text: `Screenshot saved: ${screenshotPath}\nHash: ss:${hash}` },
589
+ { type: 'image', data: base64, mimeType: 'image/png' },
590
+ ],
591
+ };
592
+ } finally {
593
+ if (browser) browser.disconnect();
594
+ }
595
+ }
596
+
470
597
  // Module-level state for stdio path only
471
598
  let dashboardHandle = null;
472
599
 
@@ -521,6 +648,8 @@ export async function dispatchTool(name, args = {}) {
521
648
  return await handleDashboardStop();
522
649
  case 'e2e_issue':
523
650
  return await handleIssue(args);
651
+ case 'e2e_capture':
652
+ return await handleCapture(args);
524
653
  default:
525
654
  return errorResult(`Unknown tool: ${name}`);
526
655
  }
package/src/reporter.js CHANGED
@@ -152,7 +152,7 @@ export function persistRun(report, config, suiteName) {
152
152
 
153
153
  try {
154
154
  const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
155
- saveRunToDb(projectId, report, runId, suiteName || null);
155
+ saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
156
156
  } catch (err) {
157
157
  process.stderr.write(`[e2e-runner] SQLite write failed: ${err.message}\n`);
158
158
  }
@@ -210,6 +210,18 @@ export function printReport(report, screenshotsDir) {
210
210
  });
211
211
  }
212
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
+
213
225
  if (screenshotsDir) {
214
226
  console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
215
227
  console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
package/src/runner.js CHANGED
@@ -80,7 +80,9 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
80
80
  error: null,
81
81
  consoleLogs: [],
82
82
  networkErrors: [],
83
+ networkLogs: [],
83
84
  };
85
+ const pendingBodies = [];
84
86
 
85
87
  try {
86
88
  await waitForSlot(config.poolUrl);
@@ -95,6 +97,37 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
95
97
  result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
96
98
  });
97
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
+
98
131
  // Run beforeEach hook
99
132
  if (hooks.beforeEach?.length) {
100
133
  await executeHookActions(page, hooks.beforeEach, config);
@@ -104,7 +137,17 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
104
137
  const action = test.actions[i];
105
138
  const actionStart = Date.now();
106
139
  try {
107
- const actionResult = await executeAction(page, action, config);
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
+ }
108
151
  const actionDuration = Date.now() - actionStart;
109
152
  result.actions.push({
110
153
  ...action,
@@ -126,6 +169,23 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
126
169
  }
127
170
  }
128
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
+
129
189
  // Run afterEach hook (success path)
130
190
  if (hooks.afterEach?.length) {
131
191
  await executeHookActions(page, hooks.afterEach, config);
@@ -148,6 +208,10 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
148
208
  } catch { /* page may be dead */ }
149
209
  }
150
210
  } finally {
211
+ // Flush pending response body reads before disconnecting
212
+ if (pendingBodies.length > 0) {
213
+ try { await Promise.allSettled(pendingBodies); } catch { /* */ }
214
+ }
151
215
  result.endTime = new Date().toISOString();
152
216
  if (page) {
153
217
  try { result.finalUrl = page.url(); } catch { /* */ }
@@ -186,7 +250,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
186
250
  const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
187
251
  const _proj = config.projectName || null;
188
252
  const _cwd = config._cwd || null;
189
- const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd });
253
+ const _triggeredBy = config.triggeredBy || 'unknown';
254
+ const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
190
255
  _progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
191
256
 
192
257
  const results = [];
@@ -222,6 +287,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
222
287
  error: error.message,
223
288
  consoleLogs: [],
224
289
  networkErrors: [],
290
+ networkLogs: [],
225
291
  };
226
292
  }
227
293
 
@@ -238,7 +304,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
238
304
  activeCount--;
239
305
 
240
306
  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 });
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 });
242
308
 
243
309
  if (result.success) {
244
310
  const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
package/src/verify.js CHANGED
@@ -35,19 +35,31 @@ export async function verifyIssue(url, config) {
35
35
  fs.writeFileSync(testFile, JSON.stringify(tests, null, 2));
36
36
 
37
37
  try {
38
- // 4. Wait for pool and run
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
39
51
  await waitForPool(config.poolUrl);
40
- const results = await runTestsParallel(tests, config, {});
52
+ const results = await runTestsParallel(tests, config, hooks);
41
53
  const report = generateReport(results);
42
54
  saveReport(report, config.screenshotsDir, config);
43
55
  persistRun(report, config, suiteName);
44
56
 
45
- // 5. Interpret results
57
+ // 6. Interpret results
46
58
  const bugConfirmed = report.summary.failed > 0;
47
59
 
48
60
  return { issue, report, bugConfirmed, tests, suiteName };
49
61
  } finally {
50
- // 6. Clean up temp file
62
+ // 7. Clean up temp file
51
63
  try { fs.unlinkSync(testFile); } catch { /* already gone */ }
52
64
  }
53
65
  }