Show timing stats (×N, p50, p95, pass%) on test dashboard

When test results include stats from --repeat mode, display inline
stat pills instead of raw duration. Adds TestStats interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-03 18:18:21 +02:00
parent 2267cfb640
commit 4dacde9ec1

324
src/views/TestsView.vue Normal file
View File

@ -0,0 +1,324 @@
<template>
<div class="tests-view">
<div class="tests-header">
<div class="tests-header-left">
<h2>Test Results</h2>
<div v-if="startedAt" class="tests-timestamps">
<span :title="startedAt">Started {{ relativeTime(startedAt) }}</span>
<span v-if="updatedAt && updatedAt !== startedAt" :title="updatedAt"> · Updated {{ relativeTime(updatedAt) }}</span>
</div>
</div>
<div class="tests-meta">
<span :class="['status-badge', overallStatus]">{{ overallStatus }}</span>
<span class="summary">{{ passedCount }}/{{ totalCount }} passed</span>
<span v-if="totalDuration" class="duration">{{ (totalDuration / 1000).toFixed(1) }}s</span>
</div>
</div>
<div v-if="results.length === 0" class="tests-empty">
No test results yet. Run <code>bash infra/test-run.sh</code> to start.
</div>
<div v-else class="tests-list">
<div
v-for="r in displayResults"
:key="r.test"
:class="['test-row', r.status]"
@click="toggleExpand(r.test)"
>
<span class="test-icon">
<span v-if="r.status === 'pass'" class="icon-pass">&#10003;</span>
<span v-else-if="r.status === 'fail' || r.status === 'error'" class="icon-fail">&#10007;</span>
<span v-else-if="r.status === 'running'" class="icon-running">&#9679;</span>
<span v-else class="icon-pending">&#9675;</span>
</span>
<span class="test-suite">{{ r.suite }}</span>
<span class="test-name">{{ r.test }}</span>
<span v-if="r.stats && r.stats.runs > 1" class="test-stats">
<span class="stat">×{{ r.stats.runs }}</span>
<span class="stat">p50 {{ r.stats.p50_ms }}ms</span>
<span class="stat">p95 {{ r.stats.p95_ms }}ms</span>
<span class="stat">{{ r.stats.pass_rate }}%</span>
</span>
<span v-else class="test-duration">{{ r.duration_ms ? r.duration_ms + 'ms' : '' }}</span>
<span :class="['test-status', r.status]">{{ r.status }}</span>
</div>
<!-- Expanded error detail -->
<div
v-for="r in displayResults.filter(r => expanded.has(r.test) && r.error)"
:key="r.test + '-error'"
class="test-error"
>
<pre>{{ r.error }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { getApiBase } from '../utils/apiBase';
import { relativeTime } from '../utils/relativeTime';
interface TestStats {
runs: number;
passed: number;
pass_rate: number;
min_ms: number;
avg_ms: number;
p50_ms: number;
p95_ms: number;
max_ms: number;
}
interface TestResult {
run_id: string;
test: string;
suite: string;
status: 'pass' | 'fail' | 'error' | 'running' | 'pending';
duration_ms: number;
error: string;
stats?: TestStats;
}
const results = ref<TestResult[]>([]);
const runId = ref('');
const startedAt = ref('');
const updatedAt = ref('');
const expanded = ref<Set<string>>(new Set());
let eventSource: EventSource | null = null;
const displayResults = computed(() =>
results.value.filter(r => r.test !== '__summary__')
);
const passedCount = computed(() => displayResults.value.filter(r => r.status === 'pass').length);
const failedCount = computed(() => displayResults.value.filter(r => r.status === 'fail' || r.status === 'error').length);
const totalCount = computed(() => displayResults.value.length);
const totalDuration = computed(() => displayResults.value.reduce((sum, r) => sum + (r.duration_ms || 0), 0));
const overallStatus = computed(() => {
if (displayResults.value.some(r => r.status === 'running')) return 'running';
if (failedCount.value > 0) return 'fail';
if (passedCount.value > 0) return 'pass';
return 'pending';
});
function toggleExpand(test: string) {
if (expanded.value.has(test)) {
expanded.value.delete(test);
} else {
expanded.value.add(test);
}
}
function handleResult(data: any) {
if (data.run_id && data.run_id !== runId.value) {
// New run clear old
results.value = [];
runId.value = data.run_id;
startedAt.value = data.ts || new Date().toISOString();
}
updatedAt.value = data.ts || new Date().toISOString();
// Update or append
const idx = results.value.findIndex(r => r.test === data.test && r.suite === data.suite);
if (idx >= 0) {
results.value[idx] = data;
} else {
results.value.push(data);
}
}
onMounted(() => {
const base = getApiBase();
// First fetch snapshot
fetch(`${base}/api/test-results/latest`)
.then(r => r.json())
.then(data => {
if (data.run_id) runId.value = data.run_id;
if (data.results) {
results.value = data.results;
// Extract timestamps from results
const timestamps = data.results.map((r: any) => r.ts).filter(Boolean).sort();
if (timestamps.length) {
startedAt.value = timestamps[0];
updatedAt.value = timestamps[timestamps.length - 1];
}
}
})
.catch(() => {});
// Then connect SSE for live updates
eventSource = new EventSource(`${base}/api/test-results`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.run_id) handleResult(data);
} catch {}
};
});
onUnmounted(() => {
eventSource?.close();
eventSource = null;
});
</script>
<style scoped>
.tests-view {
max-width: 900px;
margin: 0 auto;
padding: 24px;
color: var(--text);
}
.tests-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.tests-header-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.tests-header h2 {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.tests-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.85rem;
color: var(--text-dim);
}
.tests-timestamps {
font-size: 0.75rem;
color: var(--text-dim);
opacity: 0.7;
}
.run-id {
font-family: monospace;
opacity: 0.6;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
}
.status-badge.pass { background: #166534; color: #bbf7d0; }
.status-badge.fail { background: #991b1b; color: #fecaca; }
.status-badge.running { background: #854d0e; color: #fef08a; }
.status-badge.pending { background: var(--panel-bg); color: var(--text-dim); }
.tests-empty {
padding: 40px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.tests-empty code {
background: var(--panel-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
}
.tests-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.test-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--panel-bg);
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
font-size: 0.85rem;
}
.test-row:hover { background: color-mix(in srgb, var(--panel-bg) 80%, var(--text) 5%); }
.test-icon { width: 20px; text-align: center; flex-shrink: 0; }
.icon-pass { color: #22c55e; }
.icon-fail { color: #ef4444; }
.icon-running { color: #eab308; animation: pulse 1s infinite; }
.icon-pending { color: var(--text-dim); }
@keyframes pulse { 50% { opacity: 0.4; } }
.test-suite {
color: var(--text-dim);
min-width: 80px;
font-family: monospace;
font-size: 0.75rem;
}
.test-name { flex: 1; }
.test-duration {
font-family: monospace;
color: var(--text-dim);
font-size: 0.75rem;
min-width: 60px;
text-align: right;
}
.test-status {
min-width: 50px;
text-align: right;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
}
.test-status.pass { color: #22c55e; }
.test-status.fail, .test-status.error { color: #ef4444; }
.test-status.running { color: #eab308; }
.test-error {
margin: 0 0 2px 30px;
padding: 8px 12px;
background: #1a0000;
border-left: 3px solid #ef4444;
border-radius: 0 6px 6px 0;
}
.test-error pre {
margin: 0;
font-size: 0.8rem;
color: #fca5a5;
white-space: pre-wrap;
word-break: break-word;
}
.test-stats {
display: flex;
gap: 8px;
font-family: monospace;
font-size: 0.7rem;
color: var(--text-dim);
}
.test-stats .stat {
background: color-mix(in srgb, var(--panel-bg) 60%, var(--text) 8%);
padding: 1px 5px;
border-radius: 3px;
}
</style>