@matware/e2e-runner 1.0.1 → 1.0.3
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 +12 -1
- package/package.json +1 -1
- package/src/config.js +2 -2
- package/src/mcp-server.js +55 -18
- package/src/pool.js +8 -7
- package/src/reporter.js +30 -0
- package/src/runner.js +8 -0
package/README.md
CHANGED
|
@@ -318,13 +318,22 @@ import {
|
|
|
318
318
|
|
|
319
319
|
## Claude Code Integration (MCP)
|
|
320
320
|
|
|
321
|
-
The package includes a built-in [MCP server](https://modelcontextprotocol.io/) that gives Claude Code native access to the test runner. Install once and it's available in every project
|
|
321
|
+
The package includes a built-in [MCP server](https://modelcontextprotocol.io/) that gives Claude Code native access to the test runner. Install once and it's available in every project.
|
|
322
|
+
|
|
323
|
+
**Via npm** (requires Node.js):
|
|
322
324
|
|
|
323
325
|
```bash
|
|
324
326
|
claude mcp add --transport stdio --scope user e2e-runner \
|
|
325
327
|
-- npx -y -p @matware/e2e-runner e2e-runner-mcp
|
|
326
328
|
```
|
|
327
329
|
|
|
330
|
+
**Via Docker** (no Node.js required):
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
claude mcp add --transport stdio --scope user e2e-runner \
|
|
334
|
+
-- docker run -i --rm fastslack/e2e-runner-mcp
|
|
335
|
+
```
|
|
336
|
+
|
|
328
337
|
### MCP Tools
|
|
329
338
|
|
|
330
339
|
| Tool | Description |
|
|
@@ -336,6 +345,8 @@ claude mcp add --transport stdio --scope user e2e-runner \
|
|
|
336
345
|
| `e2e_pool_start` | Start the Chrome pool Docker container |
|
|
337
346
|
| `e2e_pool_stop` | Stop the Chrome pool |
|
|
338
347
|
|
|
348
|
+
All tools accept an optional `cwd` parameter (absolute path to the project root). Claude Code passes its current working directory so the MCP server resolves `e2e/tests/`, `e2e.config.js`, and `.e2e-pool/` relative to the correct project — even when switching between multiple projects in the same session.
|
|
349
|
+
|
|
339
350
|
Once installed, Claude Code can run tests, analyze failures, create new test files, and manage the Chrome pool as part of its normal workflow. Just ask:
|
|
340
351
|
|
|
341
352
|
> "Run all E2E tests"
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -68,8 +68,8 @@ async function loadConfigFile(cwd) {
|
|
|
68
68
|
return {};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export async function loadConfig(cliArgs = {}) {
|
|
72
|
-
|
|
71
|
+
export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
72
|
+
cwd = cwd || process.cwd();
|
|
73
73
|
const fileConfig = await loadConfigFile(cwd);
|
|
74
74
|
const envConfig = loadEnvVars();
|
|
75
75
|
|
package/src/mcp-server.js
CHANGED
|
@@ -61,6 +61,10 @@ const TOOLS = [
|
|
|
61
61
|
type: 'number',
|
|
62
62
|
description: 'Number of retries for failed tests',
|
|
63
63
|
},
|
|
64
|
+
cwd: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
67
|
+
},
|
|
64
68
|
},
|
|
65
69
|
},
|
|
66
70
|
},
|
|
@@ -70,7 +74,12 @@ const TOOLS = [
|
|
|
70
74
|
'List all available E2E test suites with their test names and counts.',
|
|
71
75
|
inputSchema: {
|
|
72
76
|
type: 'object',
|
|
73
|
-
properties: {
|
|
77
|
+
properties: {
|
|
78
|
+
cwd: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
74
83
|
},
|
|
75
84
|
},
|
|
76
85
|
{
|
|
@@ -122,6 +131,10 @@ const TOOLS = [
|
|
|
122
131
|
afterEach: { type: 'array', items: { type: 'object' } },
|
|
123
132
|
},
|
|
124
133
|
},
|
|
134
|
+
cwd: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
137
|
+
},
|
|
125
138
|
},
|
|
126
139
|
required: ['name', 'tests'],
|
|
127
140
|
},
|
|
@@ -132,7 +145,12 @@ const TOOLS = [
|
|
|
132
145
|
'Get the status of the Chrome pool (browserless/chrome). Shows availability, running sessions, capacity, and queued requests.',
|
|
133
146
|
inputSchema: {
|
|
134
147
|
type: 'object',
|
|
135
|
-
properties: {
|
|
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
|
+
},
|
|
136
154
|
},
|
|
137
155
|
},
|
|
138
156
|
{
|
|
@@ -150,6 +168,10 @@ const TOOLS = [
|
|
|
150
168
|
type: 'number',
|
|
151
169
|
description: 'Max concurrent Chrome sessions (default 10)',
|
|
152
170
|
},
|
|
171
|
+
cwd: {
|
|
172
|
+
type: 'string',
|
|
173
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
174
|
+
},
|
|
153
175
|
},
|
|
154
176
|
},
|
|
155
177
|
},
|
|
@@ -158,7 +180,12 @@ const TOOLS = [
|
|
|
158
180
|
description: 'Stop the Chrome pool Docker container.',
|
|
159
181
|
inputSchema: {
|
|
160
182
|
type: 'object',
|
|
161
|
-
properties: {
|
|
183
|
+
properties: {
|
|
184
|
+
cwd: {
|
|
185
|
+
type: 'string',
|
|
186
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
162
189
|
},
|
|
163
190
|
},
|
|
164
191
|
];
|
|
@@ -171,7 +198,7 @@ async function handleRun(args) {
|
|
|
171
198
|
if (args.baseUrl) configOverrides.baseUrl = args.baseUrl;
|
|
172
199
|
if (args.retries !== undefined) configOverrides.retries = args.retries;
|
|
173
200
|
|
|
174
|
-
const config = await loadConfig(configOverrides);
|
|
201
|
+
const config = await loadConfig(configOverrides, args.cwd);
|
|
175
202
|
|
|
176
203
|
await waitForPool(config.poolUrl);
|
|
177
204
|
|
|
@@ -182,7 +209,8 @@ async function handleRun(args) {
|
|
|
182
209
|
} else if (args.suite) {
|
|
183
210
|
({ tests, hooks } = loadTestSuite(args.suite, config.testsDir));
|
|
184
211
|
} else if (args.file) {
|
|
185
|
-
const
|
|
212
|
+
const cwd = args.cwd || process.cwd();
|
|
213
|
+
const filePath = path.isAbsolute(args.file) ? args.file : path.resolve(cwd, args.file);
|
|
186
214
|
({ tests, hooks } = loadTestFile(filePath));
|
|
187
215
|
} else {
|
|
188
216
|
return errorResult('Provide one of: all (true), suite (name), or file (path)');
|
|
@@ -213,14 +241,23 @@ async function handleRun(args) {
|
|
|
213
241
|
reportPath: path.join(config.screenshotsDir, 'report.json'),
|
|
214
242
|
};
|
|
215
243
|
|
|
244
|
+
const consoleErrors = report.results
|
|
245
|
+
.filter(r => r.consoleLogs?.some(l => l.type === 'error' || l.type === 'warning'))
|
|
246
|
+
.map(r => ({ name: r.name, logs: r.consoleLogs.filter(l => l.type === 'error' || l.type === 'warning') }));
|
|
247
|
+
const networkErrors = report.results
|
|
248
|
+
.filter(r => r.networkErrors?.length > 0)
|
|
249
|
+
.map(r => ({ name: r.name, errors: r.networkErrors }));
|
|
250
|
+
|
|
216
251
|
if (flaky.length > 0) summary.flaky = flaky;
|
|
217
252
|
if (failures.length > 0) summary.failures = failures;
|
|
253
|
+
if (consoleErrors.length > 0) summary.consoleErrors = consoleErrors;
|
|
254
|
+
if (networkErrors.length > 0) summary.networkErrors = networkErrors;
|
|
218
255
|
|
|
219
256
|
return textResult(JSON.stringify(summary, null, 2));
|
|
220
257
|
}
|
|
221
258
|
|
|
222
|
-
async function handleList() {
|
|
223
|
-
const config = await loadConfig({});
|
|
259
|
+
async function handleList(args) {
|
|
260
|
+
const config = await loadConfig({}, args.cwd);
|
|
224
261
|
const suites = listSuites(config.testsDir);
|
|
225
262
|
|
|
226
263
|
if (suites.length === 0) {
|
|
@@ -235,7 +272,7 @@ async function handleList() {
|
|
|
235
272
|
}
|
|
236
273
|
|
|
237
274
|
async function handleCreateTest(args) {
|
|
238
|
-
const config = await loadConfig({});
|
|
275
|
+
const config = await loadConfig({}, args.cwd);
|
|
239
276
|
|
|
240
277
|
if (!fs.existsSync(config.testsDir)) {
|
|
241
278
|
fs.mkdirSync(config.testsDir, { recursive: true });
|
|
@@ -259,8 +296,8 @@ async function handleCreateTest(args) {
|
|
|
259
296
|
return textResult(`Created test file: ${filePath}\n\n${args.tests.length} test(s) defined.`);
|
|
260
297
|
}
|
|
261
298
|
|
|
262
|
-
async function handlePoolStatus() {
|
|
263
|
-
const config = await loadConfig({});
|
|
299
|
+
async function handlePoolStatus(args) {
|
|
300
|
+
const config = await loadConfig({}, args.cwd);
|
|
264
301
|
const status = await getPoolStatus(config.poolUrl);
|
|
265
302
|
|
|
266
303
|
const lines = [
|
|
@@ -282,14 +319,14 @@ async function handlePoolStart(args) {
|
|
|
282
319
|
if (args.port) overrides.poolPort = args.port;
|
|
283
320
|
if (args.maxSessions) overrides.maxSessions = args.maxSessions;
|
|
284
321
|
|
|
285
|
-
const config = await loadConfig(overrides);
|
|
286
|
-
startPool(config);
|
|
322
|
+
const config = await loadConfig(overrides, args.cwd);
|
|
323
|
+
startPool(config, args.cwd);
|
|
287
324
|
return textResult(`Chrome pool started on port ${config.poolPort}`);
|
|
288
325
|
}
|
|
289
326
|
|
|
290
|
-
async function handlePoolStop() {
|
|
291
|
-
const config = await loadConfig({});
|
|
292
|
-
stopPool(config);
|
|
327
|
+
async function handlePoolStop(args) {
|
|
328
|
+
const config = await loadConfig({}, args.cwd);
|
|
329
|
+
stopPool(config, args.cwd);
|
|
293
330
|
return textResult('Chrome pool stopped');
|
|
294
331
|
}
|
|
295
332
|
|
|
@@ -323,15 +360,15 @@ export async function startMcpServer() {
|
|
|
323
360
|
case 'e2e_run':
|
|
324
361
|
return await handleRun(args);
|
|
325
362
|
case 'e2e_list':
|
|
326
|
-
return await handleList();
|
|
363
|
+
return await handleList(args);
|
|
327
364
|
case 'e2e_create_test':
|
|
328
365
|
return await handleCreateTest(args);
|
|
329
366
|
case 'e2e_pool_status':
|
|
330
|
-
return await handlePoolStatus();
|
|
367
|
+
return await handlePoolStatus(args);
|
|
331
368
|
case 'e2e_pool_start':
|
|
332
369
|
return await handlePoolStart(args);
|
|
333
370
|
case 'e2e_pool_stop':
|
|
334
|
-
return await handlePoolStop();
|
|
371
|
+
return await handlePoolStop(args);
|
|
335
372
|
default:
|
|
336
373
|
return errorResult(`Unknown tool: ${name}`);
|
|
337
374
|
}
|
package/src/pool.js
CHANGED
|
@@ -61,8 +61,8 @@ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Generates docker-compose.yml and starts the pool */
|
|
64
|
-
export function startPool(config) {
|
|
65
|
-
|
|
64
|
+
export function startPool(config, cwd = null) {
|
|
65
|
+
cwd = cwd || process.cwd();
|
|
66
66
|
const poolDir = path.join(cwd, '.e2e-pool');
|
|
67
67
|
|
|
68
68
|
if (!fs.existsSync(poolDir)) {
|
|
@@ -93,8 +93,9 @@ export function startPool(config) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** Stops the pool */
|
|
96
|
-
export function stopPool(config) {
|
|
97
|
-
|
|
96
|
+
export function stopPool(config, cwd = null) {
|
|
97
|
+
cwd = cwd || process.cwd();
|
|
98
|
+
const composePath = path.join(cwd, '.e2e-pool', 'docker-compose.yml');
|
|
98
99
|
if (!fs.existsSync(composePath)) {
|
|
99
100
|
log('⚠️', '.e2e-pool/docker-compose.yml not found');
|
|
100
101
|
return;
|
|
@@ -106,9 +107,9 @@ export function stopPool(config) {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** Restarts the pool */
|
|
109
|
-
export function restartPool(config) {
|
|
110
|
-
stopPool(config);
|
|
111
|
-
startPool(config);
|
|
110
|
+
export function restartPool(config, cwd = null) {
|
|
111
|
+
stopPool(config, cwd);
|
|
112
|
+
startPool(config, cwd);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/** Gets pool status */
|
package/src/reporter.js
CHANGED
|
@@ -55,6 +55,11 @@ export function generateJUnitXML(report) {
|
|
|
55
55
|
xml += ` <system-out><![CDATA[${logs}]]></system-out>\n`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const netErrors = (result.networkErrors || []).map(e => `[${e.error || 'unknown'}] ${e.url}`).join('\n');
|
|
59
|
+
if (netErrors) {
|
|
60
|
+
xml += ` <system-err><![CDATA[${netErrors}]]></system-err>\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
xml += ' </testcase>\n';
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -109,6 +114,31 @@ export function printReport(report, screenshotsDir) {
|
|
|
109
114
|
});
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
const consoleIssues = report.results.filter(r =>
|
|
118
|
+
r.consoleLogs?.some(l => l.type === 'error' || l.type === 'warning')
|
|
119
|
+
);
|
|
120
|
+
if (consoleIssues.length > 0) {
|
|
121
|
+
console.log(`\n${C.yellow}${C.bold}BROWSER CONSOLE ISSUES:${C.reset}`);
|
|
122
|
+
consoleIssues.forEach(r => {
|
|
123
|
+
const logs = r.consoleLogs.filter(l => l.type === 'error' || l.type === 'warning');
|
|
124
|
+
console.log(` ${C.yellow}⚠${C.reset} ${r.name}:`);
|
|
125
|
+
logs.forEach(l => {
|
|
126
|
+
console.log(` ${C.dim}[${l.type}]${C.reset} ${l.text}`);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const networkIssues = report.results.filter(r => r.networkErrors?.length > 0);
|
|
132
|
+
if (networkIssues.length > 0) {
|
|
133
|
+
console.log(`\n${C.yellow}${C.bold}NETWORK ERRORS:${C.reset}`);
|
|
134
|
+
networkIssues.forEach(r => {
|
|
135
|
+
console.log(` ${C.yellow}⚠${C.reset} ${r.name}:`);
|
|
136
|
+
r.networkErrors.forEach(e => {
|
|
137
|
+
console.log(` ${C.dim}[${e.error || 'unknown'}]${C.reset} ${e.url}`);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
if (screenshotsDir) {
|
|
113
143
|
console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
|
|
114
144
|
console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
|
package/src/runner.js
CHANGED
|
@@ -208,6 +208,14 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
208
208
|
const attempts = result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
|
|
209
209
|
log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${attempts}`);
|
|
210
210
|
}
|
|
211
|
+
|
|
212
|
+
const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
|
|
213
|
+
if (consoleIssues > 0) {
|
|
214
|
+
log('⚠️', `${C.yellow}${test.name}: ${consoleIssues} console ${consoleIssues === 1 ? 'issue' : 'issues'}${C.reset}`);
|
|
215
|
+
}
|
|
216
|
+
if (result.networkErrors?.length > 0) {
|
|
217
|
+
log('⚠️', `${C.yellow}${test.name}: ${result.networkErrors.length} network ${result.networkErrors.length === 1 ? 'error' : 'errors'}${C.reset}`);
|
|
218
|
+
}
|
|
211
219
|
}
|
|
212
220
|
};
|
|
213
221
|
|