Skip to content

Commit 7900f9f

Browse files
authoredMay 31, 2024··
feat(ui): render tests in a tree (#5807)
1 parent a820e7a commit 7900f9f

15 files changed

+200
-94
lines changed
 

Diff for: ‎packages/ui/client/auto-imports.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ declare global {
8989
const onUnmounted: typeof import('vue')['onUnmounted']
9090
const onUpdated: typeof import('vue')['onUpdated']
9191
const openInEditor: typeof import('./composables/error')['openInEditor']
92+
const openedTreeItems: typeof import('./composables/navigation')['openedTreeItems']
9293
const params: typeof import('./composables/params')['params']
9394
const parseError: typeof import('./composables/error')['parseError']
9495
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']

Diff for: ‎packages/ui/client/components.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ declare module 'vue' {
1515
DetailsPanel: typeof import('./components/DetailsPanel.vue')['default']
1616
ErrorEntry: typeof import('./components/dashboard/ErrorEntry.vue')['default']
1717
FileDetails: typeof import('./components/FileDetails.vue')['default']
18+
IconAction: typeof import('./components/IconAction.vue')['default']
1819
IconButton: typeof import('./components/IconButton.vue')['default']
1920
Modal: typeof import('./components/Modal.vue')['default']
2021
ModuleTransformResultView: typeof import('./components/ModuleTransformResultView.vue')['default']
@@ -23,7 +24,6 @@ declare module 'vue' {
2324
RouterLink: typeof import('vue-router')['RouterLink']
2425
RouterView: typeof import('vue-router')['RouterView']
2526
StatusIcon: typeof import('./components/StatusIcon.vue')['default']
26-
Suites: typeof import('./components/Suites.vue')['default']
2727
TaskItem: typeof import('./components/TaskItem.vue')['default']
2828
TasksList: typeof import('./components/TasksList.vue')['default']
2929
TaskTree: typeof import('./components/TaskTree.vue')['default']

Diff for: ‎packages/ui/client/components/FileDetails.vue

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Params } from '~/composables/params'
55
import { viewMode } from '~/composables/params'
66
import type { ModuleGraph } from '~/composables/module-graph'
77
import { getModuleGraph } from '~/composables/module-graph'
8+
import { getProjectNameColor } from '~/utils/task';
89
910
const data = ref<ModuleGraphData>({ externalized: [], graph: {}, inlined: [] })
1011
const graph = ref<ModuleGraph>({ nodes: [], links: [] })
@@ -49,6 +50,9 @@ function onDraft(value: boolean) {
4950
<div>
5051
<div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base">
5152
<StatusIcon :task="current" />
53+
<div font-light op-50 text-sm :style="{ color: getProjectNameColor(current?.file.projectName) }">
54+
[{{ current?.file.projectName || '' }}]
55+
</div>
5256
<div flex-1 font-light op-50 ws-nowrap truncate text-sm>
5357
{{ current?.filepath }}
5458
</div>

Diff for: ‎packages/ui/client/components/IconAction.vue

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup type="ts">
2+
defineProps({
3+
icon: String,
4+
})
5+
</script>
6+
7+
<template>
8+
<div
9+
bg="gray-200"
10+
rounded-1
11+
p-0.5
12+
>
13+
<div :class="icon" op50></div>
14+
</div>
15+
</template>

Diff for: ‎packages/ui/client/components/Navigation.vue

+31-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { client, findById } from '../composables/client'
1616
import { isDark, toggleDark } from '~/composables'
1717
import { files, isReport, runAll } from '~/composables/client'
1818
import { activeFileId } from '~/composables/params'
19+
import { openedTreeItems } from '~/composables/navigation'
1920
2021
const failedSnapshot = computed(() => files.value && hasFailedSnapshot(files.value))
2122
function updateSnapshot() {
@@ -25,8 +26,8 @@ function updateSnapshot() {
2526
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
2627
2728
function onItemClick(task: Task) {
28-
activeFileId.value = task.id
29-
currentModule.value = findById(task.id)
29+
activeFileId.value = task.file.id
30+
currentModule.value = findById(task.file.id)
3031
showDashboard(false)
3132
}
3233
@@ -41,14 +42,41 @@ async function onRunAll(files?: File[]) {
4142
}
4243
await runAll(files)
4344
}
45+
46+
function collapseTests() {
47+
openedTreeItems.value = []
48+
}
49+
50+
function expandTests() {
51+
files.value.forEach(file => {
52+
if (!openedTreeItems.value.includes(file.id)) {
53+
openedTreeItems.value.push(file.id)
54+
}
55+
})
56+
}
4457
</script>
4558

4659
<template>
47-
<TasksList border="r base" :tasks="files" :on-item-click="onItemClick" :group-by-type="true" @run="onRunAll">
60+
<!-- TODO: have test tree so the folders are also nested: test -> filename -> suite -> test -->
61+
<TasksList border="r base" :tasks="files" :on-item-click="onItemClick" :group-by-type="true" @run="onRunAll" :nested="true">
4862
<template #header="{ filteredTests }">
4963
<img w-6 h-6 src="/favicon.svg" alt="Vitest logo">
5064
<span font-light text-sm flex-1>Vitest</span>
5165
<div class="flex text-lg">
66+
<IconButton
67+
v-show="openedTreeItems.length > 0"
68+
v-tooltip.bottom="'Collapse tests'"
69+
title="Collapse tests"
70+
icon="i-carbon:collapse-all"
71+
@click="collapseTests()"
72+
/>
73+
<IconButton
74+
v-show="openedTreeItems.length === 0"
75+
v-tooltip.bottom="'Expand tests'"
76+
title="Expand tests"
77+
icon="i-carbon:expand-all"
78+
@click="expandTests()"
79+
/>
5280
<IconButton
5381
v-show="(coverageConfigured && !coverageEnabled) || !dashboardVisible"
5482
v-tooltip.bottom="'Dashboard'"

Diff for: ‎packages/ui/client/components/Suites.vue

-45
This file was deleted.

Diff for: ‎packages/ui/client/components/TaskItem.vue

+57-17
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
<script setup lang="ts">
22
import type { Task } from 'vitest'
3+
import { getProjectNameColor } from '~/utils/task';
4+
import { activeFileId } from '~/composables/params';
5+
import { isReport } from '~/constants';
36
47
const props = defineProps<{
58
task: Task
9+
opened: boolean
10+
failedSnapshot: boolean
11+
}>()
12+
13+
const emit = defineEmits<{
14+
run: []
15+
preview: []
16+
fixSnapshot: [],
617
}>()
718
819
const duration = computed(() => {
920
const { result } = props.task
1021
return result && Math.round(result.duration || 0)
1122
})
12-
13-
function getProjectNameColor(name: string | undefined) {
14-
if (!name)
15-
return ''
16-
const index = name.split('').reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0)
17-
const colors = [
18-
'blue',
19-
'yellow',
20-
'cyan',
21-
'green',
22-
'magenta',
23-
]
24-
return colors[index % colors.length]
25-
}
26-
2723
</script>
2824

2925
<template>
@@ -35,13 +31,20 @@ function getProjectNameColor(name: string | undefined) {
3531
border-rounded
3632
cursor-pointer
3733
hover="bg-active"
34+
class="item-wrapper"
35+
:aria-label="task.name"
36+
:data-current="activeFileId === task.id"
3837
>
38+
<div v-if="task.type === 'suite'" pr-1>
39+
<div v-if="opened" i-carbon-chevron-down op20 />
40+
<div v-else i-carbon-chevron-right op20 />
41+
</div>
3942
<StatusIcon :task="task" mr-2 />
4043
<div v-if="task.type === 'suite' && task.meta.typecheck" i-logos:typescript-icon flex-shrink-0 mr-2 />
41-
<div flex items-end gap-2 :text="task?.result?.state === 'fail' ? 'red-500' : ''">
44+
<div flex items-end gap-2 :text="task?.result?.state === 'fail' ? 'red-500' : ''" overflow-hidden>
4245
<span text-sm truncate font-light>
4346
<!-- only show [] in files view -->
44-
<span v-if="task.filepath && task.file.projectName" :style="{ color: getProjectNameColor(task.file.projectName) }">
47+
<span v-if="'filepath' in task && task.projectName" :style="{ color: getProjectNameColor(task.file.projectName) }">
4548
[{{ task.file.projectName }}]
4649
</span>
4750
{{ task.name }}
@@ -50,5 +53,42 @@ function getProjectNameColor(name: string | undefined) {
5053
{{ duration > 0 ? duration : '< 1' }}ms
5154
</span>
5255
</div>
56+
<div v-if="task.type === 'suite' && 'filepath' in task" gap-1 justify-end flex-grow-1 pl-1 class="test-actions">
57+
<IconAction
58+
v-if="!isReport && failedSnapshot"
59+
v-tooltip.bottom="'Fix failed snapshot(s)'"
60+
data-testid="btn-fix-snapshot"
61+
title="Fix failed snapshot(s)"
62+
icon="i-carbon-result-old"
63+
@click.prevent.stop="emit('fixSnapshot')"
64+
/>
65+
<IconAction
66+
v-tooltip.bottom="'Open test details'"
67+
data-testid="btn-open-details"
68+
title="Open test details"
69+
icon="i-carbon-intrusion-prevention"
70+
@click.prevent.stop="emit('preview')"
71+
/>
72+
<IconAction
73+
v-if="!isReport"
74+
v-tooltip.bottom="'Run current test'"
75+
data-testid="btn-run-test"
76+
title="Run current test"
77+
icon="i-carbon-play-filled-alt"
78+
text="green-500"
79+
@click.prevent.stop="emit('run')"
80+
/>
81+
</div>
5382
</div>
5483
</template>
84+
85+
<style scoped>
86+
.test-actions {
87+
display: none;
88+
}
89+
90+
.item-wrapper:hover .test-actions,
91+
.item-wrapper[data-current="true"] .test-actions {
92+
display: flex;
93+
}
94+
</style>

Diff for: ‎packages/ui/client/components/TaskTree.vue

+41-5
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,68 @@
11
<script setup lang="ts">
22
import type { Task } from 'vitest'
3+
import { nextTick } from 'vue'
4+
import { runFiles, client } from '~/composables/client';
35
import { caseInsensitiveMatch } from '~/utils/task'
6+
import { openedTreeItems, coverageEnabled } from '~/composables/navigation';
47
58
defineOptions({ inheritAttrs: false })
69
7-
const { task, indent = 0, nested = false, search, onItemClick } = defineProps<{
10+
// TODO: better handling of "opened" - it means to forcefully open the tree item and set in TasksList right now
11+
const { task, indent = 0, nested = false, search, onItemClick, opened = false } = defineProps<{
812
task: Task
13+
failedSnapshot: boolean
914
indent?: number
15+
opened?: boolean
1016
nested?: boolean
1117
search?: string
1218
onItemClick?: (task: Task) => void
1319
}>()
20+
21+
const isOpened = computed(() => opened || openedTreeItems.value.includes(task.id))
22+
23+
function toggleOpen() {
24+
if (isOpened.value) {
25+
const tasksIds = 'tasks' in task ? task.tasks.map(t => t.id) : []
26+
openedTreeItems.value = openedTreeItems.value.filter(id => id !== task.id && !tasksIds.includes(id))
27+
} else {
28+
openedTreeItems.value = [...openedTreeItems.value, task.id]
29+
}
30+
}
31+
32+
async function onRun() {
33+
onItemClick?.(task)
34+
if (coverageEnabled.value) {
35+
disableCoverage.value = true
36+
await nextTick()
37+
}
38+
await runFiles([task.file])
39+
}
40+
41+
function updateSnapshot() {
42+
return client.rpc.updateSnapshot(task)
43+
}
1444
</script>
1545

1646
<template>
1747
<!-- maybe provide a KEEP STRUCTURE mode, do not filter by search keyword -->
1848
<!-- v-if = keepStructure || (!search || caseInsensitiveMatch(task.name, search)) -->
1949
<TaskItem
20-
v-if="!nested || !search || caseInsensitiveMatch(task.name, search)"
50+
v-if="opened || !nested || !search || caseInsensitiveMatch(task.name, search)"
2151
v-bind="$attrs"
2252
:task="task"
23-
:style="{ paddingLeft: `${indent * 0.75 + 1}rem` }"
24-
@click="onItemClick && onItemClick(task)"
53+
:style="{ paddingLeft: indent ? `${indent * 0.75 + (task.type === 'suite' ? 0.50 : 1.75)}rem` : '1rem' }"
54+
:opened="isOpened && task.type === 'suite' && task.tasks.length"
55+
:failed-snapshot="failedSnapshot"
56+
@click="toggleOpen()"
57+
@run="onRun()"
58+
@fix-snapshot="updateSnapshot()"
59+
@preview="onItemClick?.(task)"
2560
/>
26-
<div v-if="nested && task.type === 'suite' && task.tasks.length">
61+
<div v-if="nested && task.type === 'suite' && task.tasks.length" v-show="isOpened">
2762
<TaskTree
2863
v-for="suite in task.tasks"
2964
:key="suite.id"
65+
:failed-snapshot="false"
3066
:task="suite"
3167
:nested="nested"
3268
:indent="indent + 1"

Diff for: ‎packages/ui/client/components/TasksList.vue

+11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { File, Task } from 'vitest'
44
import { findById, testRunState } from '~/composables/client'
55
import { activeFileId } from '~/composables/params'
66
import { caseInsensitiveMatch, isSuite } from '~/utils/task'
7+
import { hasFailedSnapshot } from '@vitest/ws-client'
78
89
defineOptions({ inheritAttrs: false })
910
@@ -125,6 +126,8 @@ function matchTasks(tasks: Task[], search: string): boolean {
125126
:task="task"
126127
:nested="nested"
127128
:search="search"
129+
:opened="isFiltered"
130+
:failed-snapshot="hasFailedSnapshot(task)"
128131
:class="activeFileId === task.id ? 'bg-active' : ''"
129132
:on-item-click="onItemClick"
130133
/>
@@ -141,6 +144,8 @@ function matchTasks(tasks: Task[], search: string): boolean {
141144
:task="task"
142145
:nested="nested"
143146
:search="search"
147+
:opened="isFiltered"
148+
:failed-snapshot="hasFailedSnapshot(task)"
144149
:class="activeFileId === task.id ? 'bg-active' : ''"
145150
:on-item-click="onItemClick"
146151
/>
@@ -156,6 +161,8 @@ function matchTasks(tasks: Task[], search: string): boolean {
156161
:key="task.id"
157162
:task="task"
158163
:nested="nested"
164+
:opened="isFiltered"
165+
:failed-snapshot="hasFailedSnapshot(task)"
159166
:search="search"
160167
:class="activeFileId === task.id ? 'bg-active' : ''"
161168
:on-item-click="onItemClick"
@@ -171,7 +178,9 @@ function matchTasks(tasks: Task[], search: string): boolean {
171178
v-for="task in skipped"
172179
:key="task.id"
173180
:task="task"
181+
:opened="isFiltered"
174182
:nested="nested"
183+
:failed-snapshot="hasFailedSnapshot(task)"
175184
:search="search"
176185
:class="activeFileId === task.id ? 'bg-active' : ''"
177186
:on-item-click="onItemClick"
@@ -186,6 +195,8 @@ function matchTasks(tasks: Task[], search: string): boolean {
186195
:key="task.id"
187196
:task="task"
188197
:nested="nested"
198+
:opened="isFiltered"
199+
:failed-snapshot="hasFailedSnapshot(task)"
189200
:search="search"
190201
:class="activeFileId === task.id ? 'bg-active' : ''"
191202
:on-item-click="onItemClick"

Diff for: ‎packages/ui/client/composables/navigation.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const coverageEnabled = computed(() => {
1212
return coverageConfigured.value
1313
&& coverage.value.reporter.map(([reporterName]) => reporterName).includes('html')
1414
})
15+
export const openedTreeItems = useLocalStorage<string[]>('vitest-ui_task-tree-opened', [])
1516
// TODO
1617
// For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
1718
// Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.

Diff for: ‎packages/ui/client/pages/index.vue

+1-8
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,7 @@ function resizeMain() {
4444
<transition>
4545
<Dashboard v-if="dashboardVisible" key="summary" />
4646
<Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" />
47-
<Splitpanes v-else key="detail" @resized="onModuleResized">
48-
<Pane :size="detailSizes[0]">
49-
<Suites />
50-
</Pane>
51-
<Pane :size="detailSizes[1]">
52-
<FileDetails />
53-
</Pane>
54-
</Splitpanes>
47+
<FileDetails v-else />
5548
</transition>
5649
</Pane>
5750
</Splitpanes>

Diff for: ‎packages/ui/client/utils/task.ts

+14
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,17 @@ export function caseInsensitiveMatch(target: string, str2: string) {
99
return false
1010
return target.toLowerCase().includes(str2.toLowerCase())
1111
}
12+
13+
export function getProjectNameColor(name: string | undefined) {
14+
if (!name)
15+
return ''
16+
const index = name.split('').reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0)
17+
const colors = [
18+
'blue',
19+
'yellow',
20+
'cyan',
21+
'green',
22+
'magenta',
23+
]
24+
return colors[index % colors.length]
25+
}

Diff for: ‎packages/ui/vite.config.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { presetAttributify, presetIcons, presetUno } from 'unocss'
1212
// for debug:
1313
// open a static file serve to share the report json
1414
// and ui using the link to load the report json data
15-
const debugLink = 'http://127.0.0.1:4173/__vitest__'
15+
// const debugLink = 'http://127.0.0.1:4173/__vitest__'
1616

1717
export const config: UserConfig = {
1818
root: __dirname,
@@ -71,13 +71,13 @@ export const config: UserConfig = {
7171
],
7272
injectAtEnd: true,
7373
}),
74-
{
75-
name: 'debug-html-report',
76-
apply: 'serve',
77-
transformIndexHtml(html) {
78-
return html.replace('<!-- !LOAD_METADATA! -->', `<script>window.METADATA_PATH="${debugLink}/html.meta.json.gz"</script>`)
79-
},
80-
},
74+
// {
75+
// name: 'debug-html-report',
76+
// apply: 'serve',
77+
// transformIndexHtml(html) {
78+
// return html.replace('<!-- !LOAD_METADATA! -->', `<script>window.METADATA_PATH="${debugLink}/html.meta.json.gz"</script>`)
79+
// },
80+
// },
8181
],
8282
build: {
8383
outDir: './dist/client',

Diff for: ‎test/ui/test/html-report.spec.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ test.describe('html report', () => {
4444
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1')
4545

4646
// report
47-
await page.getByTestId('details-panel').getByText('sample.test.ts').click()
47+
const sample = page.getByTestId('details-panel').getByLabel('sample.test.ts')
48+
await sample.hover()
49+
await sample.getByTestId('btn-open-details').click()
4850
await page.getByText('All tests passed in this file').click()
49-
await expect(page.getByTestId('filenames')).toContainText('sample.test.ts')
5051

5152
// graph tab
5253
await page.getByTestId('btn-graph').click()
@@ -67,7 +68,9 @@ test.describe('html report', () => {
6768

6869
test('error', async ({ page }) => {
6970
await page.goto(pageUrl)
70-
await page.getByText('fixtures/error.test.ts').click()
71+
const sample = page.getByTestId('details-panel').getByLabel('fixtures/error.test.ts')
72+
await sample.hover()
73+
await sample.getByTestId('btn-open-details').click()
7174
await expect(page.getByTestId('diff')).toContainText('- Expected + Received + <style>* {border: 2px solid green};</style>')
7275
})
7376
})

Diff for: ‎test/ui/test/ui.spec.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ test.describe('ui', () => {
4949
await expect(page.getByTestId('unhandled-errors-details')).toContainText('Unknown Error: 1')
5050

5151
// report
52-
await page.getByTestId('details-panel').getByText('sample.test.ts').click()
52+
const sample = page.getByTestId('details-panel').getByLabel('sample.test.ts')
53+
await sample.hover()
54+
await sample.getByTestId('btn-open-details').click()
5355
await page.getByText('All tests passed in this file').click()
54-
await expect(page.getByTestId('filenames')).toContainText('sample.test.ts')
5556

5657
// graph tab
5758
await page.getByTestId('btn-graph').click()
@@ -72,7 +73,9 @@ test.describe('ui', () => {
7273

7374
test('console', async ({ page }) => {
7475
await page.goto(pageUrl)
75-
await page.getByText('fixtures/console.test.ts').click()
76+
const item = page.getByLabel('fixtures/console.test.ts')
77+
await item.hover()
78+
await item.getByTestId('btn-open-details').click()
7679
await page.getByTestId('btn-console').click()
7780
await page.getByText('/(?<char>\\w)/').click()
7881

@@ -82,7 +85,9 @@ test.describe('ui', () => {
8285

8386
test('error', async ({ page }) => {
8487
await page.goto(pageUrl)
85-
await page.getByText('fixtures/error.test.ts').click()
88+
const item = page.getByLabel('fixtures/error.test.ts')
89+
await item.hover()
90+
await item.getByTestId('btn-open-details').click()
8691
await expect(page.getByTestId('diff')).toContainText('- Expected + Received + <style>* {border: 2px solid green};</style>')
8792
})
8893

0 commit comments

Comments
 (0)
Please sign in to comment.