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:
parent
2267cfb640
commit
4dacde9ec1
324
src/views/TestsView.vue
Normal file
324
src/views/TestsView.vue
Normal 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">✓</span>
|
||||||
|
<span v-else-if="r.status === 'fail' || r.status === 'error'" class="icon-fail">✗</span>
|
||||||
|
<span v-else-if="r.status === 'running'" class="icon-running">●</span>
|
||||||
|
<span v-else class="icon-pending">○</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>
|
||||||
Reference in New Issue
Block a user