@bearcreekai/testing-kx-mcp 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/bin.js +105 -0
- package/lib/playwright-runner.js +222 -0
- package/lib/result-parser.js +207 -0
- package/lib/test-catalog.js +202 -0
- package/lib/test-generator.js +297 -0
- package/package.json +42 -0
- package/server.js +163 -0
- package/tools/add-to-suite.js +94 -0
- package/tools/generate-test.js +71 -0
- package/tools/get-results.js +54 -0
- package/tools/get-status.js +11 -0
- package/tools/healing-report.js +69 -0
- package/tools/list-tests.js +31 -0
- package/tools/run-tests.js +76 -0
- package/tools/suggest-tests.js +27 -0
package/bin.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI entry point for testing-kx MCP server.
|
|
5
|
+
* Usage: npx @knowledgexpert/testing-kx-mcp
|
|
6
|
+
*
|
|
7
|
+
* This starts the MCP server via stdio transport.
|
|
8
|
+
* It auto-downloads test files on first run if not present.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
|
|
19
|
+
// When running via npx, the package is in a temp directory.
|
|
20
|
+
// We need the test files to be in a known location.
|
|
21
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
22
|
+
const DATA_DIR = path.join(HOME, '.testing-kx');
|
|
23
|
+
const REPO_URL = process.env.TESTING_KX_REPO || 'https://github.com/BearCreekAI/testing-kx.git';
|
|
24
|
+
|
|
25
|
+
async function ensureTestFiles() {
|
|
26
|
+
// If running from the actual repo (not npx), test files are already here
|
|
27
|
+
const localTests = path.join(__dirname, '..', 'tests');
|
|
28
|
+
if (fs.existsSync(localTests)) {
|
|
29
|
+
// We're in the repo — set PROJECT_ROOT and run
|
|
30
|
+
process.env.TESTING_KX_ROOT = path.resolve(__dirname, '..');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Running via npx — check if test files are cached at ~/.testing-kx
|
|
35
|
+
if (fs.existsSync(path.join(DATA_DIR, 'tests'))) {
|
|
36
|
+
process.env.TESTING_KX_ROOT = DATA_DIR;
|
|
37
|
+
console.error(`Using cached test files from ${DATA_DIR}`);
|
|
38
|
+
|
|
39
|
+
// Pull latest in background (non-blocking)
|
|
40
|
+
const pull = spawn('git', ['pull', '--quiet'], { cwd: DATA_DIR, stdio: 'ignore', shell: true });
|
|
41
|
+
pull.unref();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// First run — clone the repo
|
|
46
|
+
console.error(`First run: downloading test files to ${DATA_DIR}...`);
|
|
47
|
+
try {
|
|
48
|
+
const clone = spawn('git', ['clone', '--depth', '1', REPO_URL, DATA_DIR], {
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
shell: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
clone.on('close', (code) => {
|
|
55
|
+
if (code === 0) resolve();
|
|
56
|
+
else reject(new Error(`Git clone failed with code ${code}`));
|
|
57
|
+
});
|
|
58
|
+
clone.on('error', reject);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Install dependencies
|
|
62
|
+
console.error('Installing dependencies...');
|
|
63
|
+
const install = spawn('npm', ['ci', '--production'], {
|
|
64
|
+
cwd: DATA_DIR,
|
|
65
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
66
|
+
shell: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
install.on('close', (code) => {
|
|
71
|
+
if (code === 0) resolve();
|
|
72
|
+
else reject(new Error(`npm install failed with code ${code}`));
|
|
73
|
+
});
|
|
74
|
+
install.on('error', reject);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Install playwright browsers
|
|
78
|
+
console.error('Installing Playwright browsers...');
|
|
79
|
+
const pwInstall = spawn('npx', ['playwright', 'install', 'chromium'], {
|
|
80
|
+
cwd: DATA_DIR,
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
shell: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
pwInstall.on('close', (code) => {
|
|
87
|
+
if (code === 0) resolve();
|
|
88
|
+
else reject(new Error(`Playwright install failed with code ${code}`));
|
|
89
|
+
});
|
|
90
|
+
pwInstall.on('error', reject);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
process.env.TESTING_KX_ROOT = DATA_DIR;
|
|
94
|
+
console.error('Setup complete!');
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`Setup failed: ${err.message}`);
|
|
97
|
+
console.error('Falling back to local mode. Make sure you have the testing-kx repo cloned.');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await ensureTestFiles();
|
|
103
|
+
|
|
104
|
+
// Now start the actual MCP server
|
|
105
|
+
await import('./server.js');
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
// Support both running from repo (../.. from mcp/lib/) and via npx (TESTING_KX_ROOT env)
|
|
11
|
+
const PROJECT_ROOT = process.env.TESTING_KX_ROOT || path.resolve(__dirname, '..', '..');
|
|
12
|
+
const LOCK_FILE = path.join(PROJECT_ROOT, 'test-results', '.mcp-lock');
|
|
13
|
+
const RESULTS_FILE = path.join(PROJECT_ROOT, 'test-results', 'results.json');
|
|
14
|
+
|
|
15
|
+
// Module-level state
|
|
16
|
+
let isRunning = false;
|
|
17
|
+
let currentRun = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if another process (Express server or manual CLI) is running tests.
|
|
21
|
+
*/
|
|
22
|
+
function isExternalRunActive() {
|
|
23
|
+
// Check if Express server's lock exists (it writes results.json actively)
|
|
24
|
+
// Also check our own lock file
|
|
25
|
+
if (fs.existsSync(LOCK_FILE)) {
|
|
26
|
+
try {
|
|
27
|
+
const lockData = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
|
|
28
|
+
const lockAge = Date.now() - lockData.timestamp;
|
|
29
|
+
// If lock is older than 15 minutes, it's stale — remove it
|
|
30
|
+
if (lockAge > 15 * 60 * 1000) {
|
|
31
|
+
fs.unlinkSync(LOCK_FILE);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function acquireLock(runId) {
|
|
43
|
+
const lockData = { runId, timestamp: Date.now(), pid: process.pid };
|
|
44
|
+
fs.writeFileSync(LOCK_FILE, JSON.stringify(lockData));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function releaseLock() {
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(LOCK_FILE)) fs.unlinkSync(LOCK_FILE);
|
|
50
|
+
} catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current run status.
|
|
55
|
+
*/
|
|
56
|
+
export function getStatus() {
|
|
57
|
+
if (!isRunning || !currentRun) {
|
|
58
|
+
return { isRunning: false, currentRun: null };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
isRunning: true,
|
|
62
|
+
currentRun: {
|
|
63
|
+
runId: currentRun.id,
|
|
64
|
+
startTime: currentRun.startTime,
|
|
65
|
+
elapsedSeconds: Math.round((Date.now() - new Date(currentRun.startTime).getTime()) / 1000),
|
|
66
|
+
testLabel: currentRun.testLabel,
|
|
67
|
+
lastOutput: currentRun.output.slice(-500),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a URL is reachable (server is running).
|
|
74
|
+
*/
|
|
75
|
+
export function checkServerHealth(url, timeoutMs = 5000) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const client = url.startsWith('https') ? https : http;
|
|
78
|
+
const req = client.get(url, { timeout: timeoutMs }, (res) => {
|
|
79
|
+
resolve({ reachable: true, statusCode: res.statusCode });
|
|
80
|
+
});
|
|
81
|
+
req.on('error', () => resolve({ reachable: false }));
|
|
82
|
+
req.on('timeout', () => { req.destroy(); resolve({ reachable: false }); });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Spawn Playwright and run tests.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} opts
|
|
90
|
+
* @param {string} [opts.tag] - Test tag (@smoke, @regression, etc.)
|
|
91
|
+
* @param {string} [opts.file] - Specific test file
|
|
92
|
+
* @param {string} [opts.grep] - Playwright --grep pattern
|
|
93
|
+
* @param {string} [opts.baseUrl] - URL to test against (default: http://localhost:5173)
|
|
94
|
+
* @param {number} [opts.timeout] - Max wait in ms (default 600000)
|
|
95
|
+
* @returns {Promise<Object>} Structured test results
|
|
96
|
+
*/
|
|
97
|
+
export function runPlaywright(opts = {}) {
|
|
98
|
+
return new Promise(async (resolve, reject) => {
|
|
99
|
+
if (isRunning) {
|
|
100
|
+
reject(new Error('Tests are already running. Use get_test_status to check progress.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isExternalRunActive()) {
|
|
105
|
+
reject(new Error('Tests are already running from another source (dashboard or CLI). Try again later.'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Determine BASE_URL — MCP defaults to localhost (dev workflow)
|
|
110
|
+
const DEFAULT_LOCAL_URL = process.env.MCP_DEFAULT_URL || 'http://localhost:5173';
|
|
111
|
+
const baseUrl = opts.baseUrl || DEFAULT_LOCAL_URL;
|
|
112
|
+
|
|
113
|
+
// Health check — make sure the target server is running
|
|
114
|
+
const health = await checkServerHealth(baseUrl);
|
|
115
|
+
if (!health.reachable) {
|
|
116
|
+
reject(new Error(
|
|
117
|
+
`Cannot reach ${baseUrl} — is your local dev server running?\n\n` +
|
|
118
|
+
`Start your frontend first (e.g. "npm run dev"), then try again.\n` +
|
|
119
|
+
`If you intentionally want to test a different URL, pass baseUrl explicitly.`
|
|
120
|
+
));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
isRunning = true;
|
|
125
|
+
const startTime = new Date();
|
|
126
|
+
const runId = Date.now().toString();
|
|
127
|
+
|
|
128
|
+
const testLabel = opts.tag ? `@${opts.tag} suite` : (opts.file || opts.grep || 'all');
|
|
129
|
+
|
|
130
|
+
currentRun = {
|
|
131
|
+
id: runId,
|
|
132
|
+
startTime: startTime.toISOString(),
|
|
133
|
+
testLabel,
|
|
134
|
+
output: '',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
acquireLock(runId);
|
|
138
|
+
|
|
139
|
+
// Build playwright args
|
|
140
|
+
const args = ['playwright', 'test'];
|
|
141
|
+
if (opts.tag) {
|
|
142
|
+
args.push('--grep', `@${opts.tag}`);
|
|
143
|
+
} else if (opts.file) {
|
|
144
|
+
args.push(opts.file);
|
|
145
|
+
} else if (opts.grep) {
|
|
146
|
+
args.push('--grep', opts.grep);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const child = spawn('npx', args, {
|
|
150
|
+
cwd: PROJECT_ROOT,
|
|
151
|
+
env: { ...process.env, CI: 'true', BASE_URL: baseUrl },
|
|
152
|
+
shell: true,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
let stdout = '';
|
|
156
|
+
let stderr = '';
|
|
157
|
+
|
|
158
|
+
child.stdout.on('data', (data) => {
|
|
159
|
+
const text = data.toString();
|
|
160
|
+
stdout += text;
|
|
161
|
+
currentRun.output += text;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.stderr.on('data', (data) => {
|
|
165
|
+
const text = data.toString();
|
|
166
|
+
stderr += text;
|
|
167
|
+
currentRun.output += text;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Timeout handling
|
|
171
|
+
const timeout = opts.timeout || 600000;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
child.kill('SIGTERM');
|
|
174
|
+
isRunning = false;
|
|
175
|
+
currentRun = null;
|
|
176
|
+
releaseLock();
|
|
177
|
+
reject(new Error(`Test run timed out after ${timeout / 1000}s. Tests may still be running.`));
|
|
178
|
+
}, timeout);
|
|
179
|
+
|
|
180
|
+
child.on('close', (code) => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
const endTime = new Date();
|
|
183
|
+
const duration = (endTime - startTime) / 1000;
|
|
184
|
+
|
|
185
|
+
// Read results.json
|
|
186
|
+
let results = null;
|
|
187
|
+
try {
|
|
188
|
+
if (fs.existsSync(RESULTS_FILE)) {
|
|
189
|
+
results = JSON.parse(fs.readFileSync(RESULTS_FILE, 'utf8'));
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// results.json may not exist if all tests were filtered out
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const runResult = {
|
|
196
|
+
id: runId,
|
|
197
|
+
startTime: startTime.toISOString(),
|
|
198
|
+
endTime: endTime.toISOString(),
|
|
199
|
+
durationSeconds: Math.round(duration),
|
|
200
|
+
status: code === 0 ? 'passed' : 'failed',
|
|
201
|
+
exitCode: code,
|
|
202
|
+
testLabel,
|
|
203
|
+
stdout: stdout.slice(-5000), // Keep last 5KB
|
|
204
|
+
results,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
isRunning = false;
|
|
208
|
+
currentRun = null;
|
|
209
|
+
releaseLock();
|
|
210
|
+
|
|
211
|
+
resolve(runResult);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.on('error', (error) => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
isRunning = false;
|
|
217
|
+
currentRun = null;
|
|
218
|
+
releaseLock();
|
|
219
|
+
reject(error);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const PROJECT_ROOT = process.env.TESTING_KX_ROOT || path.resolve(__dirname, '..', '..');
|
|
8
|
+
const RESULTS_FILE = path.join(PROJECT_ROOT, 'test-results', 'results.json');
|
|
9
|
+
const HISTORY_FILE = path.join(PROJECT_ROOT, 'test-results', 'test-history.json');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract failures from Playwright JSON results.
|
|
13
|
+
* Mirrors server.js extractFailuresFromResults().
|
|
14
|
+
*/
|
|
15
|
+
export function extractFailures(results) {
|
|
16
|
+
const failures = [];
|
|
17
|
+
function walk(suites, parentPath) {
|
|
18
|
+
if (!suites) return;
|
|
19
|
+
for (const suite of suites) {
|
|
20
|
+
const suitePath = parentPath ? `${parentPath} > ${suite.title}` : suite.title;
|
|
21
|
+
if (suite.specs) {
|
|
22
|
+
for (const spec of suite.specs) {
|
|
23
|
+
if (!spec.ok && spec.tests) {
|
|
24
|
+
for (const test of spec.tests) {
|
|
25
|
+
if (test.status === 'unexpected' || test.status === 'failed') {
|
|
26
|
+
const errorResult = (test.results || []).find(r => r.status === 'failed');
|
|
27
|
+
failures.push({
|
|
28
|
+
title: spec.title,
|
|
29
|
+
file: spec.file || 'unknown',
|
|
30
|
+
line: spec.line || '?',
|
|
31
|
+
suitePath,
|
|
32
|
+
errorMessage: errorResult?.error?.message || 'Unknown error',
|
|
33
|
+
stack: errorResult?.error?.stack || '',
|
|
34
|
+
retries: (test.results || []).length,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (suite.suites) walk(suite.suites, suitePath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
walk(results.suites, '');
|
|
45
|
+
return failures;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract all tests with their status from results.
|
|
50
|
+
*/
|
|
51
|
+
function extractAllTests(results) {
|
|
52
|
+
const tests = [];
|
|
53
|
+
function walk(suites, parentPath) {
|
|
54
|
+
if (!suites) return;
|
|
55
|
+
for (const suite of suites) {
|
|
56
|
+
const suitePath = parentPath ? `${parentPath} > ${suite.title}` : suite.title;
|
|
57
|
+
if (suite.specs) {
|
|
58
|
+
for (const spec of suite.specs) {
|
|
59
|
+
if (spec.tests) {
|
|
60
|
+
for (const test of spec.tests) {
|
|
61
|
+
const lastResult = (test.results || []).at(-1);
|
|
62
|
+
tests.push({
|
|
63
|
+
title: spec.title,
|
|
64
|
+
file: spec.file || 'unknown',
|
|
65
|
+
suitePath,
|
|
66
|
+
status: test.status,
|
|
67
|
+
ok: spec.ok,
|
|
68
|
+
duration: lastResult?.duration || 0,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (suite.suites) walk(suite.suites, suitePath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
walk(results.suites, '');
|
|
78
|
+
return tests;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse run results into a structured summary.
|
|
83
|
+
*/
|
|
84
|
+
export function parseSummary(runResult) {
|
|
85
|
+
const results = runResult.results;
|
|
86
|
+
if (!results) {
|
|
87
|
+
return {
|
|
88
|
+
runId: runResult.id,
|
|
89
|
+
status: runResult.status,
|
|
90
|
+
durationSeconds: runResult.durationSeconds,
|
|
91
|
+
message: 'No detailed results available (results.json not found)',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stats = results.stats || {};
|
|
96
|
+
const failures = extractFailures(results);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
runId: runResult.id,
|
|
100
|
+
status: runResult.status,
|
|
101
|
+
durationSeconds: runResult.durationSeconds,
|
|
102
|
+
passed: stats.expected || 0,
|
|
103
|
+
failed: stats.unexpected || 0,
|
|
104
|
+
flaky: stats.flaky || 0,
|
|
105
|
+
skipped: stats.skipped || 0,
|
|
106
|
+
total: (stats.expected || 0) + (stats.unexpected || 0) + (stats.flaky || 0) + (stats.skipped || 0),
|
|
107
|
+
failedTests: failures.map(f => ({
|
|
108
|
+
name: f.title,
|
|
109
|
+
file: f.file,
|
|
110
|
+
error: f.errorMessage.slice(0, 200),
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse run results with full error details.
|
|
117
|
+
*/
|
|
118
|
+
export function parseDetailed(runResult) {
|
|
119
|
+
const summary = parseSummary(runResult);
|
|
120
|
+
const results = runResult.results;
|
|
121
|
+
if (!results) return summary;
|
|
122
|
+
|
|
123
|
+
const failures = extractFailures(results);
|
|
124
|
+
const allTests = extractAllTests(results);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
...summary,
|
|
128
|
+
failedTests: failures.map(f => ({
|
|
129
|
+
name: f.title,
|
|
130
|
+
file: f.file,
|
|
131
|
+
line: f.line,
|
|
132
|
+
suitePath: f.suitePath,
|
|
133
|
+
error: f.errorMessage,
|
|
134
|
+
stack: f.stack.split('\n').slice(0, 30).join('\n'),
|
|
135
|
+
retries: f.retries,
|
|
136
|
+
})),
|
|
137
|
+
allTests: allTests.map(t => ({
|
|
138
|
+
name: t.title,
|
|
139
|
+
file: t.file,
|
|
140
|
+
status: t.ok ? 'passed' : (t.status === 'flaky' ? 'flaky' : 'failed'),
|
|
141
|
+
durationMs: t.duration,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse only failures with full details.
|
|
148
|
+
*/
|
|
149
|
+
export function parseFailuresOnly(runResult) {
|
|
150
|
+
const results = runResult.results;
|
|
151
|
+
if (!results) {
|
|
152
|
+
return {
|
|
153
|
+
runId: runResult.id,
|
|
154
|
+
status: runResult.status,
|
|
155
|
+
message: 'No detailed results available',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const failures = extractFailures(results);
|
|
160
|
+
return {
|
|
161
|
+
runId: runResult.id,
|
|
162
|
+
status: runResult.status,
|
|
163
|
+
failureCount: failures.length,
|
|
164
|
+
failures: failures.map(f => ({
|
|
165
|
+
name: f.title,
|
|
166
|
+
file: f.file,
|
|
167
|
+
line: f.line,
|
|
168
|
+
suitePath: f.suitePath,
|
|
169
|
+
error: f.errorMessage,
|
|
170
|
+
stack: f.stack.split('\n').slice(0, 30).join('\n'),
|
|
171
|
+
retries: f.retries,
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the latest results.json from disk.
|
|
178
|
+
*/
|
|
179
|
+
export function readLatestResults() {
|
|
180
|
+
try {
|
|
181
|
+
if (fs.existsSync(RESULTS_FILE)) {
|
|
182
|
+
return JSON.parse(fs.readFileSync(RESULTS_FILE, 'utf8'));
|
|
183
|
+
}
|
|
184
|
+
} catch { /* ignore */ }
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read test history from disk.
|
|
190
|
+
*/
|
|
191
|
+
export function readHistory(limit = 20) {
|
|
192
|
+
try {
|
|
193
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
194
|
+
const data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8'));
|
|
195
|
+
return data.slice(0, limit);
|
|
196
|
+
}
|
|
197
|
+
} catch { /* ignore */ }
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Find a specific run from history by ID.
|
|
203
|
+
*/
|
|
204
|
+
export function findRunById(runId) {
|
|
205
|
+
const history = readHistory(50);
|
|
206
|
+
return history.find(r => r.id === runId) || null;
|
|
207
|
+
}
|