feat(ui): add summary (#493)

Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
This commit is contained in:
Joaquín Sánchez 2022-01-18 06:53:27 +01:00 committed by GitHub
parent 1b1807b7da
commit bdedbc2c19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 473 additions and 67 deletions

View File

@ -6,17 +6,23 @@ declare module 'vue' {
export interface GlobalComponents {
CodeMirror: typeof import('./components/CodeMirror.vue')['default']
ConnectionOverlay: typeof import('./components/ConnectionOverlay.vue')['default']
Dashboard: typeof import('./components/Dashboard.vue')['default']
DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default']
DetailsPanel: typeof import('./components/DetailsPanel.vue')['default']
FileDetails: typeof import('./components/FileDetails.vue')['default']
IconButton: typeof import('./components/IconButton.vue')['default']
Modal: typeof import('./components/Modal.vue')['default']
ModuleTransformResultView: typeof import('./components/ModuleTransformResultView.vue')['default']
Navigation: typeof import('./components/Navigation.vue')['default']
ProgressBar: typeof import('./components/ProgressBar.vue')['default']
StatusIcon: typeof import('./components/StatusIcon.vue')['default']
Suites: typeof import('./components/Suites.vue')['default']
TaskItem: typeof import('./components/TaskItem.vue')['default']
TasksList: typeof import('./components/TasksList.vue')['default']
TaskTree: typeof import('./components/TaskTree.vue')['default']
TestFilesEntry: typeof import('./components/dashboard/TestFilesEntry.vue')['default']
TestsEntry: typeof import('./components/dashboard/TestsEntry.vue')['default']
TestsFilesContainer: typeof import('./components/dashboard/TestsFilesContainer.vue')['default']
ViewConsoleOutput: typeof import('./components/views/ViewConsoleOutput.vue')['default']
ViewEditor: typeof import('./components/views/ViewEditor.vue')['default']
ViewModuleGraph: typeof import('./components/views/ViewModuleGraph.vue')['default']

View File

@ -0,0 +1,25 @@
<template>
<div h="full" flex="~ col">
<div
p="2"
h-10
flex="~ gap-2"
items-center
bg-header
border="b base"
>
<div class="i-carbon-dashboard" />
<span
font-light
text-sm
flex-auto
ws-nowrap
overflow-hidden
truncate
>Dashboard</span>
</div>
<div class="scrolls" flex-auto py-1>
<TestsFilesContainer />
</div>
</div>
</template>

View File

@ -6,21 +6,6 @@ import type { ModuleGraph } from '~/composables/module-graph'
import { getModuleGraph } from '~/composables/module-graph'
import type { ModuleGraphData } from '#types'
const sizes = reactive([95, 5])
onMounted(() => {
const bottomPanelPercent = 42 / window.innerHeight * 100
sizes[0] = 100 - bottomPanelPercent
sizes[1] = bottomPanelPercent
})
function open() {
const filePath = current.value?.filepath
if (filePath)
fetch(`/__open-in-editor?file=${encodeURIComponent(filePath)}`)
}
const data = ref<ModuleGraphData>({ externalized: [], graph: {}, inlined: [] })
const graph = ref<ModuleGraph>({ nodes: [], links: [] })
@ -35,6 +20,12 @@ debouncedWatch(
{ debounce: 100 },
)
const open = () => {
const filePath = current.value?.filepath
if (filePath)
fetch(`/__open-in-editor?file=${encodeURIComponent(filePath)}`)
}
const changeViewMode = (view: Params['view']) => {
viewMode.value = view
}
@ -68,9 +59,11 @@ const changeViewMode = (view: Params['view']) => {
</div>
</div>
<ViewModuleGraph v-show="viewMode === 'graph'" :graph="graph" />
<ViewEditor v-if="viewMode === 'editor'" :file="current" />
<ViewConsoleOutput v-else-if="viewMode === 'console'" :file="current" />
<ViewReport v-else-if="!viewMode" :file="current" />
<div flex flex-col flex-1 overflow="hidden">
<ViewModuleGraph v-show="viewMode === 'graph'" :graph="graph" />
<ViewEditor v-if="viewMode === 'editor'" :file="current" />
<ViewConsoleOutput v-else-if="viewMode === 'console'" :file="current" />
<ViewReport v-else-if="!viewMode" :file="current" />
</div>
</div>
</template>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
defineProps<{ icon?: string }>()
defineProps<{ icon?: string; title?: string }>()
</script>
<template>
<button op70 rounded hover="bg-active op100" class="w-1.4em h-1.4em flex">
<button :aria-label="title" role="button" op70 rounded hover="bg-active op100" class="w-1.4em h-1.4em flex">
<slot>
<div :class="icon" ma />
</slot>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { currentModule, dashboardVisible, showDashboard } from '../composables/navigation'
import { findById } from '../composables/client'
import type { Task } from '#types'
import { isDark, toggleDark } from '~/composables'
import { files, runAll } from '~/composables/client'
@ -6,6 +8,8 @@ import { activeFileId } from '~/composables/params'
function onItemClick(task: Task) {
activeFileId.value = task.id
currentModule.value = findById(task.id)
showDashboard(false)
}
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
</script>
@ -16,6 +20,15 @@ const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
<img w-6 h-6 mx-2 src="/favicon.svg">
<span font-light text-sm flex-1>Vitest</span>
<div class="flex text-lg">
<IconButton
v-show="!dashboardVisible"
v-tooltip.bottom="'Dashboard'"
title="Show dashboard"
class="!animate-100ms"
animate-count-1
icon="i-carbon-dashboard"
@click="showDashboard(true)"
/>
<IconButton v-tooltip.bottom="'Rerun all'" icon="i-carbon-play" @click="runAll" />
<IconButton
v-tooltip.bottom="`Toggle to ${toggleMode} mode`"

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { files } from '../composables/client'
import { filesFailed, filesSuccess, finished } from '../composables/summary'
const { width } = useWindowSize()
const classes = computed(() => {
// if there is no files, then in progress and gray
if (files.value.length === 0)
return '!bg-gray-4 !dark:bg-gray-7 in-progress'
else if (!finished.value)
return 'in-progress'
return null
})
const total = computed(() => files.value.length)
const pass = computed(() => filesSuccess.value.length)
const failed = computed(() => filesFailed.value.length)
const widthPass = computed(() => {
const t = unref(total)
return t > 0 ? (width.value * pass.value / t) : 0
})
const widthFailed = computed(() => {
const t = unref(total)
return t > 0 ? (width.value * failed.value / t) : 0
})
const pending = computed(() => {
const t = unref(total)
return t - failed.value - pass.value
})
const widthPending = computed(() => {
const t = unref(total)
return t > 0 ? (width.value * pending.value / t) : 0
})
</script>
<template>
<div
absolute
t-0
l-0
r-0
z-index-1031
pointer-events-none
p-0
h-3px
grid="~ auto-cols-max"
justify-items-center
w-screen
:class="classes"
>
<div h-3px relative overflow-hidden class="px-0" w-screen>
<div
absolute
l-0
t-0
bg-red5
h-3px
:class="classes"
:style="`width: ${widthFailed}px;`"
>
&#160;
</div>
<div
absolute
l-0
t-0
bg-green5
h-3px
:class="classes"
:style="`left: ${widthFailed}px; width: ${widthPass}px;`"
>
&#160;
</div>
<div
absolute
l-0
t-0
bg-yellow5
h-3px
:class="classes"
:style="`left: ${widthPass + widthFailed}px; width: ${widthPending}px;`"
>
&#160;
</div>
</div>
</div>
</template>
<style scoped>
.in-progress {
background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
background-size: 40px 40px;
animation: in-progress-stripes 2s linear infinite;
}
@keyframes in-progress-stripes {
from {
background-position: 40px 0;
}
to {
background-position: 0 0;
}
}
</style>

View File

@ -9,7 +9,7 @@ const updateSnapshot = () => current.value && client.rpc.updateSnapshot(current.
</script>
<template>
<div v-if="current" border="r base">
<div v-if="current" h-full border="r base">
<TasksList :tasks="current.tasks" :nested="true">
<template #header>
<StatusIcon :task="current" />
@ -19,9 +19,13 @@ const updateSnapshot = () => current.value && client.rpc.updateSnapshot(current.
v-if="failedSnapshot"
v-tooltip.bottom="'Update failed snapshots'"
icon="i-carbon-result-old"
@click="updateSnapshot"
@click="updateSnapshot()"
/>
<IconButton
v-tooltip.bottom="'Rerun file'"
icon="i-carbon-play"
@click="runCurrent()"
/>
<IconButton v-tooltip.bottom="'Rerun file'" icon="i-carbon-play" @click="runCurrent" />
</div>
</template>
</TasksList>

View File

@ -65,6 +65,7 @@ export default {
bg="transparent"
font="light"
text="sm"
flex-1
:op="search.length ? '100' : '50'"
>
</div>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(defineProps<{ tail?: boolean }>(), { tail: false })
</script>
<template>
<div p-2 text-center flex>
<div>
<div text-4xl>
<slot name="body" />
</div>
<div text-md>
<slot name="header" />
</div>
</div>
<div v-if="!tail" my-2 op50 w-1px bg-current origin-center rotate-15 translate-x-3 />
</div>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { files } from '../../composables/client'
import { filesFailed, filesSnapshotFailed, filesSuccess, time } from '../../composables/summary'
</script>
<template>
<div
grid="~ cols-[min-content_1fr_min-content]"
items-center gap="x-2 y-3" p="x4" relative font-light w-80
op80
>
<div i-carbon-document />
<div>Files</div>
<div class="number">
{{ files.length }}
</div>
<template v-if="filesSuccess.length">
<div i-carbon-checkmark />
<div>Pass</div>
<div class="number">
{{ filesSuccess.length }}
</div>
</template>
<template v-if="filesFailed.length">
<div i-carbon-close />
<div>
Fail
</div>
<div class="number" text-red5>
{{ filesFailed.length }}
</div>
</template>
<template v-if="filesSnapshotFailed.length">
<div i-carbon-compare />
<div>
Snapshot Fail
</div>
<div class="number" text-red5>
{{ filesSnapshotFailed.length }}
</div>
</template>
<div i-carbon-timer />
<div>Time</div>
<div class="number">
{{ time }}
</div>
</div>
</template>
<style scoped>
.number {
font-weight: 400;
text-align: right;
}
</style>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { tests, testsFailed, testsSkipped, testsSuccess, testsTodo } from '../../composables/summary'
const total = computed(() => tests.value.length)
const pass = computed(() => testsSuccess.value.length)
const failed = computed(() => testsFailed.value.length)
const skipped = computed(() => testsSkipped.value.length)
const todo = computed(() => testsTodo.value.length)
const pending = computed(() => {
const t = unref(total)
return t - failed.value - pass.value
})
</script>
<template>
<div flex="~ wrap" justify-evenly gap-2 p="x-4" relative>
<DashboardEntry text-green5>
<template #header>
Pass
</template>
<template #body>
{{ pass }}
</template>
</DashboardEntry>
<DashboardEntry :class="{ 'text-red5': failed, 'op50': !failed }">
<template #header>
Fail
</template>
<template #body>
{{ failed }}
</template>
</DashboardEntry>
<DashboardEntry v-if="skipped" op50>
<template #header>
Skip
</template>
<template #body>
{{ skipped }}
</template>
</DashboardEntry>
<DashboardEntry v-if="todo" op50>
<template #header>
Todo
</template>
<template #body>
{{ todo }}
</template>
</DashboardEntry>
<DashboardEntry :tail="true">
<template #header>
Total
</template>
<template #body>
{{ total }}
</template>
</DashboardEntry>
</div>
</template>

View File

@ -0,0 +1,10 @@
<template>
<div gap-0 flex="~ col gap-4" h-full justify-center items-center>
<div bg-header rounded-lg p="y4 x2">
<!-- <section aria-labelledby="tests" m="y-4 x-2">
<TestsEntry />
</section> -->
<TestFilesEntry />
</div>
</div>
</template>

View File

@ -18,6 +18,10 @@ export const files = computed(() => client.state.getFiles())
export const current = computed(() => files.value.find(file => file.id === activeFileId.value))
export const currentLogs = computed(() => getTasks(current.value).map(i => i?.logs || []).flat() || [])
export const findById = (id: string) => {
return files.value.find(file => file.id === id)
}
export const isConnected = computed(() => status.value === 'OPEN')
export const isConnecting = computed(() => status.value === 'CONNECTING')
export const isDisconnected = computed(() => status.value === 'CLOSED')
@ -62,20 +66,20 @@ watch(
)
// display the first file on init
if (!activeFileId.value) {
const stop = watch(
() => client.state.getFiles(),
(files) => {
if (activeFileId.value) {
stop()
return
}
if (files.length && files[0].id) {
activeFileId.value = files[0].id
stop()
}
},
{ immediate: true },
)
}
// if (!activeFileId.value) {
// const stop = watch(
// () => client.state.getFiles(),
// (files) => {
// if (activeFileId.value) {
// stop()
// return
// }
//
// if (files.length && files[0].id) {
// activeFileId.value = files[0].id
// stop()
// }
// },
// { immediate: true },
// )
// }

View File

@ -0,0 +1,36 @@
import { client, findById } from './client'
import { activeFileId } from './params'
import type { File } from '#types'
export const currentModule = ref<File | undefined>(undefined)
export const dashboardVisible = ref(true)
export function initializeNavigation() {
const file = activeFileId.value
if (file && file.length > 0) {
const current = findById(file)
if (current) {
currentModule.value = current
dashboardVisible.value = false
}
else {
watchOnce(
() => client.state.getFiles(),
() => {
currentModule.value = findById(file)
dashboardVisible.value = false
},
)
}
}
return dashboardVisible
}
export function showDashboard(show: boolean) {
dashboardVisible.value = show
if (show) {
currentModule.value = undefined
activeFileId.value = ''
}
}

View File

@ -0,0 +1,57 @@
import { hasFailedSnapshot } from '@vitest/ws-client'
import type { Task, Test } from 'vitest/src'
import { files } from '~/composables/client'
type Nullable<T> = T | null | undefined
type Arrayable<T> = T | Array<T>
// files
export const filesFailed = computed(() => files.value.filter(f => f.result?.state === 'fail'))
export const filesSuccess = computed(() => files.value.filter(f => f.result?.state === 'pass'))
export const filesIgnore = computed(() => files.value.filter(f => f.mode === 'skip' || f.mode === 'todo'))
export const filesRunning = computed(() => files.value.filter(f =>
!filesFailed.value.includes(f)
&& !filesSuccess.value.includes(f)
&& !filesIgnore.value.includes(f),
))
export const filesSkipped = computed(() => filesIgnore.value.filter(f => f.mode === 'skip'))
export const filesSnapshotFailed = computed(() => files.value.filter(hasFailedSnapshot))
export const filesTodo = computed(() => filesIgnore.value.filter(f => f.mode === 'todo'))
export const finished = computed(() => filesRunning.value.length === 0)
// tests
export const tests = computed(() => {
return getTests(files.value)
})
export const testsFailed = computed(() => {
return tests.value.filter(f => f.result?.state === 'fail')
})
export const testsSuccess = computed(() => {
return tests.value.filter(f => f.result?.state === 'pass')
})
export const testsIgnore = computed(() => tests.value.filter(f => f.mode === 'skip' || f.mode === 'todo'))
export const testsSkipped = computed(() => testsIgnore.value.filter(f => f.mode === 'skip'))
export const testsTodo = computed(() => testsIgnore.value.filter(f => f.mode === 'todo'))
export const totalTests = computed(() => testsFailed.value.length + testsSuccess.value.length)
export const time = computed(() => {
const t = getTests(tests.value).reduce((acc, t) => {
if (t.result?.duration)
acc += t.result.duration
return acc
}, 0)
if (t > 1000)
return `${(t / 1000).toFixed(2)}s`
return `${Math.round(t)}ms`
})
function toArray<T>(array?: Nullable<Arrayable<T>>): Array<T> {
array = array || []
if (Array.isArray(array))
return array
return [array]
}
function getTests(suite: Arrayable<Task>): Test[] {
return toArray(suite).flatMap(s => s.type === 'test' ? [s] : s.tasks.flatMap(c => c.type === 'test' ? [c] : getTests(c)))
}

View File

@ -1,36 +1,53 @@
<script setup lang="ts">
// @ts-expect-error missing types
import { Pane, Splitpanes } from 'splitpanes'
import { initializeNavigation } from '../composables/navigation'
const sizes = reactive([33, 33, 34])
const dashboardVisible = initializeNavigation()
const mainSizes = reactive([33, 67])
const detailSizes = reactive([33, 67])
function onResize(event: { size: number }[]) {
const onMainResized = useDebounceFn((event: { size: number }[]) => {
event.forEach((e, i) => {
sizes[i] = e.size
mainSizes[i] = e.size
})
}
}, 0)
const onModuleResized = useDebounceFn((event: { size: number }[]) => {
event.forEach((e, i) => {
detailSizes[i] = e.size
})
}, 0)
onMounted(() => {
const resizeMain = () => {
const width = window.innerWidth
const panelWidth = Math.min(width / 3, 300)
const panelPercent = panelWidth / width * 100
sizes[0] = panelPercent
sizes[1] = panelPercent
sizes[2] = 100 - panelPercent * 2
})
mainSizes[0] = (100 * panelWidth) / width
mainSizes[1] = 100 - mainSizes[0]
// initialize suite width with the same navigation panel width in pixels (adjust its % inside detail's split pane)
detailSizes[0] = (100 * panelWidth) / (width - panelWidth)
detailSizes[1] = 100 - detailSizes[0]
}
</script>
<template>
<ProgressBar />
<div h-screen w-screen overflow="hidden">
<Splitpanes @resize="onResize">
<Pane :size="sizes[0]">
<Splitpanes class="pt-4px" @resized="onMainResized" @ready="resizeMain">
<Pane :size="mainSizes[0]">
<Navigation />
</Pane>
<Pane :size="sizes[1]">
<Suites />
</Pane>
<Pane :size="sizes[2]">
<FileDetails />
<Pane :size="mainSizes[1]">
<transition>
<Dashboard v-if="dashboardVisible" key="summary" />
<Splitpanes v-else key="detail" @resized="onModuleResized">
<Pane :size="detailSizes[0]">
<Suites />
</Pane>
<Pane :size="detailSizes[1]">
<FileDetails />
</Pane>
</Splitpanes>
</transition>
</Pane>
</Splitpanes>
</div>

View File

@ -110,7 +110,8 @@ html.dark {
}
.splitpanes--vertical > .splitpanes__splitter:before {
left: -10px;
/* make vertical scroll usable */
/*left: -10px;*/
right: -10px;
height: 100%;
}
@ -163,3 +164,4 @@ html.dark {
padding-bottom: unset !important;
}

View File

@ -26,7 +26,7 @@ export default defineConfig({
],
shortcuts: {
'bg-base': 'bg-white dark:bg-[#111]',
'bg-overlay': 'bg-white:2 dark:bg-[#222]:2',
'bg-overlay': 'bg-[#eee]:2 dark:bg-[#222]:2',
'bg-header': 'bg-gray-500:5',
'bg-active': 'bg-gray-500:8',
'bg-hover': 'bg-gray-500:20',

View File

@ -15,13 +15,13 @@ test('outside snapshot', () => {
test('inline snapshot', () => {
expect('inline string').toMatchInlineSnapshot('"inline string"')
expect({ foo: { type: 'object', map: new Map() } }).toMatchInlineSnapshot(`
{
"foo": {
"map": Map {},
"type": "object",
},
}
`)
{
"foo": {
"map": Map {},
"type": "object",
},
}
`)
const indent = `
()=>
array