fix: broadcast k6 stdout summary and show full stats

- Remove --out json=- (was flooding stdout and hiding summary)
- Broadcast both stdout (summary table) and stderr (live warnings)
- Add --summary-trend-stats with p(90)/p(95)/p(99)
- Parse checks, failed count/total, data received, all percentiles
- Strip ANSI codes before regex matching
- Add threshold pass/fail banner with per-threshold status
- Show all duration percentiles as individual cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 19:46:39 +02:00
parent 6848d69a9d
commit 0cd0f96bab
3 changed files with 85 additions and 23 deletions

View File

@@ -132,7 +132,11 @@ app.post('/api/tests', (req, res) => {
const clients = new Set();
activeTests[id] = { clients };
const k6 = spawn('k6', ['run', '--out', 'json=-', tmpScript]);
const k6 = spawn('k6', [
'run',
'--summary-trend-stats', 'avg,min,med,max,p(90),p(95),p(99)',
tmpScript,
]);
const broadcast = (data) => {
outputBuffer += data;
@@ -141,14 +145,11 @@ app.post('/api/tests', (req, res) => {
}
};
k6.stdout.on('data', (data) => {
// k6 JSON metrics go to stdout when using --out json=-
// We collect but don't broadcast raw JSON (it's noisy)
});
// stdout = final summary table (the important part)
k6.stdout.on('data', (data) => broadcast(data.toString()));
k6.stderr.on('data', (data) => {
broadcast(data.toString());
});
// stderr = live progress bars + warnings
k6.stderr.on('data', (data) => broadcast(data.toString()));
k6.on('close', (code) => {
fs.unlink(tmpScript, () => {});
@@ -226,19 +227,33 @@ app.delete('/api/tests/:id', (req, res) => {
// Parse k6 terminal output for key metrics
function parseSummary(output) {
// Strip ANSI colour codes before matching
const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
const summary = {};
const patterns = {
http_reqs: /http_reqs\.*\s+([\d.]+)\s+([\d.]+\/s)/,
http_req_duration: /http_req_duration\.*\s+avg=([\d.]+\w+)\s+min=([\d.]+\w+)\s+med=([\d.]+\w+)\s+max=([\d.]+\w+)\s+p\(90\)=([\d.]+\w+)\s+p\(95\)=([\d.]+\w+)/,
http_req_failed: /http_req_failed\.*\s+([\d.]+%)/,
vus: /vus\.*\s+(\d+)\s+min=(\d+)\s+max=(\d+)/,
iterations: /iterations\.*\s+([\d.]+)\s+([\d.]+\/s)/,
http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/,
http_req_duration: /http_req_duration[\s.]+avg=([\d.µms]+)\s+min=([\d.µms]+)\s+med=([\d.µms]+)\s+max=([\d.µms]+)\s+p\(90\)=([\d.µms]+)\s+p\(95\)=([\d.µms]+)\s+p\(99\)=([\d.µms]+)/,
http_req_failed: /http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
iterations: /iterations[\s.]+(\d+)\s+([\d.]+\/s)/,
vus: /vus[\s.]+(\d+)\s+min=(\d+)\s+max=(\d+)/,
data_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
data_sent: /data_sent[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
};
for (const [key, re] of Object.entries(patterns)) {
const m = output.match(re);
const m = plain.match(re);
if (m) summary[key] = m.slice(1);
}
// Extract threshold results (✓ / ✗ lines)
const thresholds = [];
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });
}
if (thresholds.length) summary.thresholds = thresholds;
return summary;
}