@matware/e2e-runner 1.0.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/index.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @matware/e2e-runner — Programmatic API
3
+ *
4
+ * Usage:
5
+ * import { createRunner } from '@matware/e2e-runner';
6
+ * const runner = await createRunner({ baseUrl: 'http://localhost:3000' });
7
+ * const report = await runner.runAll();
8
+ */
9
+
10
+ export { loadConfig } from './config.js';
11
+ export { waitForPool, connectToPool, startPool, stopPool, restartPool, getPoolStatus } from './pool.js';
12
+ export { executeAction } from './actions.js';
13
+ export { runTest, runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
14
+ export { generateReport, generateJUnitXML, saveReport, printReport } from './reporter.js';
15
+
16
+ import { loadConfig } from './config.js';
17
+ import { waitForPool } from './pool.js';
18
+ import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites } from './runner.js';
19
+ import { generateReport, saveReport, printReport } from './reporter.js';
20
+
21
+ /**
22
+ * Creates a runner instance with custom configuration
23
+ * @param {object} userConfig - Configuration overrides
24
+ * @returns {object} Runner with runAll, runSuite, runTests, runFile methods
25
+ */
26
+ export async function createRunner(userConfig = {}) {
27
+ const config = await loadConfig(userConfig);
28
+
29
+ return {
30
+ config,
31
+
32
+ /** Runs all test suites from the tests directory */
33
+ async runAll() {
34
+ await waitForPool(config.poolUrl);
35
+ const { tests, hooks } = loadAllSuites(config.testsDir);
36
+ const results = await runTestsParallel(tests, config, hooks);
37
+ const report = generateReport(results);
38
+ saveReport(report, config.screenshotsDir, config);
39
+ printReport(report, config.screenshotsDir);
40
+ return report;
41
+ },
42
+
43
+ /** Runs a single suite by name */
44
+ async runSuite(name) {
45
+ await waitForPool(config.poolUrl);
46
+ const { tests, hooks } = loadTestSuite(name, config.testsDir);
47
+ const results = await runTestsParallel(tests, config, hooks);
48
+ const report = generateReport(results);
49
+ saveReport(report, config.screenshotsDir, config);
50
+ printReport(report, config.screenshotsDir);
51
+ return report;
52
+ },
53
+
54
+ /** Runs an array of test objects */
55
+ async runTests(tests) {
56
+ await waitForPool(config.poolUrl);
57
+ const results = await runTestsParallel(tests, config);
58
+ const report = generateReport(results);
59
+ saveReport(report, config.screenshotsDir, config);
60
+ printReport(report, config.screenshotsDir);
61
+ return report;
62
+ },
63
+
64
+ /** Runs tests from a JSON file path */
65
+ async runFile(filePath) {
66
+ await waitForPool(config.poolUrl);
67
+ const { tests, hooks } = loadTestFile(filePath);
68
+ const results = await runTestsParallel(tests, config, hooks);
69
+ const report = generateReport(results);
70
+ saveReport(report, config.screenshotsDir, config);
71
+ printReport(report, config.screenshotsDir);
72
+ return report;
73
+ },
74
+ };
75
+ }
package/src/logger.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ANSI color logger with timestamps
3
+ */
4
+
5
+ export const colors = {
6
+ reset: '\x1b[0m',
7
+ green: '\x1b[32m',
8
+ red: '\x1b[31m',
9
+ yellow: '\x1b[33m',
10
+ blue: '\x1b[34m',
11
+ cyan: '\x1b[36m',
12
+ dim: '\x1b[2m',
13
+ bold: '\x1b[1m',
14
+ };
15
+
16
+ export function log(icon, msg) {
17
+ const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
18
+ console.log(`${colors.dim}${ts}${colors.reset} ${icon} ${msg}`);
19
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * MCP Server for @matware/e2e-runner
3
+ *
4
+ * Exposes E2E test runner capabilities as MCP tools so Claude Code
5
+ * (and any MCP-compatible client) can run tests, list suites,
6
+ * create test files, and manage the Chrome pool.
7
+ *
8
+ * Install once for all Claude Code sessions:
9
+ * claude mcp add --transport stdio --scope user e2e-runner -- npx -y -p @matware/e2e-runner e2e-runner-mcp
10
+ */
11
+
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import {
15
+ ListToolsRequestSchema,
16
+ CallToolRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js';
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+
22
+ import { loadConfig } from './config.js';
23
+ import { waitForPool, getPoolStatus, startPool, stopPool } from './pool.js';
24
+ import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
25
+ import { generateReport, saveReport } from './reporter.js';
26
+
27
+ // ── Redirect console.log to stderr so it doesn't corrupt the MCP stdio protocol ──
28
+ console.log = (...args) => process.stderr.write(args.join(' ') + '\n');
29
+
30
+ // ── Tool definitions ──────────────────────────────────────────────────────────
31
+
32
+ const TOOLS = [
33
+ {
34
+ name: 'e2e_run',
35
+ description:
36
+ '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.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ all: {
41
+ type: 'boolean',
42
+ description: 'Run all test suites from the tests directory',
43
+ },
44
+ suite: {
45
+ type: 'string',
46
+ description: 'Suite name to run (e.g. "auth", "01-login"). Matches with or without numeric prefix.',
47
+ },
48
+ file: {
49
+ type: 'string',
50
+ description: 'Absolute or relative path to a JSON test file',
51
+ },
52
+ concurrency: {
53
+ type: 'number',
54
+ description: 'Number of parallel workers (default from config)',
55
+ },
56
+ baseUrl: {
57
+ type: 'string',
58
+ description: 'Override the base URL for this run',
59
+ },
60
+ retries: {
61
+ type: 'number',
62
+ description: 'Number of retries for failed tests',
63
+ },
64
+ },
65
+ },
66
+ },
67
+ {
68
+ name: 'e2e_list',
69
+ description:
70
+ 'List all available E2E test suites with their test names and counts.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {},
74
+ },
75
+ },
76
+ {
77
+ name: 'e2e_create_test',
78
+ description:
79
+ 'Create a new E2E test JSON file. Provide the suite name and an array of test objects, each with a name and actions array.',
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ name: {
84
+ type: 'string',
85
+ description: 'Suite file name without .json extension (e.g. "login", "05-checkout")',
86
+ },
87
+ tests: {
88
+ type: 'array',
89
+ description: 'Array of test objects with { name, actions }',
90
+ items: {
91
+ type: 'object',
92
+ properties: {
93
+ name: { type: 'string', description: 'Test name' },
94
+ actions: {
95
+ type: 'array',
96
+ description: 'Sequential browser actions',
97
+ items: {
98
+ type: 'object',
99
+ properties: {
100
+ type: {
101
+ type: 'string',
102
+ description: 'Action type: goto, click, type, wait, assert_text, assert_url, assert_visible, assert_count, screenshot, select, clear, press, scroll, hover, evaluate',
103
+ },
104
+ selector: { type: 'string', description: 'CSS selector' },
105
+ value: { type: 'string', description: 'Value for the action' },
106
+ text: { type: 'string', description: 'Text content to match' },
107
+ },
108
+ required: ['type'],
109
+ },
110
+ },
111
+ },
112
+ required: ['name', 'actions'],
113
+ },
114
+ },
115
+ hooks: {
116
+ type: 'object',
117
+ description: 'Optional hooks: beforeAll, afterAll, beforeEach, afterEach (each an array of actions)',
118
+ properties: {
119
+ beforeAll: { type: 'array', items: { type: 'object' } },
120
+ afterAll: { type: 'array', items: { type: 'object' } },
121
+ beforeEach: { type: 'array', items: { type: 'object' } },
122
+ afterEach: { type: 'array', items: { type: 'object' } },
123
+ },
124
+ },
125
+ },
126
+ required: ['name', 'tests'],
127
+ },
128
+ },
129
+ {
130
+ name: 'e2e_pool_status',
131
+ description:
132
+ 'Get the status of the Chrome pool (browserless/chrome). Shows availability, running sessions, capacity, and queued requests.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {},
136
+ },
137
+ },
138
+ {
139
+ name: 'e2e_pool_start',
140
+ description:
141
+ 'Start the Chrome pool (browserless/chrome Docker container). Requires Docker to be running.',
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: {
145
+ port: {
146
+ type: 'number',
147
+ description: 'Port for the pool (default 3333)',
148
+ },
149
+ maxSessions: {
150
+ type: 'number',
151
+ description: 'Max concurrent Chrome sessions (default 10)',
152
+ },
153
+ },
154
+ },
155
+ },
156
+ {
157
+ name: 'e2e_pool_stop',
158
+ description: 'Stop the Chrome pool Docker container.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {},
162
+ },
163
+ },
164
+ ];
165
+
166
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
167
+
168
+ async function handleRun(args) {
169
+ const configOverrides = {};
170
+ if (args.concurrency) configOverrides.concurrency = args.concurrency;
171
+ if (args.baseUrl) configOverrides.baseUrl = args.baseUrl;
172
+ if (args.retries !== undefined) configOverrides.retries = args.retries;
173
+
174
+ const config = await loadConfig(configOverrides);
175
+
176
+ await waitForPool(config.poolUrl);
177
+
178
+ let tests, hooks;
179
+
180
+ if (args.all) {
181
+ ({ tests, hooks } = loadAllSuites(config.testsDir));
182
+ } else if (args.suite) {
183
+ ({ tests, hooks } = loadTestSuite(args.suite, config.testsDir));
184
+ } else if (args.file) {
185
+ const filePath = path.isAbsolute(args.file) ? args.file : path.resolve(args.file);
186
+ ({ tests, hooks } = loadTestFile(filePath));
187
+ } else {
188
+ return errorResult('Provide one of: all (true), suite (name), or file (path)');
189
+ }
190
+
191
+ if (tests.length === 0) {
192
+ return errorResult('No tests found');
193
+ }
194
+
195
+ const results = await runTestsParallel(tests, config, hooks || {});
196
+ const report = generateReport(results);
197
+ saveReport(report, config.screenshotsDir, config);
198
+
199
+ const failures = report.results
200
+ .filter(r => !r.success)
201
+ .map(r => ({
202
+ name: r.name,
203
+ error: r.error,
204
+ errorScreenshot: r.errorScreenshot || null,
205
+ }));
206
+
207
+ const flaky = report.results
208
+ .filter(r => r.success && r.attempt > 1)
209
+ .map(r => ({ name: r.name, attempts: r.attempt }));
210
+
211
+ const summary = {
212
+ ...report.summary,
213
+ reportPath: path.join(config.screenshotsDir, 'report.json'),
214
+ };
215
+
216
+ if (flaky.length > 0) summary.flaky = flaky;
217
+ if (failures.length > 0) summary.failures = failures;
218
+
219
+ return textResult(JSON.stringify(summary, null, 2));
220
+ }
221
+
222
+ async function handleList() {
223
+ const config = await loadConfig({});
224
+ const suites = listSuites(config.testsDir);
225
+
226
+ if (suites.length === 0) {
227
+ return textResult('No test suites found in ' + config.testsDir);
228
+ }
229
+
230
+ const lines = suites.map(s =>
231
+ `${s.name} (${s.testCount} tests): ${s.tests.join(', ')}`
232
+ );
233
+
234
+ return textResult(lines.join('\n'));
235
+ }
236
+
237
+ async function handleCreateTest(args) {
238
+ const config = await loadConfig({});
239
+
240
+ if (!fs.existsSync(config.testsDir)) {
241
+ fs.mkdirSync(config.testsDir, { recursive: true });
242
+ }
243
+
244
+ const filename = args.name.endsWith('.json') ? args.name : `${args.name}.json`;
245
+ const filePath = path.join(config.testsDir, filename);
246
+
247
+ if (fs.existsSync(filePath)) {
248
+ return errorResult(`File already exists: ${filePath}`);
249
+ }
250
+
251
+ let content;
252
+ if (args.hooks && Object.values(args.hooks).some(h => h?.length > 0)) {
253
+ content = { hooks: args.hooks, tests: args.tests };
254
+ } else {
255
+ content = args.tests;
256
+ }
257
+
258
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
259
+ return textResult(`Created test file: ${filePath}\n\n${args.tests.length} test(s) defined.`);
260
+ }
261
+
262
+ async function handlePoolStatus() {
263
+ const config = await loadConfig({});
264
+ const status = await getPoolStatus(config.poolUrl);
265
+
266
+ const lines = [
267
+ `Available: ${status.available ? 'yes' : 'no'}`,
268
+ `Running: ${status.running}/${status.maxConcurrent}`,
269
+ `Queued: ${status.queued}`,
270
+ `Sessions: ${status.sessions.length}`,
271
+ ];
272
+
273
+ if (status.error) {
274
+ lines.push(`Error: ${status.error}`);
275
+ }
276
+
277
+ return textResult(lines.join('\n'));
278
+ }
279
+
280
+ async function handlePoolStart(args) {
281
+ const overrides = {};
282
+ if (args.port) overrides.poolPort = args.port;
283
+ if (args.maxSessions) overrides.maxSessions = args.maxSessions;
284
+
285
+ const config = await loadConfig(overrides);
286
+ startPool(config);
287
+ return textResult(`Chrome pool started on port ${config.poolPort}`);
288
+ }
289
+
290
+ async function handlePoolStop() {
291
+ const config = await loadConfig({});
292
+ stopPool(config);
293
+ return textResult('Chrome pool stopped');
294
+ }
295
+
296
+ // ── Helpers ───────────────────────────────────────────────────────────────────
297
+
298
+ function textResult(text) {
299
+ return { content: [{ type: 'text', text }] };
300
+ }
301
+
302
+ function errorResult(message) {
303
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
304
+ }
305
+
306
+ // ── Server setup ──────────────────────────────────────────────────────────────
307
+
308
+ export async function startMcpServer() {
309
+ const server = new Server(
310
+ { name: 'e2e-runner', version: '1.0.0' },
311
+ { capabilities: { tools: {} } }
312
+ );
313
+
314
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
315
+ tools: TOOLS,
316
+ }));
317
+
318
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
319
+ const { name, arguments: args = {} } = request.params;
320
+
321
+ try {
322
+ switch (name) {
323
+ case 'e2e_run':
324
+ return await handleRun(args);
325
+ case 'e2e_list':
326
+ return await handleList();
327
+ case 'e2e_create_test':
328
+ return await handleCreateTest(args);
329
+ case 'e2e_pool_status':
330
+ return await handlePoolStatus();
331
+ case 'e2e_pool_start':
332
+ return await handlePoolStart(args);
333
+ case 'e2e_pool_stop':
334
+ return await handlePoolStop();
335
+ default:
336
+ return errorResult(`Unknown tool: ${name}`);
337
+ }
338
+ } catch (error) {
339
+ return errorResult(error.message);
340
+ }
341
+ });
342
+
343
+ const transport = new StdioServerTransport();
344
+ await server.connect(transport);
345
+ }
package/src/pool.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Pool Management
3
+ *
4
+ * Connectivity to the Chrome Pool (browserless/chrome) and Docker Compose lifecycle.
5
+ */
6
+
7
+ import puppeteer from 'puppeteer-core';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { execFileSync } from 'child_process';
11
+ import { fileURLToPath } from 'url';
12
+ import { log } from './logger.js';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ function sleep(ms) {
18
+ return new Promise(resolve => setTimeout(resolve, ms));
19
+ }
20
+
21
+ /** Waits for the Chrome Pool to become available */
22
+ export async function waitForPool(poolUrl, maxWaitMs = 30000) {
23
+ const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
24
+ const pressureUrl = `${poolHttpUrl}/pressure`;
25
+ const start = Date.now();
26
+
27
+ while (Date.now() - start < maxWaitMs) {
28
+ try {
29
+ const res = await fetch(pressureUrl);
30
+ if (res.ok) {
31
+ const data = await res.json();
32
+ if (data.pressure?.isAvailable) {
33
+ return data.pressure;
34
+ }
35
+ log('⏳', `Pool busy (${data.pressure.running}/${data.pressure.maxConcurrent}), waiting...`);
36
+ }
37
+ } catch {
38
+ // Pool not ready
39
+ }
40
+ await sleep(2000);
41
+ }
42
+ throw new Error(`Chrome Pool unavailable after ${maxWaitMs / 1000}s. Verify the container is running.`);
43
+ }
44
+
45
+ /** Connects to the pool with retries */
46
+ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
47
+ for (let attempt = 1; attempt <= retries; attempt++) {
48
+ try {
49
+ return await puppeteer.connect({
50
+ browserWSEndpoint: poolUrl,
51
+ timeout: 30000,
52
+ });
53
+ } catch (error) {
54
+ if (attempt === retries) {
55
+ throw new Error(`Failed to connect to pool: ${error.message}`);
56
+ }
57
+ log('🔄', `Attempt ${attempt}/${retries} failed, retrying...`);
58
+ await sleep(delay);
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Generates docker-compose.yml and starts the pool */
64
+ export function startPool(config) {
65
+ const cwd = process.cwd();
66
+ const poolDir = path.join(cwd, '.e2e-pool');
67
+
68
+ if (!fs.existsSync(poolDir)) {
69
+ fs.mkdirSync(poolDir, { recursive: true });
70
+ }
71
+
72
+ // Read template and interpolate variables
73
+ const templatePath = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
74
+ let template = fs.readFileSync(templatePath, 'utf-8');
75
+ template = template.replace(/\$\{PORT\}/g, String(config.poolPort || 3333));
76
+ template = template.replace(/\$\{MAX_SESSIONS\}/g, String(config.maxSessions || 5));
77
+
78
+ const composePath = path.join(poolDir, 'docker-compose.yml');
79
+ fs.writeFileSync(composePath, template);
80
+
81
+ // Add .e2e-pool/ to .gitignore if missing
82
+ const gitignorePath = path.join(cwd, '.gitignore');
83
+ if (fs.existsSync(gitignorePath)) {
84
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
85
+ if (!content.includes('.e2e-pool')) {
86
+ fs.appendFileSync(gitignorePath, '\n.e2e-pool/\n');
87
+ }
88
+ }
89
+
90
+ log('🐳', 'Starting Chrome Pool...');
91
+ execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { stdio: 'inherit' });
92
+ log('✅', `Chrome Pool started on port ${config.poolPort || 3333}`);
93
+ }
94
+
95
+ /** Stops the pool */
96
+ export function stopPool(config) {
97
+ const composePath = path.join(process.cwd(), '.e2e-pool', 'docker-compose.yml');
98
+ if (!fs.existsSync(composePath)) {
99
+ log('⚠️', '.e2e-pool/docker-compose.yml not found');
100
+ return;
101
+ }
102
+
103
+ log('🐳', 'Stopping Chrome Pool...');
104
+ execFileSync('docker', ['compose', '-f', composePath, 'down'], { stdio: 'inherit' });
105
+ log('✅', 'Chrome Pool stopped');
106
+ }
107
+
108
+ /** Restarts the pool */
109
+ export function restartPool(config) {
110
+ stopPool(config);
111
+ startPool(config);
112
+ }
113
+
114
+ /** Gets pool status */
115
+ export async function getPoolStatus(poolUrl) {
116
+ const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
117
+
118
+ try {
119
+ const [pressureRes, sessionsRes] = await Promise.all([
120
+ fetch(`${poolHttpUrl}/pressure`),
121
+ fetch(`${poolHttpUrl}/sessions`),
122
+ ]);
123
+
124
+ const pressure = pressureRes.ok ? await pressureRes.json() : null;
125
+ const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
126
+
127
+ return {
128
+ available: pressure?.pressure?.isAvailable ?? false,
129
+ running: pressure?.pressure?.running ?? 0,
130
+ maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
131
+ queued: pressure?.pressure?.queued ?? 0,
132
+ sessions: sessions || [],
133
+ };
134
+ } catch (error) {
135
+ return {
136
+ available: false,
137
+ error: error.message,
138
+ running: 0,
139
+ maxConcurrent: 0,
140
+ queued: 0,
141
+ sessions: [],
142
+ };
143
+ }
144
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Report generation — JSON output, JUnit XML, and formatted console output
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { colors as C } from './logger.js';
8
+
9
+ function escapeXml(str) {
10
+ return String(str)
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;');
15
+ }
16
+
17
+ /** Generates a report object from test results */
18
+ export function generateReport(results) {
19
+ const passed = results.filter(r => r.success).length;
20
+ const failed = results.filter(r => !r.success).length;
21
+ const totalDuration = results.reduce((acc, r) => acc + (new Date(r.endTime) - new Date(r.startTime)), 0);
22
+
23
+ return {
24
+ summary: {
25
+ total: results.length,
26
+ passed,
27
+ failed,
28
+ passRate: `${((passed / results.length) * 100).toFixed(1)}%`,
29
+ duration: `${(totalDuration / 1000).toFixed(1)}s`,
30
+ },
31
+ results,
32
+ generatedAt: new Date().toISOString(),
33
+ };
34
+ }
35
+
36
+ /** Generates JUnit XML string from a report */
37
+ export function generateJUnitXML(report) {
38
+ const { summary, results, generatedAt } = report;
39
+ const totalTime = parseFloat(summary.duration);
40
+
41
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
42
+ xml += `<testsuites tests="${summary.total}" failures="${summary.failed}" time="${totalTime}">\n`;
43
+ xml += ` <testsuite name="e2e" tests="${summary.total}" failures="${summary.failed}" time="${totalTime}" timestamp="${generatedAt}">\n`;
44
+
45
+ for (const result of results) {
46
+ const duration = ((new Date(result.endTime) - new Date(result.startTime)) / 1000).toFixed(3);
47
+ xml += ` <testcase name="${escapeXml(result.name)}" classname="e2e" time="${duration}">\n`;
48
+
49
+ if (!result.success) {
50
+ xml += ` <failure message="${escapeXml(result.error || 'Unknown error')}">${escapeXml(result.error || 'Unknown error')}</failure>\n`;
51
+ }
52
+
53
+ const logs = (result.consoleLogs || []).map(l => `[${l.type}] ${l.text}`).join('\n');
54
+ if (logs) {
55
+ xml += ` <system-out><![CDATA[${logs}]]></system-out>\n`;
56
+ }
57
+
58
+ xml += ' </testcase>\n';
59
+ }
60
+
61
+ xml += ' </testsuite>\n';
62
+ xml += '</testsuites>\n';
63
+
64
+ return xml;
65
+ }
66
+
67
+ /** Saves the report to disk based on outputFormat */
68
+ export function saveReport(report, screenshotsDir, config = {}) {
69
+ const format = config.outputFormat || 'json';
70
+ const saved = [];
71
+
72
+ if (format === 'json' || format === 'both') {
73
+ const reportPath = path.join(screenshotsDir, 'report.json');
74
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
75
+ saved.push(reportPath);
76
+ }
77
+
78
+ if (format === 'junit' || format === 'both') {
79
+ const junitPath = path.join(screenshotsDir, 'junit.xml');
80
+ fs.writeFileSync(junitPath, generateJUnitXML(report));
81
+ saved.push(junitPath);
82
+ }
83
+
84
+ return saved.length === 1 ? saved[0] : saved;
85
+ }
86
+
87
+ /** Prints a formatted report summary to the console */
88
+ export function printReport(report, screenshotsDir) {
89
+ const { summary } = report;
90
+ console.log('');
91
+ console.log(`${C.bold}${'='.repeat(50)}${C.reset}`);
92
+ console.log(`${C.bold} E2E RESULTS${C.reset}`);
93
+ console.log(`${'='.repeat(50)}`);
94
+ console.log(` Total: ${summary.total}`);
95
+ console.log(` Passed: ${C.green}${summary.passed}${C.reset}`);
96
+ console.log(` Failed: ${summary.failed > 0 ? C.red : C.green}${summary.failed}${C.reset}`);
97
+ console.log(` Rate: ${summary.passRate}`);
98
+ console.log(` Duration: ${summary.duration}`);
99
+ console.log(`${'='.repeat(50)}`);
100
+
101
+ const failures = report.results.filter(r => !r.success);
102
+ if (failures.length > 0) {
103
+ console.log(`\n${C.red}${C.bold}FAILURES:${C.reset}`);
104
+ failures.forEach(f => {
105
+ console.log(` ${C.red}✗${C.reset} ${f.name}: ${f.error}`);
106
+ if (f.errorScreenshot) {
107
+ console.log(` ${C.dim}Screenshot: ${f.errorScreenshot}${C.reset}`);
108
+ }
109
+ });
110
+ }
111
+
112
+ if (screenshotsDir) {
113
+ console.log(`\n${C.dim}Report: ${path.join(screenshotsDir, 'report.json')}${C.reset}`);
114
+ console.log(`${C.dim}Screenshots: ${screenshotsDir}${C.reset}\n`);
115
+ }
116
+ }