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