@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/mcp-tools.js
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MCP tool definitions and handlers.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the stdio MCP server (src/mcp-server.js) and the
|
|
5
|
+
* Streamable HTTP transport mounted on the dashboard (src/dashboard.js).
|
|
6
|
+
*
|
|
7
|
+
* Kept in its own module so importing it does NOT trigger the
|
|
8
|
+
* console.log→stderr redirect that mcp-server.js applies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import http from 'http';
|
|
14
|
+
|
|
15
|
+
import { loadConfig } from './config.js';
|
|
16
|
+
import { waitForPool, getPoolStatus, connectToPool } from './pool.js';
|
|
17
|
+
import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
|
|
18
|
+
import { generateReport, saveReport, persistRun } from './reporter.js';
|
|
19
|
+
import { startDashboard, stopDashboard } from './dashboard.js';
|
|
20
|
+
import { lookupScreenshotHash, ensureProject, computeScreenshotHash, registerScreenshotHash } from './db.js';
|
|
21
|
+
import { fetchIssue, checkCliAuth, detectProvider } from './issues.js';
|
|
22
|
+
import { buildPrompt, hasApiKey } from './ai-generate.js';
|
|
23
|
+
import { verifyIssue } from './verify.js';
|
|
24
|
+
|
|
25
|
+
// ── Tool definitions ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const TOOLS = [
|
|
28
|
+
{
|
|
29
|
+
name: 'e2e_run',
|
|
30
|
+
description:
|
|
31
|
+
'Run E2E browser tests. Specify "all" to run every suite, "suite" for a specific suite, or "file" for a JSON file path. Returns structured results with pass/fail status, duration, and error details.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
all: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description: 'Run all test suites from the tests directory',
|
|
38
|
+
},
|
|
39
|
+
suite: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'Suite name to run (e.g. "auth", "01-login"). Matches with or without numeric prefix.',
|
|
42
|
+
},
|
|
43
|
+
file: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description: 'Absolute or relative path to a JSON test file',
|
|
46
|
+
},
|
|
47
|
+
concurrency: {
|
|
48
|
+
type: 'number',
|
|
49
|
+
description: 'Number of parallel workers (default from config)',
|
|
50
|
+
},
|
|
51
|
+
baseUrl: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'Override the base URL for this run',
|
|
54
|
+
},
|
|
55
|
+
retries: {
|
|
56
|
+
type: 'number',
|
|
57
|
+
description: 'Number of retries for failed tests',
|
|
58
|
+
},
|
|
59
|
+
failOnNetworkError: {
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
description: 'Fail tests when network requests fail (e.g. ERR_CONNECTION_REFUSED). Default: false.',
|
|
62
|
+
},
|
|
63
|
+
cwd: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'e2e_list',
|
|
72
|
+
description:
|
|
73
|
+
'List all available E2E test suites with their test names and counts.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
cwd: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'e2e_create_test',
|
|
86
|
+
description:
|
|
87
|
+
'Create a new E2E test JSON file. Provide the suite name and an array of test objects, each with a name and actions array.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
name: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Suite file name without .json extension (e.g. "login", "05-checkout")',
|
|
94
|
+
},
|
|
95
|
+
tests: {
|
|
96
|
+
type: 'array',
|
|
97
|
+
description: 'Array of test objects with { name, actions }',
|
|
98
|
+
items: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
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.' },
|
|
103
|
+
actions: {
|
|
104
|
+
type: 'array',
|
|
105
|
+
description: 'Sequential browser actions',
|
|
106
|
+
items: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
type: {
|
|
110
|
+
type: 'string',
|
|
111
|
+
description: 'Action type: goto, click, type, wait, assert_text, assert_url, assert_visible, assert_count, screenshot, select, clear, press, scroll, hover, evaluate, navigate',
|
|
112
|
+
},
|
|
113
|
+
selector: { type: 'string', description: 'CSS selector' },
|
|
114
|
+
value: { type: 'string', description: 'Value for the action' },
|
|
115
|
+
text: { type: 'string', description: 'Text content to match' },
|
|
116
|
+
},
|
|
117
|
+
required: ['type'],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
required: ['name', 'actions'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
hooks: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
description: 'Optional hooks: beforeAll, afterAll, beforeEach, afterEach (each an array of actions)',
|
|
127
|
+
properties: {
|
|
128
|
+
beforeAll: { type: 'array', items: { type: 'object' } },
|
|
129
|
+
afterAll: { type: 'array', items: { type: 'object' } },
|
|
130
|
+
beforeEach: { type: 'array', items: { type: 'object' } },
|
|
131
|
+
afterEach: { type: 'array', items: { type: 'object' } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
cwd: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
required: ['name', 'tests'],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'e2e_pool_status',
|
|
144
|
+
description:
|
|
145
|
+
'Get the status of the Chrome pool (browserless/chrome). Shows availability, running sessions, capacity, and queued requests.',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
cwd: {
|
|
150
|
+
type: 'string',
|
|
151
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'e2e_screenshot',
|
|
158
|
+
description:
|
|
159
|
+
'Retrieve a screenshot by its hash (e.g. ss:a3f2b1c9). Returns the image. Hashes are shown in the dashboard next to screenshots.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
hash: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'Screenshot hash with or without ss: prefix (e.g. "ss:a3f2b1c9" or "a3f2b1c9")',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
required: ['hash'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'e2e_dashboard_start',
|
|
173
|
+
description:
|
|
174
|
+
'Start the E2E Runner web dashboard. Provides a real-time UI for running tests, viewing results, screenshots, history, and pool status.',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
port: {
|
|
179
|
+
type: 'number',
|
|
180
|
+
description: 'Dashboard port (default 8484)',
|
|
181
|
+
},
|
|
182
|
+
cwd: {
|
|
183
|
+
type: 'string',
|
|
184
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'e2e_dashboard_stop',
|
|
191
|
+
description: 'Stop the E2E Runner web dashboard.',
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
properties: {},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'e2e_issue',
|
|
199
|
+
description:
|
|
200
|
+
'Fetch a GitHub/GitLab issue and prepare E2E test generation. Returns issue details and a prompt for test creation. Use mode "verify" to auto-generate and run tests (requires ANTHROPIC_API_KEY).',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
url: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Issue URL (GitHub or GitLab)',
|
|
207
|
+
},
|
|
208
|
+
mode: {
|
|
209
|
+
type: 'string',
|
|
210
|
+
enum: ['prompt', 'verify'],
|
|
211
|
+
description:
|
|
212
|
+
'prompt = return issue + prompt for Claude Code to create tests (default). verify = auto-generate tests via Claude API and run them.',
|
|
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
|
+
},
|
|
257
|
+
cwd: {
|
|
258
|
+
type: 'string',
|
|
259
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
required: ['url'],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
/** Tools exposed on the dashboard — excludes dashboard start/stop (already running). */
|
|
268
|
+
export const DASHBOARD_TOOLS = TOOLS.filter(
|
|
269
|
+
t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop'
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// ── Dashboard broadcast helper ────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
function createDashboardBroadcaster(dashboardPort) {
|
|
275
|
+
const broadcaster = function broadcast(data) {
|
|
276
|
+
const body = JSON.stringify(data);
|
|
277
|
+
broadcaster._last = new Promise((resolve) => {
|
|
278
|
+
const req = http.request({
|
|
279
|
+
hostname: '127.0.0.1',
|
|
280
|
+
port: dashboardPort,
|
|
281
|
+
path: '/api/broadcast',
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
284
|
+
timeout: 1000,
|
|
285
|
+
});
|
|
286
|
+
req.on('error', () => resolve());
|
|
287
|
+
req.on('close', () => resolve());
|
|
288
|
+
req.end(body);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
broadcaster._last = null;
|
|
292
|
+
broadcaster.flush = () => broadcaster._last || Promise.resolve();
|
|
293
|
+
return broadcaster;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function detectDashboardPort() {
|
|
297
|
+
for (const port of [8484]) {
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/status`);
|
|
300
|
+
if (res.ok) return port;
|
|
301
|
+
} catch { /* not running */ }
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
async function handleRun(args) {
|
|
309
|
+
const configOverrides = {};
|
|
310
|
+
if (args.concurrency) configOverrides.concurrency = args.concurrency;
|
|
311
|
+
if (args.baseUrl) configOverrides.baseUrl = args.baseUrl;
|
|
312
|
+
if (args.retries !== undefined) configOverrides.retries = args.retries;
|
|
313
|
+
if (args.failOnNetworkError !== undefined) configOverrides.failOnNetworkError = args.failOnNetworkError;
|
|
314
|
+
|
|
315
|
+
const config = await loadConfig(configOverrides, args.cwd);
|
|
316
|
+
config.triggeredBy = 'mcp';
|
|
317
|
+
|
|
318
|
+
await waitForPool(config.poolUrl);
|
|
319
|
+
|
|
320
|
+
let tests, hooks;
|
|
321
|
+
|
|
322
|
+
if (args.all) {
|
|
323
|
+
({ tests, hooks } = loadAllSuites(config.testsDir));
|
|
324
|
+
} else if (args.suite) {
|
|
325
|
+
({ tests, hooks } = loadTestSuite(args.suite, config.testsDir));
|
|
326
|
+
} else if (args.file) {
|
|
327
|
+
const cwd = args.cwd || process.cwd();
|
|
328
|
+
const filePath = path.isAbsolute(args.file) ? args.file : path.resolve(cwd, args.file);
|
|
329
|
+
({ tests, hooks } = loadTestFile(filePath));
|
|
330
|
+
} else {
|
|
331
|
+
return errorResult('Provide one of: all (true), suite (name), or file (path)');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (tests.length === 0) {
|
|
335
|
+
return errorResult('No tests found');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Wire up live progress to dashboard if it's running
|
|
339
|
+
const dashboardPort = await detectDashboardPort();
|
|
340
|
+
if (dashboardPort) {
|
|
341
|
+
config.onProgress = createDashboardBroadcaster(dashboardPort);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const results = await runTestsParallel(tests, config, hooks || {});
|
|
345
|
+
|
|
346
|
+
// Flush the run:complete broadcast before building the response
|
|
347
|
+
if (config.onProgress?.flush) await config.onProgress.flush();
|
|
348
|
+
|
|
349
|
+
const report = generateReport(results);
|
|
350
|
+
saveReport(report, config.screenshotsDir, config);
|
|
351
|
+
persistRun(report, config, args.suite || null);
|
|
352
|
+
|
|
353
|
+
const failures = report.results
|
|
354
|
+
.filter(r => !r.success)
|
|
355
|
+
.map(r => ({
|
|
356
|
+
name: r.name,
|
|
357
|
+
error: r.error,
|
|
358
|
+
errorScreenshot: r.errorScreenshot || null,
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
const flaky = report.results
|
|
362
|
+
.filter(r => r.success && r.attempt > 1)
|
|
363
|
+
.map(r => ({ name: r.name, attempts: r.attempt }));
|
|
364
|
+
|
|
365
|
+
const summary = {
|
|
366
|
+
...report.summary,
|
|
367
|
+
reportPath: path.join(config.screenshotsDir, 'report.json'),
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const consoleErrors = report.results
|
|
371
|
+
.filter(r => r.consoleLogs?.some(l => l.type === 'error' || l.type === 'warning'))
|
|
372
|
+
.map(r => ({ name: r.name, logs: r.consoleLogs.filter(l => l.type === 'error' || l.type === 'warning') }));
|
|
373
|
+
const networkErrors = report.results
|
|
374
|
+
.filter(r => r.networkErrors?.length > 0)
|
|
375
|
+
.map(r => ({ name: r.name, errors: r.networkErrors }));
|
|
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
|
+
|
|
390
|
+
if (flaky.length > 0) summary.flaky = flaky;
|
|
391
|
+
if (failures.length > 0) summary.failures = failures;
|
|
392
|
+
if (consoleErrors.length > 0) summary.consoleErrors = consoleErrors;
|
|
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
|
+
}
|
|
399
|
+
|
|
400
|
+
return textResult(JSON.stringify(summary, null, 2));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleList(args) {
|
|
404
|
+
const config = await loadConfig({}, args.cwd);
|
|
405
|
+
const suites = listSuites(config.testsDir);
|
|
406
|
+
|
|
407
|
+
if (suites.length === 0) {
|
|
408
|
+
return textResult('No test suites found in ' + config.testsDir);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const lines = suites.map(s =>
|
|
412
|
+
`${s.name} (${s.testCount} tests): ${s.tests.join(', ')}`
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
return textResult(lines.join('\n'));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function handleCreateTest(args) {
|
|
419
|
+
const config = await loadConfig({}, args.cwd);
|
|
420
|
+
|
|
421
|
+
if (!fs.existsSync(config.testsDir)) {
|
|
422
|
+
fs.mkdirSync(config.testsDir, { recursive: true });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const safeName = path.basename(args.name);
|
|
426
|
+
const filename = safeName.endsWith('.json') ? safeName : `${safeName}.json`;
|
|
427
|
+
const filePath = path.join(config.testsDir, filename);
|
|
428
|
+
|
|
429
|
+
if (fs.existsSync(filePath)) {
|
|
430
|
+
return errorResult(`File already exists: ${filePath}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let content;
|
|
434
|
+
if (args.hooks && Object.values(args.hooks).some(h => h?.length > 0)) {
|
|
435
|
+
content = { hooks: args.hooks, tests: args.tests };
|
|
436
|
+
} else {
|
|
437
|
+
content = args.tests;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
|
|
441
|
+
return textResult(`Created test file: ${filePath}\n\n${args.tests.length} test(s) defined.`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function handlePoolStatus(args) {
|
|
445
|
+
const config = await loadConfig({}, args.cwd);
|
|
446
|
+
const status = await getPoolStatus(config.poolUrl);
|
|
447
|
+
|
|
448
|
+
const lines = [
|
|
449
|
+
`Available: ${status.available ? 'yes' : 'no'}`,
|
|
450
|
+
`Running: ${status.running}/${status.maxConcurrent}`,
|
|
451
|
+
`Queued: ${status.queued}`,
|
|
452
|
+
`Sessions: ${status.sessions.length}`,
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
if (status.error) {
|
|
456
|
+
lines.push(`Error: ${status.error}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return textResult(lines.join('\n'));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function handleScreenshot(args) {
|
|
463
|
+
if (!args.hash) return errorResult('Missing required parameter: hash');
|
|
464
|
+
|
|
465
|
+
const row = lookupScreenshotHash(args.hash);
|
|
466
|
+
if (!row) return errorResult(`Screenshot not found for hash: ${args.hash}`);
|
|
467
|
+
|
|
468
|
+
if (!fs.existsSync(row.file_path)) {
|
|
469
|
+
return errorResult(`Screenshot file no longer exists: ${row.file_path}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const data = fs.readFileSync(row.file_path);
|
|
473
|
+
const base64 = data.toString('base64');
|
|
474
|
+
const ext = path.extname(row.file_path).toLowerCase();
|
|
475
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
476
|
+
const mimeType = mimeTypes[ext] || 'image/png';
|
|
477
|
+
const filename = path.basename(row.file_path);
|
|
478
|
+
const hash = row.hash;
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{ type: 'text', text: `Screenshot ss:${hash} (${filename})` },
|
|
483
|
+
{ type: 'image', data: base64, mimeType },
|
|
484
|
+
],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function handleIssue(args) {
|
|
489
|
+
if (!args.url) return errorResult('Missing required parameter: url');
|
|
490
|
+
|
|
491
|
+
const mode = args.mode || 'prompt';
|
|
492
|
+
const config = await loadConfig({}, args.cwd);
|
|
493
|
+
|
|
494
|
+
// Check provider and auth
|
|
495
|
+
let provider;
|
|
496
|
+
try {
|
|
497
|
+
provider = detectProvider(args.url);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
return errorResult(err.message);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const auth = checkCliAuth(provider);
|
|
503
|
+
if (!auth.authenticated) {
|
|
504
|
+
return errorResult(auth.error);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (mode === 'verify') {
|
|
508
|
+
if (!hasApiKey(config)) {
|
|
509
|
+
return errorResult('ANTHROPIC_API_KEY is required for verify mode. Set it as an environment variable.');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (args.authToken) config.authToken = args.authToken;
|
|
513
|
+
if (args.authStorageKey) config.authStorageKey = args.authStorageKey;
|
|
514
|
+
|
|
515
|
+
const result = await verifyIssue(args.url, config);
|
|
516
|
+
const status = result.bugConfirmed ? 'BUG CONFIRMED' : 'NOT REPRODUCIBLE';
|
|
517
|
+
const summary = {
|
|
518
|
+
status,
|
|
519
|
+
bugConfirmed: result.bugConfirmed,
|
|
520
|
+
issue: {
|
|
521
|
+
title: result.issue.title,
|
|
522
|
+
url: result.issue.url,
|
|
523
|
+
number: result.issue.number,
|
|
524
|
+
labels: result.issue.labels,
|
|
525
|
+
},
|
|
526
|
+
testResults: result.report.summary,
|
|
527
|
+
testsGenerated: result.tests.length,
|
|
528
|
+
suiteName: result.suiteName,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
return textResult(JSON.stringify(summary, null, 2));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Default: prompt mode
|
|
535
|
+
const issue = fetchIssue(args.url);
|
|
536
|
+
const promptData = buildPrompt(issue, config);
|
|
537
|
+
|
|
538
|
+
return textResult(promptData.prompt);
|
|
539
|
+
}
|
|
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
|
+
|
|
597
|
+
// Module-level state for stdio path only
|
|
598
|
+
let dashboardHandle = null;
|
|
599
|
+
|
|
600
|
+
async function handleDashboardStart(args) {
|
|
601
|
+
if (dashboardHandle) {
|
|
602
|
+
return errorResult('Dashboard is already running on port ' + dashboardHandle.port);
|
|
603
|
+
}
|
|
604
|
+
const overrides = {};
|
|
605
|
+
if (args.port) overrides.dashboardPort = args.port;
|
|
606
|
+
const config = await loadConfig(overrides, args.cwd);
|
|
607
|
+
dashboardHandle = await startDashboard(config);
|
|
608
|
+
return textResult(`Dashboard started at http://localhost:${dashboardHandle.port}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function handleDashboardStop() {
|
|
612
|
+
if (!dashboardHandle) {
|
|
613
|
+
return errorResult('Dashboard is not running');
|
|
614
|
+
}
|
|
615
|
+
stopDashboard(dashboardHandle);
|
|
616
|
+
dashboardHandle = null;
|
|
617
|
+
return textResult('Dashboard stopped');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
export function textResult(text) {
|
|
623
|
+
return { content: [{ type: 'text', text }] };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function errorResult(message) {
|
|
627
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
/** Routes a tool call to its handler. Used by both stdio and HTTP transports. */
|
|
633
|
+
export async function dispatchTool(name, args = {}) {
|
|
634
|
+
switch (name) {
|
|
635
|
+
case 'e2e_run':
|
|
636
|
+
return await handleRun(args);
|
|
637
|
+
case 'e2e_list':
|
|
638
|
+
return await handleList(args);
|
|
639
|
+
case 'e2e_create_test':
|
|
640
|
+
return await handleCreateTest(args);
|
|
641
|
+
case 'e2e_pool_status':
|
|
642
|
+
return await handlePoolStatus(args);
|
|
643
|
+
case 'e2e_screenshot':
|
|
644
|
+
return await handleScreenshot(args);
|
|
645
|
+
case 'e2e_dashboard_start':
|
|
646
|
+
return await handleDashboardStart(args);
|
|
647
|
+
case 'e2e_dashboard_stop':
|
|
648
|
+
return await handleDashboardStop();
|
|
649
|
+
case 'e2e_issue':
|
|
650
|
+
return await handleIssue(args);
|
|
651
|
+
case 'e2e_capture':
|
|
652
|
+
return await handleCapture(args);
|
|
653
|
+
default:
|
|
654
|
+
return errorResult(`Unknown tool: ${name}`);
|
|
655
|
+
}
|
|
656
|
+
}
|