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

687 lines
16 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 } 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))
try {
results.value = await runAll(registerAllTests, {
onProgress: (d, t) => {
done.value = d
total.value = t
},
onTestStart: (suiteName, testName) => {
currentSuite.value = suiteName
currentTest.value = testName
},
interTestDelayMs: 80,
})
} finally {
store.$state = snapshot
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" :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"
:title="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" :title="t('tests.runAgain')" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" :title="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;
}
.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>