Files
RUWIO/src/devtools/TestOverlay.vue
T
2026-04-23 19:33:22 +02:00

711 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhArrowClockwise, PhCheck, PhClipboardText, PhWarning, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { t, useI18n } from '@/i18n'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const running = ref(false)
const done = ref(0)
const total = ref(0)
const results = ref<TestResult[]>([])
const startedAt = ref(0)
const finishedAt = ref(0)
const clipboardStatus = ref<'idle' | 'copied' | 'failed'>('idle')
const currentSuite = ref('')
const currentTest = ref('')
const progressPct = computed(() =>
total.value > 0 ? Math.round((done.value / total.value) * 100) : 0,
)
const passedSoFar = computed(() => results.value.filter((r) => r.ok).length)
const failedSoFar = computed(() => results.value.filter((r) => !r.ok).length)
function buildReport(): string {
const failures = results.value.filter((r) => !r.ok)
const pass = results.value.filter((r) => r.ok).length
const fail = failures.length
const durMs = Math.round(finishedAt.value - startedAt.value)
const lines: string[] = []
lines.push('# Animatr Test Report')
lines.push('')
lines.push(`**Run at:** ${new Date().toISOString()}`)
lines.push(`**Duration:** ${durMs} ms`)
lines.push(`**Result:** ${pass} passed, ${fail} failed / ${results.value.length} total`)
lines.push('')
lines.push('## Environment')
lines.push(`- URL: ${location.href}`)
lines.push(`- User Agent: ${navigator.userAgent}`)
lines.push(`- Viewport: ${window.innerWidth}×${window.innerHeight}`)
lines.push(`- DPR: ${window.devicePixelRatio}`)
lines.push(`- Platform: ${navigator.platform}`)
lines.push(`- Language: ${navigator.language}`)
lines.push('')
if (fail === 0) {
lines.push('All tests passed. ✅')
return lines.join('\n')
}
lines.push('## Failures')
lines.push('')
for (const f of failures) {
lines.push(`### [${f.suite}] › ${f.name}`)
lines.push(`_${Math.round(f.durationMs)} ms_`)
lines.push('')
lines.push('```')
lines.push(f.error ?? '(no message)')
if (f.stack && f.stack.trim()) {
lines.push('')
lines.push(f.stack)
}
lines.push('```')
lines.push('')
}
lines.push('## Passing suites')
const suites = new Map<string, { pass: number; total: number }>()
for (const r of results.value) {
const s = suites.get(r.suite) ?? { pass: 0, total: 0 }
s.total++
if (r.ok) s.pass++
suites.set(r.suite, s)
}
for (const [name, s] of suites.entries()) {
const mark = s.pass === s.total ? '✅' : '⚠️'
lines.push(`- ${mark} ${name} — ${s.pass}/${s.total}`)
}
return lines.join('\n')
}
async function copyReport() {
const report = buildReport()
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(report)
} else {
const ta = document.createElement('textarea')
ta.value = report
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
clipboardStatus.value = 'copied'
} catch (err) {
console.error('clipboard copy failed', err)
clipboardStatus.value = 'failed'
}
setTimeout(() => {
clipboardStatus.value = 'idle'
}, 3000)
}
const grouped = computed(() => {
const map = new Map<string, TestResult[]>()
for (const r of results.value) {
const arr = map.get(r.suite) ?? []
arr.push(r)
map.set(r.suite, arr)
}
return Array.from(map.entries()).map(([suite, tests]) => ({
suite,
tests,
ok: tests.every((t) => t.ok),
}))
})
const passed = computed(() => results.value.filter((r) => r.ok).length)
const failed = computed(() => results.value.filter((r) => !r.ok).length)
const totalMs = computed(() =>
Math.round(results.value.reduce((a, r) => a + r.durationMs, 0)),
)
async function run() {
if (running.value) return
running.value = true
results.value = []
done.value = 0
total.value = 0
currentSuite.value = ''
currentTest.value = ''
clipboardStatus.value = 'idle'
startedAt.value = performance.now()
const snapshot = JSON.parse(JSON.stringify(store.$state))
// The test harness asserts against English UI strings (aria-labels,
// section titles, help dialog headings, "yet"/"unsaved" substrings).
// Pin the locale to 'en' for the duration of the run so a German
// browser default doesn't flip them all red. Restore on finally.
const { locale } = useI18n()
const prevLocale = locale.value
locale.value = 'en'
try {
await runAll(registerAllTests, {
onProgress: (d, t) => {
done.value = d
total.value = t
},
onTestStart: (suiteName, testName) => {
currentSuite.value = suiteName
currentTest.value = testName
},
// Push each result as it arrives so `failedSoFar` updates live
// — that's what drives the progress bar turning red the moment
// any test fails, instead of only at the end of the run.
onResult: (r) => {
results.value.push(r)
},
interTestDelayMs: 80,
})
} finally {
store.$state = snapshot
locale.value = prevLocale
finishedAt.value = performance.now()
running.value = false
}
if (results.value.some((r) => !r.ok)) {
await copyReport()
}
}
function close() {
emit('update:modelValue', false)
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape' && !running.value) close()
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
if (props.modelValue) run()
})
watch(
() => props.modelValue,
(v) => {
if (v && !running.value) run()
},
)
onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
</script>
<template>
<Teleport to="body">
<Transition name="hud-pop">
<div v-if="modelValue && running" class="hud" role="status" aria-live="polite">
<div class="hud-top">
<span class="hud-tag">{{ t('tests.running') }}</span>
<span class="hud-counts">
<span class="hud-count">{{ done }}/{{ total }}</span>
<span class="hud-chip ok" v-if="passedSoFar">{{ passedSoFar }} ✓</span>
<span class="hud-chip bad" v-if="failedSoFar">{{ failedSoFar }} ✗</span>
</span>
</div>
<div class="hud-current">
<span class="hud-suite">{{ currentSuite || '…' }}</span>
<span class="hud-sep">›</span>
<span class="hud-test">{{ currentTest || '…' }}</span>
</div>
<div class="hud-bar">
<div
class="hud-bar-fill"
:class="{ 'has-failures': failedSoFar > 0 }"
:style="{ width: progressPct + '%' }"
></div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="modelValue && !running" class="overlay" @mousedown.self="close">
<div class="panel" role="dialog" aria-modal="true">
<header class="hdr">
<div class="hdr-title">
<span>{{ t('tests.title') }}</span>
<span v-if="results.length" class="hdr-status">
<span class="ok-badge" :class="{ bad: failed > 0 }">
{{ passed }}/{{ results.length }}
</span>
<span class="dim">{{ totalMs }} ms</span>
</span>
</div>
<div class="hdr-actions">
<button
class="tbtn"
:disabled="!results.length"
v-tooltip="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" v-tooltip="t('tests.runAgain')" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" v-tooltip="t('tests.close')" @click="close">
<PhX :size="14" weight="bold" />
</button>
</div>
</header>
<div v-if="clipboardStatus !== 'idle'" class="clip-banner" :class="clipboardStatus">
<span v-if="clipboardStatus === 'copied'">
{{ t('tests.copiedBanner') }}
</span>
<span v-else>
{{ t('tests.copyFailedBanner') }}
</span>
</div>
<div class="body">
<div v-if="!results.length" class="empty">
{{ t('tests.emptyState', { shortcut: '⌃⇧⌥T' }) }}
</div>
<div v-for="g in grouped" :key="g.suite" class="suite">
<div class="suite-hdr" :class="{ bad: !g.ok }">
<PhCheck v-if="g.ok" :size="12" weight="bold" class="icon ok" />
<PhWarning v-else :size="12" weight="fill" class="icon bad" />
<span class="suite-name">{{ g.suite }}</span>
<span class="suite-count">
{{ g.tests.filter((t) => t.ok).length }}/{{ g.tests.length }}
</span>
</div>
<ul class="tests">
<li v-for="t in g.tests" :key="t.name" class="test" :class="{ bad: !t.ok }">
<span class="test-dot" :class="{ ok: t.ok, bad: !t.ok }"></span>
<span class="test-name">{{ t.name }}</span>
<span class="test-dur">{{ Math.round(t.durationMs) }}ms</span>
<pre v-if="!t.ok" class="test-err">{{ t.error }}</pre>
</li>
</ul>
</div>
</div>
<footer class="ftr">
<span class="ftr-item">{{ t('tests.testsCountLabel', { n: results.length }) }}</span>
<span class="ftr-item ok" v-if="passed">{{ t('tests.passedLabel', { n: passed }) }}</span>
<span class="ftr-item bad" v-if="failed">{{ t('tests.failedLabel', { n: failed }) }}</span>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: stretch;
justify-content: flex-end;
backdrop-filter: blur(2px);
}
.panel {
width: min(640px, 100%);
background: var(--panel);
border-left: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
}
.hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.hdr-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 13px;
}
.hdr-status {
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
display: inline-flex;
gap: 6px;
align-items: center;
}
.hdr-status.running {
color: var(--accent);
}
.ok-badge {
padding: 2px 8px;
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.ok-badge.bad {
background: var(--danger-soft);
color: var(--danger);
}
.dim {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.hdr-actions {
display: flex;
gap: 4px;
}
.tbtn {
width: 28px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: var(--radius-sm);
}
.tbtn:hover:not(:disabled) {
background: var(--panel-alt);
color: var(--text);
}
.tbtn.copied {
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
border-color: #47bf82;
}
.tbtn.failed {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.clip-banner {
padding: 8px 14px;
font-size: 11px;
background: rgba(71, 191, 130, 0.14);
color: #47bf82;
border-bottom: 1px solid rgba(71, 191, 130, 0.3);
}
.clip-banner.failed {
background: var(--danger-soft);
color: var(--danger);
border-bottom-color: var(--danger);
}
.progress {
height: 2px;
background: var(--border);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 120ms linear;
}
.body {
flex: 1;
overflow: auto;
padding: 8px 10px 12px;
}
.empty {
color: var(--text-dim);
font-size: 12px;
padding: 20px 10px;
text-align: center;
}
.empty kbd {
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
}
.suite {
margin-top: 10px;
}
.suite-hdr {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
}
.suite-hdr.bad {
border-color: var(--danger);
}
.suite-name {
flex: 1;
}
.suite-count {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-size: 11px;
}
.icon.ok {
color: #47bf82;
}
.icon.bad {
color: var(--danger);
}
.tests {
list-style: none;
margin: 0;
padding: 4px 0 0 14px;
}
.test {
display: grid;
grid-template-columns: 12px 1fr auto;
gap: 8px;
align-items: center;
padding: 3px 4px;
font-size: 12px;
color: var(--text-dim-strong);
}
.test.bad {
color: var(--text);
}
.test-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border);
}
.test-dot.ok {
background: #47bf82;
}
.test-dot.bad {
background: var(--danger);
}
.test-dur {
font-size: 10px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.test-err {
grid-column: 2 / -1;
margin: 2px 0 4px;
padding: 6px 8px;
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
font-family: ui-monospace, Menlo, monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
}
.ftr {
display: flex;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
}
.ftr-item.ok {
color: #47bf82;
}
.ftr-item.bad {
color: var(--danger);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 140ms var(--ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.hud {
position: fixed;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
width: min(520px, calc(100vw - 24px));
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 10px 12px 8px;
pointer-events: none;
font-size: 12px;
}
.hud-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.hud-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
}
.hud-tag::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: hud-pulse 900ms var(--ease) infinite;
}
@keyframes hud-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.7); }
}
.hud-counts {
display: inline-flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.hud-count {
color: var(--text-dim);
font-size: 11px;
}
.hud-chip {
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 500;
}
.hud-chip.ok {
background: rgba(71, 191, 130, 0.18);
color: #47bf82;
}
.hud-chip.bad {
background: var(--danger-soft);
color: var(--danger);
}
.hud-current {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text);
margin-bottom: 6px;
min-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hud-suite {
color: var(--text-dim);
flex-shrink: 0;
}
.hud-sep {
color: var(--text-dim);
opacity: 0.5;
}
.hud-test {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
}
.hud-bar {
height: 3px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.hud-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 120ms linear, background 160ms var(--ease);
}
/* Flip the progress bar to red as soon as the first test fails so the
user sees the bad state mid-run instead of only at the end. */
.hud-bar-fill.has-failures {
background: var(--danger);
}
.hud-pop-enter-active,
.hud-pop-leave-active {
transition: opacity 140ms var(--ease), transform 160ms var(--ease);
}
.hud-pop-enter-from,
.hud-pop-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
</style>