Skip to content

Commit 45dfc95

Browse files
authoredJun 20, 2024··
feat(ui): replace navigation tree with test explorer (#5907)
1 parent 486fd11 commit 45dfc95

34 files changed

+2221
-733
lines changed
 

‎packages/ui/client/auto-imports.d.ts

-16
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,6 @@ declare global {
4646
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
4747
const effectScope: typeof import('vue')['effectScope']
4848
const extendRef: typeof import('@vueuse/core')['extendRef']
49-
const filesFailed: typeof import('./composables/summary')['filesFailed']
50-
const filesIgnore: typeof import('./composables/summary')['filesIgnore']
51-
const filesRunning: typeof import('./composables/summary')['filesRunning']
52-
const filesSkipped: typeof import('./composables/summary')['filesSkipped']
53-
const filesSnapshotFailed: typeof import('./composables/summary')['filesSnapshotFailed']
54-
const filesSuccess: typeof import('./composables/summary')['filesSuccess']
55-
const filesTodo: typeof import('./composables/summary')['filesTodo']
56-
const finished: typeof import('./composables/summary')['finished']
5749
const getCurrentBrowserIframe: typeof import('./composables/api')['getCurrentBrowserIframe']
5850
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
5951
const getCurrentScope: typeof import('vue')['getCurrentScope']
@@ -128,22 +120,14 @@ declare global {
128120
const syncRef: typeof import('@vueuse/core')['syncRef']
129121
const syncRefs: typeof import('@vueuse/core')['syncRefs']
130122
const templateRef: typeof import('@vueuse/core')['templateRef']
131-
const tests: typeof import('./composables/summary')['tests']
132-
const testsFailed: typeof import('./composables/summary')['testsFailed']
133-
const testsIgnore: typeof import('./composables/summary')['testsIgnore']
134-
const testsSkipped: typeof import('./composables/summary')['testsSkipped']
135-
const testsSuccess: typeof import('./composables/summary')['testsSuccess']
136-
const testsTodo: typeof import('./composables/summary')['testsTodo']
137123
const throttledRef: typeof import('@vueuse/core')['throttledRef']
138124
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
139-
const time: typeof import('./composables/summary')['time']
140125
const toRaw: typeof import('vue')['toRaw']
141126
const toReactive: typeof import('@vueuse/core')['toReactive']
142127
const toRef: typeof import('vue')['toRef']
143128
const toRefs: typeof import('vue')['toRefs']
144129
const toValue: typeof import('vue')['toValue']
145130
const toggleDark: typeof import('./composables/dark')['toggleDark']
146-
const totalTests: typeof import('./composables/summary')['totalTests']
147131
const triggerRef: typeof import('vue')['triggerRef']
148132
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
149133
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']

‎packages/ui/client/components.d.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ declare module 'vue' {
1515
DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default']
1616
DetailsPanel: typeof import('./components/DetailsPanel.vue')['default']
1717
ErrorEntry: typeof import('./components/dashboard/ErrorEntry.vue')['default']
18+
Explorer: typeof import('./components/explorer/Explorer.vue')['default']
19+
ExplorerItem: typeof import('./components/explorer/ExplorerItem.vue')['default']
1820
FileDetails: typeof import('./components/FileDetails.vue')['default']
21+
FilterStatus: typeof import('./components/FilterStatus.vue')['default']
1922
IconAction: typeof import('./components/IconAction.vue')['default']
2023
IconButton: typeof import('./components/IconButton.vue')['default']
2124
Modal: typeof import('./components/Modal.vue')['default']
@@ -25,9 +28,6 @@ declare module 'vue' {
2528
RouterLink: typeof import('vue-router')['RouterLink']
2629
RouterView: typeof import('vue-router')['RouterView']
2730
StatusIcon: typeof import('./components/StatusIcon.vue')['default']
28-
TaskItem: typeof import('./components/TaskItem.vue')['default']
29-
TasksList: typeof import('./components/TasksList.vue')['default']
30-
TaskTree: typeof import('./components/TaskTree.vue')['default']
3131
TestFilesEntry: typeof import('./components/dashboard/TestFilesEntry.vue')['default']
3232
TestsEntry: typeof import('./components/dashboard/TestsEntry.vue')['default']
3333
TestsFilesContainer: typeof import('./components/dashboard/TestsFilesContainer.vue')['default']
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script setup lang="ts">
2+
defineProps<{ label: string }>()
3+
const modelValue = defineModel<boolean | null>()
4+
</script>
5+
6+
<template>
7+
<label
8+
class="font-light text-sm checkbox flex items-center cursor-pointer py-1 text-sm w-full gap-y-1 mb-1px"
9+
v-bind="$attrs"
10+
@click.prevent="modelValue = !modelValue"
11+
>
12+
<span
13+
:class="[
14+
modelValue ? 'i-carbon:checkbox-checked-filled' : 'i-carbon:checkbox',
15+
]"
16+
text-lg
17+
aria-hidden="true"
18+
/>
19+
<input
20+
v-model="modelValue"
21+
type="checkbox"
22+
sr-only
23+
>
24+
<span flex-1 ms-2 select-none>{{ label }}</span>
25+
</label>
26+
</template>
27+
28+
<style>
29+
.checkbox:focus-within {
30+
outline: none;
31+
@apply focus-base border-b-1 !mb-none;
32+
}
33+
</style>

‎packages/ui/client/components/IconButton.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ defineProps<{
1818
class="w-1.4em h-1.4em flex" :class="[{ 'bg-gray-500:35 op100': active }]"
1919
>
2020
<slot>
21-
<div :class="icon" ma />
21+
<span :class="icon" ma block />
2222
</slot>
2323
</button>
2424
</template>
+26-41
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script setup lang="ts">
2-
import { hasFailedSnapshot } from '@vitest/ws-client'
32
import { Tooltip as VueTooltip } from 'floating-vue'
43
import type { File, Task } from 'vitest'
54
import {
@@ -9,22 +8,20 @@ import {
98
currentModule,
109
dashboardVisible,
1110
disableCoverage,
12-
openedTreeItems,
1311
showCoverage,
1412
showDashboard,
1513
} from '~/composables/navigation'
16-
import { client, files, findById, isReport, runAll } from '~/composables/client'
14+
import { client, findById, isReport, runAll, runFiles } from '~/composables/client'
1715
import { isDark, toggleDark } from '~/composables'
1816
import { activeFileId } from '~/composables/params'
17+
import { explorerTree } from '~/composables/explorer'
18+
import { initialized, shouldShowExpandAll } from '~/composables/explorer/state'
1919
20-
const failedSnapshot = computed(
21-
() => files.value && hasFailedSnapshot(files.value),
22-
)
2320
function updateSnapshot() {
2421
return client.rpc.updateSnapshot()
2522
}
2623
27-
const toggleMode = computed(() => (isDark.value ? 'light' : 'dark'))
24+
const toggleMode = computed(() => isDark.value ? 'light' : 'dark')
2825
2926
function onItemClick(task: Task) {
3027
activeFileId.value = task.file.id
@@ -41,46 +38,42 @@ async function onRunAll(files?: File[]) {
4138
await nextTick()
4239
}
4340
}
44-
await runAll(files)
41+
if (files?.length) {
42+
await runFiles(files)
43+
}
44+
else {
45+
await runAll()
46+
}
4547
}
4648
4749
function collapseTests() {
48-
openedTreeItems.value = []
50+
explorerTree.collapseAllNodes()
4951
}
5052
5153
function expandTests() {
52-
files.value.forEach((file) => {
53-
if (!openedTreeItems.value.includes(file.id)) {
54-
openedTreeItems.value.push(file.id)
55-
}
56-
})
54+
explorerTree.expandAllNodes()
5755
}
5856
</script>
5957

6058
<template>
6159
<!-- TODO: have test tree so the folders are also nested: test -> filename -> suite -> test -->
62-
<TasksList
63-
border="r base"
64-
:tasks="files"
65-
:on-item-click="onItemClick"
66-
:group-by-type="true"
67-
:nested="true"
68-
@run="onRunAll"
69-
>
70-
<template #header="{ filteredTests }">
60+
<Explorer border="r base" :on-item-click="onItemClick" :nested="true" @run="onRunAll">
61+
<template #header="{ filteredFiles }">
7162
<img w-6 h-6 src="/favicon.svg" alt="Vitest logo">
7263
<span font-light text-sm flex-1>Vitest</span>
7364
<div class="flex text-lg">
7465
<IconButton
75-
v-show="openedTreeItems.length > 0"
66+
v-show="!shouldShowExpandAll"
7667
v-tooltip.bottom="'Collapse tests'"
7768
title="Collapse tests"
69+
:disabled="!initialized"
7870
icon="i-carbon:collapse-all"
7971
@click="collapseTests()"
8072
/>
8173
<IconButton
82-
v-show="openedTreeItems.length === 0"
74+
v-show="shouldShowExpandAll"
8375
v-tooltip.bottom="'Expand tests'"
76+
:disabled="!initialized"
8477
title="Expand tests"
8578
icon="i-carbon:expand-all"
8679
@click="expandTests()"
@@ -101,10 +94,7 @@ function expandTests() {
10194
>
10295
<div class="i-carbon:folder-off ma" />
10396
<template #popper>
104-
<div
105-
class="op100 gap-1 p-y-1"
106-
grid="~ items-center cols-[1.5em_1fr]"
107-
>
97+
<div class="op100 gap-1 p-y-1" grid="~ items-center cols-[1.5em_1fr]">
10898
<div class="i-carbon:information-square w-1.5em h-1.5em" />
10999
<div>Coverage enabled but missing html reporter.</div>
110100
<div style="grid-column: 2">
@@ -125,23 +115,18 @@ function expandTests() {
125115
@click="showCoverage()"
126116
/>
127117
<IconButton
128-
v-if="failedSnapshot && !isReport"
118+
v-if="(explorerTree.summary.failedSnapshot && !isReport)"
129119
v-tooltip.bottom="'Update all failed snapshot(s)'"
130120
icon="i-carbon:result-old"
131-
@click="updateSnapshot()"
121+
:disabled="!explorerTree.summary.failedSnapshotEnabled"
122+
@click="explorerTree.summary.failedSnapshotEnabled && updateSnapshot()"
132123
/>
133124
<IconButton
134125
v-if="!isReport"
135-
v-tooltip.bottom="
136-
filteredTests
137-
? filteredTests.length === 0
138-
? 'No test to run (clear filter)'
139-
: 'Rerun filtered'
140-
: 'Rerun all'
141-
"
142-
:disabled="filteredTests?.length === 0"
126+
v-tooltip.bottom="filteredFiles ? (filteredFiles.length === 0 ? 'No test to run (clear filter)' : 'Rerun filtered') : 'Rerun all'"
127+
:disabled="filteredFiles?.length === 0"
143128
icon="i-carbon:play"
144-
@click="onRunAll(filteredTests)"
129+
@click="onRunAll(filteredFiles)"
145130
/>
146131
<IconButton
147132
v-tooltip.bottom="`Toggle to ${toggleMode} mode`"
@@ -150,5 +135,5 @@ function expandTests() {
150135
/>
151136
</div>
152137
</template>
153-
</TasksList>
138+
</Explorer>
154139
</template>

‎packages/ui/client/components/ProgressBar.vue

+12-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<script setup lang="ts">
2-
import { files } from '~/composables/client'
3-
import { filesFailed, filesSuccess, finished } from '~/composables/summary'
2+
import { explorerTree } from '~/composables/explorer'
3+
import { finished } from '~/composables/client/state'
44
55
const { width } = useWindowSize()
66
const classes = computed(() => {
7-
// if there is no files, then in progress and gray
8-
if (files.value.length === 0) {
7+
// if there are no files, then in progress and gray
8+
if (explorerTree.summary.files === 0) {
99
return '!bg-gray-4 !dark:bg-gray-7 in-progress'
1010
}
1111
else if (!finished.value) {
@@ -14,25 +14,22 @@ const classes = computed(() => {
1414
1515
return null
1616
})
17-
const total = computed(() => files.value.length)
18-
const pass = computed(() => filesSuccess.value.length)
19-
const failed = computed(() => filesFailed.value.length)
2017
2118
const widthPass = computed(() => {
22-
const t = unref(total)
23-
return t > 0 ? (width.value * pass.value) / t : 0
19+
const t = explorerTree.summary.files
20+
return t > 0 ? (width.value * explorerTree.summary.filesSuccess / t) : 0
2421
})
2522
const widthFailed = computed(() => {
26-
const t = unref(total)
27-
return t > 0 ? (width.value * failed.value) / t : 0
23+
const t = explorerTree.summary.files
24+
return t > 0 ? (width.value * explorerTree.summary.filesFailed / t) : 0
2825
})
2926
const pending = computed(() => {
30-
const t = unref(total)
31-
return t - failed.value - pass.value
27+
const t = explorerTree.summary.files
28+
return t - explorerTree.summary.filesFailed - explorerTree.summary.filesSuccess
3229
})
3330
const widthPending = computed(() => {
34-
const t = unref(total)
35-
return t > 0 ? (width.value * pending.value) / t : 0
31+
const t = explorerTree.summary.files
32+
return t > 0 ? (width.value * pending.value / t) : 0
3633
})
3734
</script>
3835

‎packages/ui/client/components/TaskItem.vue

-120
This file was deleted.

‎packages/ui/client/components/TaskTree.vue

-94
This file was deleted.

‎packages/ui/client/components/TasksList.vue

-243
This file was deleted.

‎packages/ui/client/components/dashboard/TestFilesEntry.vue

+21-24
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
<script setup lang="ts">
2-
import { files, unhandledErrors } from '~/composables/client'
3-
import {
4-
filesFailed,
5-
filesSnapshotFailed,
6-
filesSuccess,
7-
time,
8-
} from '~/composables/summary'
2+
import { unhandledErrors } from '~/composables/client/state'
3+
import { explorerTree } from '~/composables/explorer'
94
</script>
105

116
<template>
@@ -23,36 +18,42 @@ import {
2318
<div i-carbon-document />
2419
<div>Files</div>
2520
<div class="number" data-testid="num-files">
26-
{{ files.length }}
21+
{{ explorerTree.summary.files }}
2722
</div>
2823

29-
<template v-if="filesSuccess.length">
24+
<template v-if="explorerTree.summary.filesSuccess">
3025
<div i-carbon-checkmark />
3126
<div>Pass</div>
3227
<div class="number">
33-
{{ filesSuccess.length }}
28+
{{ explorerTree.summary.filesSuccess }}
3429
</div>
3530
</template>
3631

37-
<template v-if="filesFailed.length">
32+
<template v-if="explorerTree.summary.filesFailed">
3833
<div i-carbon-close />
39-
<div>Fail</div>
34+
<div>
35+
Fail
36+
</div>
4037
<div class="number" text-red5>
41-
{{ filesFailed.length }}
38+
{{ explorerTree.summary.filesFailed }}
4239
</div>
4340
</template>
4441

45-
<template v-if="filesSnapshotFailed.length">
42+
<template v-if="explorerTree.summary.filesSnapshotFailed">
4643
<div i-carbon-compare />
47-
<div>Snapshot Fail</div>
44+
<div>
45+
Snapshot Fail
46+
</div>
4847
<div class="number" text-red5>
49-
{{ filesSnapshotFailed.length }}
48+
{{ explorerTree.summary.filesSnapshotFailed }}
5049
</div>
5150
</template>
5251

5352
<template v-if="unhandledErrors.length">
5453
<div i-carbon-checkmark-outline-error />
55-
<div>Errors</div>
54+
<div>
55+
Errors
56+
</div>
5657
<div class="number" text-red5>
5758
{{ unhandledErrors.length }}
5859
</div>
@@ -61,7 +62,7 @@ import {
6162
<div i-carbon-timer />
6263
<div>Time</div>
6364
<div class="number" data-testid="run-time">
64-
{{ time }}
65+
{{ explorerTree.summary.time }}
6566
</div>
6667
</div>
6768
<template v-if="unhandledErrors.length">
@@ -70,12 +71,8 @@ import {
7071
Unhandled Errors
7172
</h3>
7273
<p text="sm" font-thin mb-2 data-testid="unhandled-errors">
73-
Vitest caught {{ unhandledErrors.length }} error{{
74-
unhandledErrors.length > 1 ? "s" : ""
75-
}}
76-
during the test run.<br>
77-
This might cause false positive tests. Resolve unhandled errors to make
78-
sure your tests are not affected.
74+
Vitest caught {{ unhandledErrors.length }} error{{ unhandledErrors.length > 1 ? 's' : '' }} during the test run.<br>
75+
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
7976
</p>
8077
<details
8178
data-testid="unhandled-errors-details"

‎packages/ui/client/components/dashboard/TestsEntry.vue

+15-25
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
11
<script setup lang="ts">
2-
import {
3-
tests,
4-
testsFailed,
5-
testsSkipped,
6-
testsSuccess,
7-
testsTodo,
8-
} from '~/composables/summary'
9-
10-
const total = computed(() => tests.value.length)
11-
const pass = computed(() => testsSuccess.value.length)
12-
const failed = computed(() => testsFailed.value.length)
13-
const skipped = computed(() => testsSkipped.value.length)
14-
const todo = computed(() => testsTodo.value.length)
15-
// const pending = computed(() => {
16-
// const t = unref(total)
17-
// return t - failed.value - pass.value
18-
// })
2+
import { explorerTree } from '~/composables/explorer'
193
</script>
204

215
<template>
@@ -25,42 +9,48 @@ const todo = computed(() => testsTodo.value.length)
259
Pass
2610
</template>
2711
<template #body>
28-
{{ pass }}
12+
{{ explorerTree.summary.testsSuccess }}
2913
</template>
3014
</DashboardEntry>
3115
<DashboardEntry
32-
:class="{ 'text-red5': failed, 'op50': !failed }"
16+
:class="{ 'text-red5': explorerTree.summary.testsFailed, 'op50': !explorerTree.summary.testsFailed }"
3317
data-testid="fail-entry"
3418
>
3519
<template #header>
3620
Fail
3721
</template>
3822
<template #body>
39-
{{ failed }}
23+
{{ explorerTree.summary.testsFailed }}
4024
</template>
4125
</DashboardEntry>
42-
<DashboardEntry v-if="skipped" op50 data-testid="skipped-entry">
26+
<DashboardEntry
27+
v-if="explorerTree.summary.testsSkipped"
28+
op50 data-testid="skipped-entry"
29+
>
4330
<template #header>
4431
Skip
4532
</template>
4633
<template #body>
47-
{{ skipped }}
34+
{{ explorerTree.summary.testsSkipped }}
4835
</template>
4936
</DashboardEntry>
50-
<DashboardEntry v-if="todo" op50 data-testid="todo-entry">
37+
<DashboardEntry
38+
v-if="explorerTree.summary.testsTodo" op50
39+
data-testid="todo-entry"
40+
>
5141
<template #header>
5242
Todo
5343
</template>
5444
<template #body>
55-
{{ todo }}
45+
{{ explorerTree.summary.testsTodo }}
5646
</template>
5747
</DashboardEntry>
5848
<DashboardEntry :tail="true" data-testid="total-entry">
5949
<template #header>
6050
Total
6151
</template>
6252
<template #body>
63-
{{ total }}
53+
{{ explorerTree.summary.totalTests }}
6454
</template>
6555
</DashboardEntry>
6656
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<script setup lang="ts">
2+
import type { File, Task } from '@vitest/runner'
3+
import { hideAllPoppers } from 'floating-vue'
4+
5+
// @ts-expect-error missing types
6+
import { RecycleScroller } from 'vue-virtual-scroller'
7+
8+
import { activeFileId } from '~/composables/params'
9+
import { useSearch } from '~/composables/explorer/search'
10+
11+
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
12+
13+
defineOptions({ inheritAttrs: false })
14+
15+
const { onItemClick } = defineProps<{
16+
onItemClick?: (task: Task) => void
17+
}>()
18+
19+
const emit = defineEmits<{
20+
(event: 'item-click', files?: File[]): void
21+
(event: 'run', files?: File[]): void
22+
}>()
23+
24+
const searchBox = ref<HTMLInputElement | undefined>()
25+
26+
const {
27+
initialized,
28+
filter,
29+
search,
30+
disableFilter,
31+
isFiltered,
32+
isFilteredByStatus,
33+
disableClearSearch,
34+
clearAll,
35+
clearSearch,
36+
clearFilter,
37+
filteredFiles,
38+
testsTotal,
39+
uiEntries,
40+
} = useSearch(searchBox)
41+
42+
const filterClass = ref<string>('grid-cols-2')
43+
const filterHeaderClass = ref<string>('grid-col-span-2')
44+
const testExplorerRef = ref<HTMLInputElement | undefined>()
45+
46+
useResizeObserver(testExplorerRef, (entries) => {
47+
const { width } = entries[0].contentRect
48+
if (width < 420) {
49+
filterClass.value = 'grid-cols-2'
50+
filterHeaderClass.value = 'grid-col-span-2'
51+
}
52+
else {
53+
filterClass.value = 'grid-cols-4'
54+
filterHeaderClass.value = 'grid-col-span-4'
55+
}
56+
})
57+
</script>
58+
59+
<template>
60+
<div ref="testExplorerRef" h="full" flex="~ col">
61+
<div>
62+
<div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base">
63+
<slot name="header" :filtered-files="isFiltered || isFilteredByStatus ? filteredFiles : undefined" />
64+
</div>
65+
<div
66+
p="l3 y2 r2"
67+
flex="~ gap-2"
68+
items-center
69+
bg-header
70+
border="b-2 base"
71+
>
72+
<div class="i-carbon:search" flex-shrink-0 />
73+
<input
74+
ref="searchBox"
75+
v-model="search"
76+
placeholder="Search..."
77+
outline="none"
78+
bg="transparent"
79+
font="light"
80+
text="sm"
81+
flex-1
82+
pl-1
83+
:op="search.length ? '100' : '50'"
84+
@keydown.esc="clearSearch(false)"
85+
@keydown.enter="emit('run', isFiltered || isFilteredByStatus ? filteredFiles : undefined)"
86+
>
87+
<IconButton
88+
v-tooltip.bottom="'Clear search'"
89+
:disabled="disableClearSearch"
90+
title="Clear search"
91+
icon="i-carbon:filter-remove"
92+
@click.passive="clearSearch(true)"
93+
/>
94+
</div>
95+
<div
96+
p="l3 y2 r2"
97+
items-center
98+
bg-header
99+
border="b-2 base"
100+
grid="~ items-center gap-x-2 rows-[auto_auto]"
101+
:class="filterClass"
102+
>
103+
<div :class="filterHeaderClass" flex="~ gap-2 items-center">
104+
<div aria-hidden="true" class="i-carbon:filter" />
105+
<div flex-grow-1 text-sm>
106+
Filter
107+
</div>
108+
<IconButton
109+
v-tooltip.bottom="'Clear Filter'"
110+
:disabled="disableFilter"
111+
title="Clear search"
112+
icon="i-carbon:filter-remove"
113+
@click.passive="clearFilter(false)"
114+
/>
115+
</div>
116+
<FilterStatus v-model="filter.failed" label="Fail" />
117+
<FilterStatus v-model="filter.success" label="Pass" />
118+
<FilterStatus v-model="filter.skipped" label="Skip" />
119+
<FilterStatus v-model="filter.onlyTests" label="Only Tests" />
120+
</div>
121+
</div>
122+
<div class="scrolls" flex-auto py-1 @scroll.passive="hideAllPoppers">
123+
<DetailsPanel>
124+
<template #summary>
125+
<div grid="~ items-center gap-x-1 cols-[auto_min-content_auto] rows-[min-content_min-content]">
126+
<span text-red5>
127+
FAIL ({{ testsTotal.failed }})
128+
</span>
129+
<span>/</span>
130+
<span text-yellow5>
131+
RUNNING ({{ testsTotal.running }})
132+
</span>
133+
<span text-green5>
134+
PASS ({{ testsTotal.success }})
135+
</span>
136+
<span>/</span>
137+
<span class="text-purple5:50">
138+
SKIP ({{ filter.onlyTests ? testsTotal.skipped : '--' }})
139+
</span>
140+
</div>
141+
</template>
142+
<!-- empty-state -->
143+
<template v-if="(isFiltered || isFilteredByStatus) && uiEntries.length === 0">
144+
<div v-show="initialized" flex="~ col" items-center p="x4 y4" font-light>
145+
<div op30>
146+
No matched test
147+
</div>
148+
<button
149+
type="button"
150+
font-light
151+
text-sm
152+
border="~ gray-400/50 rounded"
153+
p="x2 y0.5"
154+
m="t2"
155+
op="50"
156+
:class="disableClearSearch ? null : 'hover:op100'"
157+
:disabled="disableClearSearch"
158+
@click.passive="clearSearch(true)"
159+
>
160+
Clear Search
161+
</button>
162+
<button
163+
type="button"
164+
font-light
165+
text-sm
166+
border="~ gray-400/50 rounded"
167+
p="x2 y0.5"
168+
m="t2"
169+
op="50"
170+
:class="disableFilter ? null : 'hover:op100'"
171+
:disabled="disableFilter"
172+
@click.passive="clearFilter(true)"
173+
>
174+
Clear Filter
175+
</button>
176+
<button
177+
type="button"
178+
font-light
179+
op="50 hover:100"
180+
text-sm
181+
border="~ gray-400/50 rounded"
182+
p="x2 y0.5"
183+
m="t2"
184+
@click.passive="clearAll"
185+
>
186+
Clear All
187+
</button>
188+
</div>
189+
</template>
190+
<template v-else>
191+
<RecycleScroller
192+
page-mode
193+
key-field="id"
194+
:item-size="28"
195+
:items="uiEntries"
196+
:buffer="100"
197+
>
198+
<template #default="{ item }">
199+
<ExplorerItem
200+
:task-id="item.id"
201+
:expandable="item.expandable"
202+
:type="item.type"
203+
:current="activeFileId === item.id"
204+
:indent="item.indent"
205+
:name="item.name"
206+
:typecheck="item.typecheck === true"
207+
:project-name="item.projectName ?? ''"
208+
:project-name-color="item.projectNameColor ?? ''"
209+
:state="item.state"
210+
:duration="item.duration"
211+
:opened="item.expanded"
212+
class="h-28px m-0 p-0"
213+
:class="activeFileId === item.id ? 'bg-active' : ''"
214+
:on-item-click="onItemClick"
215+
/>
216+
</template>
217+
</RecycleScroller>
218+
</template>
219+
</DetailsPanel>
220+
</div>
221+
</div>
222+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<script setup lang="ts">
2+
import type { Task, TaskState } from '@vitest/runner'
3+
import { nextTick } from 'vue'
4+
import { hasFailedSnapshot } from '@vitest/ws-client'
5+
import { client, isReport, runFiles } from '~/composables/client'
6+
import { coverageEnabled } from '~/composables/navigation'
7+
import type { TaskTreeNodeType } from '~/composables/explorer/types'
8+
import { explorerTree } from '~/composables/explorer'
9+
import { search } from '~/composables/explorer/state'
10+
11+
// TODO: better handling of "opened" - it means to forcefully open the tree item and set in TasksList right now
12+
const {
13+
taskId,
14+
indent,
15+
name,
16+
duration,
17+
current,
18+
opened,
19+
expandable,
20+
typecheck,
21+
type,
22+
onItemClick,
23+
} = defineProps<{
24+
taskId: string
25+
name: string
26+
indent: number
27+
typecheck?: boolean
28+
duration?: number
29+
state?: TaskState
30+
current: boolean
31+
type: TaskTreeNodeType
32+
opened: boolean
33+
expandable: boolean
34+
search?: string
35+
projectName?: string
36+
projectNameColor: string
37+
onItemClick?: (task: Task) => void
38+
}>()
39+
40+
const task = computed(() => client.state.idMap.get(taskId))
41+
42+
const failedSnapshot = computed(() => task.value && hasFailedSnapshot(task.value))
43+
44+
function toggleOpen() {
45+
if (!expandable) {
46+
return
47+
}
48+
49+
if (opened) {
50+
explorerTree.collapseNode(taskId)
51+
}
52+
else {
53+
explorerTree.expandNode(taskId)
54+
}
55+
}
56+
57+
async function onRun(task: Task) {
58+
onItemClick?.(task)
59+
if (coverageEnabled.value) {
60+
disableCoverage.value = true
61+
await nextTick()
62+
}
63+
await runFiles([task.file])
64+
}
65+
66+
function updateSnapshot(task: Task) {
67+
return client.rpc.updateSnapshot(task.file)
68+
}
69+
70+
const data = computed(() => {
71+
return indent <= 0 ? [] : Array.from({ length: indent }, (_, i) => `${taskId}-${i}`)
72+
})
73+
const gridStyles = computed(() => {
74+
const entries = data.value
75+
const gridColumns: string[] = []
76+
// folder icon
77+
if (type === 'file' || type === 'suite') {
78+
gridColumns.push('min-content')
79+
}
80+
81+
// status icon
82+
gridColumns.push('min-content')
83+
// typecheck icon
84+
if (type === 'suite' && typecheck) {
85+
gridColumns.push('min-content')
86+
}
87+
// text content
88+
gridColumns.push('minmax(0, 1fr)')
89+
// buttons
90+
if (type === 'file') {
91+
gridColumns.push('min-content')
92+
}
93+
// all the vertical lines with width 1rem and mx-2: always centered
94+
return `grid-template-columns: ${
95+
entries.map(() => '1rem').join(' ')
96+
} ${gridColumns.join(' ')};`
97+
})
98+
99+
const highlightRegex = computed(() => {
100+
const searchString = search.value.toLowerCase()
101+
return searchString.length ? new RegExp(`(${searchString})`, 'gi') : null
102+
})
103+
104+
const highlighted = computed(() => {
105+
const regex = highlightRegex.value
106+
return regex
107+
? name.replace(regex, match => `<span class="highlight">${match}</span>`)
108+
: name
109+
})
110+
</script>
111+
112+
<template>
113+
<div
114+
v-if="task"
115+
items-center
116+
p="x-2 y-1"
117+
grid="~ rows-1 items-center gap-x-2"
118+
w-full
119+
h-28px
120+
border-rounded
121+
hover="bg-active"
122+
cursor-pointer
123+
class="item-wrapper"
124+
:style="gridStyles"
125+
:aria-label="name"
126+
:data-current="current"
127+
@click="toggleOpen()"
128+
>
129+
<template v-if="indent > 0">
130+
<div v-for="i in data" :key="i" border="solid gray-500 dark:gray-400" class="vertical-line" h-28px inline-flex mx-2 op20 />
131+
</template>
132+
<div v-if="type === 'file' || type === 'suite'" w-4>
133+
<div :class="opened ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right op20'" op20 />
134+
</div>
135+
<StatusIcon :task="task" w-4 />
136+
<div v-if="type === 'suite' && typecheck" class="i-logos:typescript-icon" flex-shrink-0 mr-2 />
137+
<div flex items-end gap-2 :text="state === 'fail' ? 'red-500' : ''" overflow-hidden>
138+
<span text-sm truncate font-light>
139+
<!-- only show [] in files view -->
140+
<span v-if="type === 'file' && projectName" :style="{ color: projectNameColor }">
141+
[{{ projectName }}]
142+
</span>
143+
<span v-html="highlighted" />
144+
</span>
145+
<span v-if="typeof duration === 'number'" text="xs" op20 style="white-space: nowrap">
146+
{{ duration > 0 ? duration : '< 1' }}ms
147+
</span>
148+
</div>
149+
<div v-if="type === 'file'" gap-1 justify-end flex-grow-1 pl-1 class="test-actions">
150+
<IconAction
151+
v-if="!isReport && failedSnapshot"
152+
v-tooltip.bottom="'Fix failed snapshot(s)'"
153+
data-testid="btn-fix-snapshot"
154+
title="Fix failed snapshot(s)"
155+
icon="i-carbon-result-old"
156+
@click.prevent.stop="updateSnapshot(task)"
157+
/>
158+
<IconAction
159+
v-tooltip.bottom="'Open test details'"
160+
data-testid="btn-open-details"
161+
title="Open test details"
162+
icon="i-carbon-intrusion-prevention"
163+
@click.prevent.stop="onItemClick?.(task)"
164+
/>
165+
<IconAction
166+
v-if="!isReport"
167+
v-tooltip.bottom="'Run current test'"
168+
data-testid="btn-run-test"
169+
title="Run current test"
170+
icon="i-carbon:play-filled-alt"
171+
text-green5
172+
@click.prevent.stop="onRun(task)"
173+
/>
174+
</div>
175+
</div>
176+
</template>
177+
178+
<style scoped>
179+
.vertical-line:first-of-type {
180+
@apply border-l-2px;
181+
}
182+
.vertical-line + .vertical-line {
183+
@apply border-r-1px;
184+
}
185+
.test-actions {
186+
display: none;
187+
}
188+
.item-wrapper:hover .test-actions,
189+
.item-wrapper[data-current="true"] .test-actions {
190+
display: flex;
191+
}
192+
</style>

‎packages/ui/client/composables/client/index.ts

+58-32
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
import { createClient, getTasks } from '@vitest/ws-client'
22
import type { WebSocketStatus } from '@vueuse/core'
3-
import type { ErrorWithDiff, File, ResolvedConfig } from 'vitest'
4-
import type { Ref } from 'vue'
5-
import { reactive } from 'vue'
3+
import type { File, ResolvedConfig, TaskResultPack } from 'vitest'
4+
import { reactive as reactiveVue } from 'vue'
65
import { createFileTask } from '@vitest/runner/utils'
7-
import type { BrowserRunnerState, RunState } from '../../../types'
6+
import type { BrowserRunnerState } from '../../../types'
87
import { ENTRY_URL, isReport } from '../../constants'
98
import { parseError } from '../error'
109
import { activeFileId } from '../params'
1110
import { createStaticClient } from './static'
11+
import { testRunState, unhandledErrors } from './state'
12+
import { uiFiles } from '~/composables/explorer/state'
13+
import { explorerTree } from '~/composables/explorer'
14+
import { isFileNode } from '~/composables/explorer/utils'
1215

1316
export { ENTRY_URL, PORT, HOST, isReport } from '../../constants'
1417

15-
export const testRunState: Ref<RunState> = ref('idle')
16-
export const unhandledErrors: Ref<ErrorWithDiff[]> = ref([])
17-
1818
export const client = (function createVitestClient() {
1919
if (isReport) {
2020
return createStaticClient()
2121
}
2222
else {
2323
return createClient(ENTRY_URL, {
24-
reactive: reactive as any,
24+
reactive: (data, ctxKey) => {
25+
return ctxKey === 'state' ? reactiveVue(data as any) as any : shallowRef(data)
26+
},
2527
handlers: {
26-
onTaskUpdate() {
28+
onTaskUpdate(packs: TaskResultPack[]) {
29+
explorerTree.resumeRun(packs)
2730
testRunState.value = 'running'
2831
},
2932
onFinished(_files, errors) {
33+
explorerTree.endRun()
3034
testRunState.value = 'idle'
3135
unhandledErrors.value = (errors || []).map(parseError)
3236
},
@@ -42,41 +46,61 @@ export const client = (function createVitestClient() {
4246
}
4347
})()
4448

45-
function sort(a: File, b: File) {
46-
return a.name.localeCompare(b.name)
47-
}
48-
4949
export const config = shallowRef<ResolvedConfig>({} as any)
5050
export const status = ref<WebSocketStatus>('CONNECTING')
51-
export const files = computed(() => client.state.getFiles().sort(sort))
52-
export const current = computed(() =>
53-
files.value.find(file => file.id === activeFileId.value),
54-
)
55-
export const currentLogs = computed(
56-
() =>
57-
getTasks(current.value)
58-
.map(i => i?.logs || [])
59-
.flat() || [],
60-
)
51+
52+
export const current = computed(() => {
53+
const currentFileId = activeFileId.value
54+
const entry = uiFiles.value.find(file => file.id === currentFileId)!
55+
return entry ? findById(entry.id) : undefined
56+
})
57+
export const currentLogs = computed(() => getTasks(current.value).map(i => i?.logs || []).flat() || [])
6158

6259
export function findById(id: string) {
63-
return files.value.find(file => file.id === id)
60+
const file = client.state.idMap.get(id)
61+
return file ? file as File : undefined
6462
}
6563

6664
export const isConnected = computed(() => status.value === 'OPEN')
6765
export const isConnecting = computed(() => status.value === 'CONNECTING')
6866
export const isDisconnected = computed(() => status.value === 'CLOSED')
6967

70-
export function runAll(files = client.state.getFiles()) {
71-
return runFiles(files)
68+
export function runAll() {
69+
return runFiles(client.state.getFiles()/* , true */)
7270
}
7371

74-
export function runFiles(files: File[]) {
75-
files.forEach((f) => {
72+
function clearResults(useFiles: File[]) {
73+
const map = explorerTree.nodes
74+
useFiles.forEach((f) => {
7675
delete f.result
77-
getTasks(f).forEach(i => delete i.result)
76+
getTasks(f).forEach((i) => {
77+
delete i.result
78+
// explorerTree.removeTaskDone(i.id)
79+
if (map.has(i.id)) {
80+
const task = map.get(i.id)
81+
if (task) {
82+
task.state = undefined
83+
task.duration = undefined
84+
}
85+
}
86+
})
87+
const file = map.get(f.id)
88+
if (file) {
89+
file.state = undefined
90+
file.duration = undefined
91+
if (isFileNode(file)) {
92+
file.collectDuration = undefined
93+
}
94+
}
7895
})
79-
return client.rpc.rerun(files.map(i => i.filepath))
96+
}
97+
98+
export function runFiles(useFiles: File[]) {
99+
clearResults(useFiles)
100+
101+
explorerTree.startRun()
102+
103+
return client.rpc.rerun(useFiles.map(i => i.filepath))
80104
}
81105

82106
export function runCurrent() {
@@ -98,7 +122,7 @@ watch(
98122
ws.addEventListener('open', async () => {
99123
status.value = 'OPEN'
100124
client.state.filesMap.clear()
101-
const [files, _config, errors] = await Promise.all([
125+
const [remoteFiles, _config, errors] = await Promise.all([
102126
client.rpc.getFiles(),
103127
client.rpc.getConfig(),
104128
client.rpc.getUnhandledErrors(),
@@ -111,7 +135,9 @@ watch(
111135
client.state.collectFiles(files)
112136
}
113137
else {
114-
client.state.collectFiles(files)
138+
explorerTree.loadFiles(remoteFiles)
139+
client.state.collectFiles(remoteFiles)
140+
explorerTree.startRun()
115141
}
116142
unhandledErrors.value = (errors || []).map(parseError)
117143
config.value = _config
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { Ref } from 'vue'
2+
import type { ErrorWithDiff } from '@vitest/utils'
3+
import type { RunState } from '../../../types'
4+
5+
export const testRunState: Ref<RunState> = ref('idle')
6+
export const finished = computed(() => testRunState.value === 'idle')
7+
export const unhandledErrors: Ref<ErrorWithDiff[]> = ref([])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { UITaskTreeNode } from '~/composables/explorer/types'
2+
import { isFileNode, isParentNode } from '~/composables/explorer/utils'
3+
import { openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state'
4+
import { explorerTree } from '~/composables/explorer/index'
5+
6+
/**
7+
* Collapse all nodes: all children collapsed.
8+
*
9+
* This method will use current `uiEntries`, we don't need to traverse the full tree.
10+
* The action will be applied on the current items in the test results explorer.
11+
*
12+
* If the node is not a parent node, nothing will happen.
13+
*
14+
* Calling this method will:
15+
* - collapse all nodes
16+
* - remove opened tree items for the node and any children
17+
* - update uiEntries without child nodes
18+
*
19+
* @param id The node id to collapse.
20+
*/
21+
export function runCollapseNode(id: string) {
22+
const node = explorerTree.nodes.get(id)
23+
if (!node || !isParentNode(node)) {
24+
return
25+
}
26+
27+
const treeItems = new Set(openedTreeItems.value)
28+
treeItems.delete(node.id)
29+
const entries = [...collectCollapseNode(node)]
30+
openedTreeItems.value = Array.from(treeItems)
31+
// Keep expandAll state as it is: collapsing individual shouldn't prevent collapsing all the nodes ("collapse all" button)
32+
// There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items
33+
// treeFilter.value.expandAll = true
34+
uiEntries.value = entries
35+
}
36+
37+
/**
38+
* Collapse all nodes: any child collapsed.
39+
*
40+
* This method will use current `uiEntries`, we don't need to traverse the full tree.
41+
* The action will be applied on the current items in the test results explorer.
42+
*
43+
* We'll use current `uiEntries`, we don't need to traverse the full tree.
44+
*
45+
* Calling this method will:
46+
* - collapse all nodes
47+
* - clear stored opened tree items
48+
* - update the filtered expandAll state to true
49+
* - update uiEntries without child nodes
50+
*
51+
*/
52+
export function runCollapseAllTask() {
53+
// collapse all nodes
54+
collapseAllNodes(explorerTree.root.tasks)
55+
const entries = [...uiEntries.value.filter(isFileNode)]
56+
collapseAllNodes(entries)
57+
// collapse all nodes
58+
openedTreeItems.value = []
59+
treeFilter.value.expandAll = true
60+
uiEntries.value = entries
61+
}
62+
63+
function collapseAllNodes(nodes: UITaskTreeNode[]) {
64+
for (const node of nodes) {
65+
if (isParentNode(node)) {
66+
node.expanded = false
67+
collapseAllNodes(node.tasks)
68+
}
69+
}
70+
}
71+
72+
function * collectChildNodes(node: UITaskTreeNode, itself: boolean): Generator<string> {
73+
if (itself) {
74+
yield node.id
75+
}
76+
77+
if (isParentNode(node)) {
78+
for (let i = 0; i < node.tasks.length; i++) {
79+
yield * collectChildNodes(node.tasks[i], true)
80+
}
81+
}
82+
}
83+
84+
function* collectCollapseNode(node: UITaskTreeNode) {
85+
const id = node.id
86+
// collect children to remove from the list
87+
const childNodes = new Set<string>(collectChildNodes(node, false))
88+
for (let i = 0; i < uiEntries.value.length; i++) {
89+
const child = uiEntries.value[i]
90+
// collapse current node and return it
91+
if (child.id === id) {
92+
child.expanded = false
93+
yield child
94+
continue
95+
}
96+
97+
// remove children from the list
98+
if (childNodes.has(child.id)) {
99+
childNodes.delete(child.id)
100+
continue
101+
}
102+
103+
// return the node
104+
yield child
105+
}
106+
}

‎packages/ui/client/composables/explorer/collector.ts

+400
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state'
2+
import type { Filter, UITaskTreeNode } from '~/composables/explorer/types'
3+
import { createOrUpdateNode, createOrUpdateSuiteTask, isFileNode, isParentNode } from '~/composables/explorer/utils'
4+
import { filterAll, filterNode } from '~/composables/explorer/filter'
5+
import { findById } from '~/composables/client'
6+
import { explorerTree } from '~/composables/explorer/index'
7+
8+
/**
9+
* Expand the node: only direct children will be expanded
10+
*
11+
* This method will use current `uiEntries`, we don't need to traverse the full tree.
12+
* The action will be applied on the current items in the test results explorer.
13+
*
14+
* **Note:** we only need to apply the filter on child nodes, when filtering, parent nodes
15+
* are not present in the explorer if don't match the filter, only those matching the criteria
16+
* will be there, we only need to filter the children of the node to expand.
17+
*
18+
* Calling this method will:
19+
* - remove opened tree items for the node and any children
20+
* - update uiEntries including child nodes
21+
*
22+
* @param id The node id to expand.
23+
* @param search The search applied.
24+
* @param filter The filter applied.
25+
*/
26+
export function runExpandNode(
27+
id: string,
28+
search: string,
29+
filter: Filter,
30+
) {
31+
const entry = createOrUpdateSuiteTask(
32+
id,
33+
false,
34+
)
35+
if (!entry) {
36+
return
37+
}
38+
39+
const [node, task] = entry
40+
41+
// create only direct children
42+
for (const subtask of task.tasks) {
43+
createOrUpdateNode(node.id, subtask, false)
44+
}
45+
46+
// expand the node
47+
node.expanded = true
48+
49+
const treeItems = new Set(openedTreeItems.value)
50+
treeItems.add(node.id)
51+
// collect children
52+
// the first node is itself only when it is a file
53+
const children = new Set(filterNode(
54+
node,
55+
search,
56+
filter,
57+
))
58+
59+
const entries = [...collectExpandedNode(node, children)]
60+
openedTreeItems.value = Array.from(treeItems)
61+
// Keep expandAll state as it is: expanding individual shouldn't prevent expanding all the nodes ("expand all" button)
62+
// There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items
63+
// treeFilter.value.expandAll = false
64+
uiEntries.value = entries
65+
}
66+
67+
/**
68+
* Expand all nodes: any child expanded.
69+
*
70+
* This method will use current `uiEntries`, we don't need to traverse the full tree.
71+
* The action will be applied on the current items in the test results explorer.
72+
*
73+
* Any already expanded child will be shown as expanded: collapsing nodes will not collapse any child.
74+
*
75+
* **Note:** we don't need to apply the filter here, we'll use the current `uiEntries`, when filtering,
76+
* parent nodes are not present in the explorer, only those matching the criteria will be there.
77+
* The filter will be applied to the full tree.
78+
*
79+
* Calling this method will:
80+
* - expand all nodes
81+
* - add stored opened tree items
82+
* - update the filtered expandAll state to false
83+
* - update uiEntries with child nodes
84+
*
85+
* @param search The search applied.
86+
* @param filter The filter applied.
87+
*/
88+
export function runExpandAll(
89+
search: string,
90+
filter: Filter,
91+
) {
92+
expandAllNodes(explorerTree.root.tasks, false)
93+
const entries = [...filterAll(
94+
search,
95+
filter,
96+
)]
97+
treeFilter.value.expandAll = false
98+
openedTreeItems.value = []
99+
uiEntries.value = entries
100+
filteredFiles.value = entries.filter(isFileNode).map(f => findById(f.id)!)
101+
}
102+
103+
export function expandNodesOnEndRun(
104+
ids: Set<string>,
105+
end: boolean,
106+
) {
107+
if (ids.size) {
108+
for (const node of uiEntries.value) {
109+
if (ids.has(node.id)) {
110+
node.expanded = true
111+
}
112+
}
113+
}
114+
else if (end) {
115+
expandAllNodes(uiEntries.value.filter(isFileNode), true)
116+
}
117+
}
118+
119+
export function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) {
120+
for (const node of nodes) {
121+
if (isParentNode(node)) {
122+
node.expanded = true
123+
expandAllNodes(node.tasks, false)
124+
}
125+
}
126+
127+
if (updateState) {
128+
treeFilter.value.expandAll = false
129+
openedTreeItems.value = []
130+
}
131+
}
132+
133+
function* collectExpandedNode(
134+
node: UITaskTreeNode,
135+
children: Set<UITaskTreeNode>,
136+
) {
137+
const id = node.id
138+
const ids = new Set(Array.from(children).map(n => n.id))
139+
140+
for (const child of uiEntries.value) {
141+
if (child.id === id) {
142+
child.expanded = true
143+
if (!ids.has(child.id)) {
144+
yield node
145+
}
146+
yield * children
147+
}
148+
else if (!ids.has(child.id)) {
149+
yield child
150+
}
151+
}
152+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import type { Task } from '@vitest/runner'
2+
import { caseInsensitiveMatch } from '~/utils/task'
3+
import type { FileTreeNode, Filter, FilterResult, ParentTreeNode, UITaskTreeNode } from '~/composables/explorer/types'
4+
import {
5+
isFileNode,
6+
isParentNode,
7+
isTestNode,
8+
} from '~/composables/explorer/utils'
9+
import { client, findById } from '~/composables/client'
10+
import { filteredFiles, uiEntries } from '~/composables/explorer/state'
11+
import { explorerTree } from '~/composables/explorer/index'
12+
13+
export function testMatcher(task: Task, search: string, filter: Filter) {
14+
return task ? matchTask(task, search, filter) : false
15+
}
16+
/**
17+
* Filter child nodes using search, filter and only tests.
18+
*
19+
* @param search The search applied.
20+
* @param filter The filter applied.
21+
*/
22+
export function runFilter(
23+
search: string,
24+
filter: Filter,
25+
) {
26+
const entries = [...filterAll(
27+
search,
28+
filter,
29+
)]
30+
uiEntries.value = entries
31+
filteredFiles.value = entries.filter(isFileNode).map(f => findById(f.id)!)
32+
}
33+
34+
export function* filterAll(
35+
search: string,
36+
filter: Filter,
37+
) {
38+
for (const node of explorerTree.root.tasks) {
39+
yield * filterNode(node, search, filter)
40+
}
41+
}
42+
43+
export function* filterNode(
44+
node: UITaskTreeNode,
45+
search: string,
46+
filter: Filter,
47+
) {
48+
const treeNodes = new Set<string>()
49+
50+
const list: FilterResult[] = []
51+
52+
for (const entry of visitNode(
53+
node,
54+
treeNodes,
55+
n => matcher(n, search, filter),
56+
)) {
57+
list.push(entry)
58+
}
59+
60+
const filesToShow = new Set<string>()
61+
62+
const entries = [...filterParents(
63+
list,
64+
filter.onlyTests,
65+
treeNodes,
66+
filesToShow,
67+
)].reverse()
68+
69+
// We show only the files and parents whose parent is expanded.
70+
// Filtering will return all the nodes matching the filter and their parents.
71+
// Once we've the tree, we need to remove the children from not expanded parents.
72+
// For example, if we have a suite with only one test, when collapsing the suite node,
73+
// we still need to show the suite, but the test must be removed from the list to render.
74+
75+
const map = explorerTree.nodes
76+
// collect files and all suites whose parent is expanded
77+
const parents = new Set(
78+
entries.filter(e => isFileNode(e) || (isParentNode(e) && map.get(e.parentId)?.expanded)).map(e => e.id),
79+
)
80+
81+
// collect files, and suites and tests whose parent is expanded
82+
yield * entries.filter((node) => {
83+
// all file nodes or children of expanded parents
84+
return isFileNode(node) || (parents.has(node.parentId) && map.get(node.parentId)?.expanded)
85+
})
86+
}
87+
88+
function expandCollapseNode(
89+
match: boolean,
90+
child: FileTreeNode | ParentTreeNode,
91+
treeNodes: Set<string>,
92+
collapseParents: boolean,
93+
filesToShow: Set<string>,
94+
) {
95+
if (collapseParents) {
96+
if (isFileNode(child)) {
97+
if (filesToShow.has(child.id)) {
98+
return child
99+
}
100+
101+
return undefined
102+
}
103+
// show the parent if at least one child matches the filter
104+
if (treeNodes.has(child.id)) {
105+
const parent = explorerTree.nodes.get(child.parentId)
106+
if (parent && isFileNode(parent)) {
107+
filesToShow.add(parent.id)
108+
}
109+
110+
return child
111+
}
112+
}
113+
else {
114+
// show the parent if matches the filter or at least one child matches the filter
115+
if (match || treeNodes.has(child.id) || filesToShow.has(child.id)) {
116+
const parent = explorerTree.nodes.get(child.parentId)
117+
if (parent && isFileNode(parent)) {
118+
filesToShow.add(parent.id)
119+
}
120+
121+
return child
122+
}
123+
}
124+
}
125+
126+
function* filterParents(
127+
list: FilterResult[],
128+
collapseParents: boolean,
129+
treeNodes: Set<string>,
130+
filesToShow: Set<string>,
131+
) {
132+
for (let i = list.length - 1; i >= 0; i--) {
133+
const [match, child] = list[i]
134+
if (isParentNode(child)) {
135+
const node = expandCollapseNode(
136+
match,
137+
child,
138+
treeNodes,
139+
collapseParents,
140+
filesToShow,
141+
)
142+
if (node) {
143+
yield node
144+
}
145+
}
146+
else if (match) {
147+
const parent = explorerTree.nodes.get(child.parentId)
148+
if (parent && isFileNode(parent)) {
149+
filesToShow.add(parent.id)
150+
}
151+
yield child
152+
}
153+
}
154+
}
155+
156+
function matchState(task: Task, filter: Filter) {
157+
if (filter.success || filter.failed) {
158+
if ('result' in task) {
159+
if (filter.success && task.result?.state === 'pass') {
160+
return true
161+
}
162+
if (filter.failed && task.result?.state === 'fail') {
163+
return true
164+
}
165+
}
166+
}
167+
168+
if (filter.skipped && 'mode' in task) {
169+
return task.mode === 'skip' || task.mode === 'todo'
170+
}
171+
172+
return false
173+
}
174+
175+
function matchTask(
176+
task: Task,
177+
search: string,
178+
filter: Filter,
179+
) {
180+
const match = search.length === 0 || caseInsensitiveMatch(task.name, search)
181+
182+
// search and filter will apply together
183+
if (match) {
184+
if (filter.success || filter.failed || filter.skipped) {
185+
if (matchState(task, filter)) {
186+
return true
187+
}
188+
}
189+
else {
190+
return true
191+
}
192+
}
193+
194+
return false
195+
}
196+
197+
function* visitNode(
198+
node: UITaskTreeNode,
199+
treeNodes: Set<string>,
200+
matcher: (node: UITaskTreeNode) => boolean,
201+
): Generator<[match: boolean, node: UITaskTreeNode]> {
202+
const match = matcher(node)
203+
204+
if (match) {
205+
if (isTestNode(node)) {
206+
let parent = explorerTree.nodes.get(node.parentId)
207+
while (parent) {
208+
treeNodes.add(parent.id)
209+
parent = explorerTree.nodes.get(parent.parentId)
210+
}
211+
}
212+
else if (isFileNode(node)) {
213+
treeNodes.add(node.id)
214+
}
215+
else {
216+
treeNodes.add(node.id)
217+
let parent = explorerTree.nodes.get(node.parentId)
218+
while (parent) {
219+
treeNodes.add(parent.id)
220+
parent = explorerTree.nodes.get(parent.parentId)
221+
}
222+
}
223+
}
224+
225+
yield [match, node]
226+
if (isParentNode(node)) {
227+
for (let i = 0; i < node.tasks.length; i++) {
228+
yield * visitNode(node.tasks[i], treeNodes, matcher)
229+
}
230+
}
231+
}
232+
233+
function matcher(node: UITaskTreeNode, search: string, filter: Filter) {
234+
const task = client.state.idMap.get(node.id)
235+
return task ? matchTask(task, search, filter) : false
236+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { ExplorerTree } from './tree'
2+
3+
export const explorerTree = new ExplorerTree()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { Ref } from 'vue'
2+
import {
3+
filter,
4+
filteredFiles,
5+
initialized,
6+
isFiltered,
7+
isFilteredByStatus,
8+
openedTreeItems,
9+
search,
10+
testsTotal,
11+
treeFilter,
12+
uiEntries,
13+
} from './state'
14+
import { explorerTree } from '~/composables/explorer'
15+
16+
export function useSearch(searchBox: Ref<HTMLDivElement | undefined>) {
17+
const disableFilter = computed(() => {
18+
if (isFilteredByStatus.value) {
19+
return false
20+
}
21+
22+
return !filter.onlyTests
23+
})
24+
const disableClearSearch = computed(() => search.value === '')
25+
const debouncedSearch = ref(search.value)
26+
27+
debouncedWatch(search, (value) => {
28+
debouncedSearch.value = value?.trim() ?? ''
29+
}, { debounce: 256 })
30+
31+
function clearSearch(focus: boolean) {
32+
search.value = ''
33+
focus && searchBox.value?.focus()
34+
}
35+
36+
function clearFilter(focus: boolean) {
37+
filter.failed = false
38+
filter.success = false
39+
filter.skipped = false
40+
filter.onlyTests = false
41+
focus && searchBox.value?.focus()
42+
}
43+
44+
function clearAll() {
45+
clearFilter(false)
46+
clearSearch(true)
47+
}
48+
49+
function updateFilterStorage(
50+
searchValue: string,
51+
failedValue: boolean,
52+
successValue: boolean,
53+
skippedValue: boolean,
54+
onlyTestsValue: boolean,
55+
) {
56+
if (!initialized.value) {
57+
return
58+
}
59+
60+
treeFilter.value.search = searchValue?.trim() ?? ''
61+
treeFilter.value.failed = failedValue
62+
treeFilter.value.success = successValue
63+
treeFilter.value.skipped = skippedValue
64+
treeFilter.value.onlyTests = onlyTestsValue
65+
}
66+
67+
watch(
68+
() => [
69+
debouncedSearch.value,
70+
filter.failed,
71+
filter.success,
72+
filter.skipped,
73+
filter.onlyTests,
74+
] as const,
75+
([search, failed, success, skipped, onlyTests]) => {
76+
updateFilterStorage(search, failed, success, skipped, onlyTests)
77+
explorerTree.filterNodes()
78+
},
79+
{ flush: 'post' },
80+
)
81+
82+
watch(() => openedTreeItems.value.length, (size) => {
83+
if (size) {
84+
treeFilter.value.expandAll = undefined
85+
}
86+
}, { flush: 'post' })
87+
88+
onMounted(() => {
89+
nextTick(() => (initialized.value = true))
90+
})
91+
92+
return {
93+
initialized,
94+
filter,
95+
search,
96+
disableFilter,
97+
isFiltered,
98+
isFilteredByStatus,
99+
disableClearSearch,
100+
clearAll,
101+
clearSearch,
102+
clearFilter,
103+
filteredFiles,
104+
testsTotal,
105+
uiEntries,
106+
}
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { File } from '@vitest/runner'
2+
import type { FileTreeNode, Filter, FilteredTests, TreeFilterState, UITaskTreeNode } from './types'
3+
import { explorerTree } from './index'
4+
5+
export const uiFiles = shallowRef<FileTreeNode[]>([])
6+
export const uiEntries = shallowRef<UITaskTreeNode[]>([])
7+
export const openedTreeItems = useLocalStorage<string[]>(
8+
'vitest-ui_task-tree-opened',
9+
[],
10+
{ shallow: true },
11+
)
12+
export const openedTreeItemsSet = computed(() => new Set(openedTreeItems.value))
13+
export const treeFilter = useLocalStorage<TreeFilterState>(
14+
'vitest-ui_task-tree-filter',
15+
{
16+
expandAll: undefined,
17+
failed: false,
18+
success: false,
19+
skipped: false,
20+
onlyTests: false,
21+
search: '',
22+
},
23+
)
24+
export const search = ref<string>(treeFilter.value.search)
25+
export const isFiltered = computed(() => search.value.trim() !== '')
26+
export const filter = reactive<Filter>({
27+
failed: treeFilter.value.failed,
28+
success: treeFilter.value.success,
29+
skipped: treeFilter.value.skipped,
30+
onlyTests: treeFilter.value.onlyTests,
31+
})
32+
export const isFilteredByStatus = computed(() => {
33+
if (filter.failed) {
34+
return true
35+
}
36+
37+
if (filter.success) {
38+
return true
39+
}
40+
41+
if (filter.skipped) {
42+
return true
43+
}
44+
45+
return false
46+
})
47+
export const filteredFiles = shallowRef<File[]>([])
48+
export const initialized = ref(false)
49+
export const shouldShowExpandAll = computed(() => {
50+
const expandAll = treeFilter.value.expandAll
51+
const opened = openedTreeItems.value
52+
53+
if (opened.length > 0) {
54+
return expandAll !== true
55+
}
56+
57+
return expandAll !== false
58+
})
59+
export const testsTotal = computed<FilteredTests>(() => {
60+
const filtered = isFiltered.value
61+
const filteredByStatus = isFilteredByStatus.value
62+
const onlyTests = filter.onlyTests
63+
const failed = explorerTree.summary.filesFailed
64+
const success = explorerTree.summary.filesSuccess
65+
const skipped = explorerTree.summary.filesSkipped
66+
const running = explorerTree.summary.filesRunning
67+
const files = filteredFiles.value
68+
return explorerTree.collectTestsTotal(
69+
filtered || filteredByStatus,
70+
onlyTests,
71+
files,
72+
{
73+
failed,
74+
success,
75+
skipped,
76+
running,
77+
},
78+
)
79+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import type { File, TaskResultPack } from '@vitest/runner'
2+
import {
3+
filter,
4+
search,
5+
} from '~/composables/explorer/state'
6+
import type {
7+
CollectorInfo,
8+
FilteredTests,
9+
RootTreeNode,
10+
UITaskTreeNode,
11+
} from '~/composables/explorer/types'
12+
import { collectTestsTotalData, preparePendingTasks, runCollect, runLoadFiles } from '~/composables/explorer/collector'
13+
import { runCollapseAllTask, runCollapseNode } from '~/composables/explorer/collapse'
14+
import { runExpandAll, runExpandNode } from '~/composables/explorer/expand'
15+
import { runFilter } from '~/composables/explorer/filter'
16+
17+
export class ExplorerTree {
18+
private rafCollector: ReturnType<typeof useRafFn>
19+
private resumeEndRunId: ReturnType<typeof setTimeout> | undefined
20+
constructor(
21+
private onTaskUpdateCalled: boolean = false,
22+
private done = new Set<string>(),
23+
private resumeEndTimeout = 500,
24+
public root = <RootTreeNode>{
25+
id: 'vitest-root-node',
26+
expandable: true,
27+
expanded: true,
28+
tasks: [],
29+
},
30+
public pendingTasks = new Map<string, Set<string>>(),
31+
public nodes = new Map<string, UITaskTreeNode>(),
32+
public summary = reactive<CollectorInfo>({
33+
files: 0,
34+
time: '',
35+
filesFailed: 0,
36+
filesSuccess: 0,
37+
filesIgnore: 0,
38+
filesRunning: 0,
39+
filesSkipped: 0,
40+
filesSnapshotFailed: 0,
41+
filesTodo: 0,
42+
testsFailed: 0,
43+
testsSuccess: 0,
44+
testsIgnore: 0,
45+
testsSkipped: 0,
46+
testsTodo: 0,
47+
totalTests: 0,
48+
failedSnapshot: false,
49+
failedSnapshotEnabled: false,
50+
}),
51+
) {
52+
// will run runCollect every ~100ms: 1000/10 = 100ms
53+
// (beware increasing fpsLimit, it can be too much for the browser)
54+
this.rafCollector = useRafFn(this.runCollect.bind(this), { fpsLimit: 10, immediate: false })
55+
}
56+
57+
isUITaskDone(node: UITaskTreeNode) {
58+
return this.done.has(node.id)
59+
}
60+
61+
taskDone(id: string) {
62+
this.done.add(id)
63+
}
64+
65+
removeTaskDone(id: string) {
66+
this.done.delete(id)
67+
}
68+
69+
clearDone() {
70+
this.done.clear()
71+
}
72+
73+
loadFiles(remoteFiles: File[]) {
74+
runLoadFiles(
75+
remoteFiles,
76+
true,
77+
search.value.trim(),
78+
{
79+
failed: filter.failed,
80+
success: filter.success,
81+
skipped: filter.skipped,
82+
onlyTests: filter.onlyTests,
83+
},
84+
)
85+
}
86+
87+
startRun() {
88+
this.resumeEndRunId = setTimeout(() => this.endRun(), this.resumeEndTimeout)
89+
this.collect(true, false)
90+
}
91+
92+
resumeRun(packs: TaskResultPack[]) {
93+
preparePendingTasks(packs)
94+
if (!this.onTaskUpdateCalled) {
95+
clearTimeout(this.resumeEndRunId)
96+
this.onTaskUpdateCalled = true
97+
this.collect(true, false, false)
98+
this.rafCollector.resume()
99+
}
100+
}
101+
102+
endRun() {
103+
this.rafCollector.pause()
104+
this.onTaskUpdateCalled = false
105+
this.collect(false, true)
106+
}
107+
108+
private runCollect() {
109+
this.collect(false, false)
110+
}
111+
112+
private collect(start: boolean, end: boolean, task = true) {
113+
if (task) {
114+
queueMicrotask(() => {
115+
runCollect(
116+
start,
117+
end,
118+
this.summary,
119+
search.value.trim(),
120+
{
121+
failed: filter.failed,
122+
success: filter.success,
123+
skipped: filter.skipped,
124+
onlyTests: filter.onlyTests,
125+
},
126+
)
127+
})
128+
}
129+
else {
130+
runCollect(
131+
start,
132+
end,
133+
this.summary,
134+
search.value.trim(),
135+
{
136+
failed: filter.failed,
137+
success: filter.success,
138+
skipped: filter.skipped,
139+
onlyTests: filter.onlyTests,
140+
},
141+
)
142+
}
143+
}
144+
145+
collectTestsTotal(
146+
filtered: boolean,
147+
onlyTests: boolean,
148+
tests: File[],
149+
filesSummary: FilteredTests,
150+
) {
151+
return collectTestsTotalData(filtered, onlyTests, tests, filesSummary, search.value.trim(), {
152+
failed: filter.failed,
153+
success: filter.success,
154+
skipped: filter.skipped,
155+
onlyTests: filter.onlyTests,
156+
})
157+
}
158+
159+
collapseNode(id: string) {
160+
queueMicrotask(() => {
161+
runCollapseNode(id)
162+
})
163+
}
164+
165+
expandNode(id: string) {
166+
queueMicrotask(() => {
167+
runExpandNode(id, search.value.trim(), {
168+
failed: filter.failed,
169+
success: filter.success,
170+
skipped: filter.skipped,
171+
onlyTests: filter.onlyTests,
172+
})
173+
})
174+
}
175+
176+
collapseAllNodes() {
177+
queueMicrotask(() => {
178+
runCollapseAllTask()
179+
})
180+
}
181+
182+
expandAllNodes() {
183+
queueMicrotask(() => {
184+
runExpandAll(search.value.trim(), {
185+
failed: filter.failed,
186+
success: filter.success,
187+
skipped: filter.skipped,
188+
onlyTests: filter.onlyTests,
189+
})
190+
})
191+
}
192+
193+
filterNodes() {
194+
queueMicrotask(() => {
195+
runFilter(search.value.trim(), {
196+
failed: filter.failed,
197+
success: filter.success,
198+
skipped: filter.skipped,
199+
onlyTests: filter.onlyTests,
200+
})
201+
})
202+
}
203+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { RunMode, TaskState } from '@vitest/runner'
2+
3+
export type FilterResult = [match: boolean, node: UITaskTreeNode]
4+
5+
export interface FilteredTests {
6+
failed: number
7+
success: number
8+
skipped: number
9+
running: number
10+
}
11+
12+
export interface CollectFilteredTests extends FilteredTests {
13+
total: number
14+
ignored: number
15+
todo: number
16+
}
17+
18+
export interface TaskTreeNode {
19+
id: string
20+
expandable: boolean
21+
expanded: boolean
22+
}
23+
24+
export interface RootTreeNode extends TaskTreeNode {
25+
tasks: FileTreeNode[]
26+
}
27+
28+
export type TaskTreeNodeType = 'file' | 'suite' | 'test' | 'custom'
29+
30+
export interface UITaskTreeNode extends TaskTreeNode {
31+
type: TaskTreeNodeType
32+
name: string
33+
parentId: string
34+
mode: RunMode
35+
indent: number
36+
state?: TaskState
37+
duration?: number
38+
}
39+
40+
export interface ParentTreeNode extends UITaskTreeNode {
41+
children: Set<string>
42+
tasks: UITaskTreeNode[]
43+
}
44+
45+
export interface SuiteTreeNode extends ParentTreeNode {
46+
type: 'suite'
47+
typecheck?: boolean
48+
}
49+
50+
export interface FileTreeNode extends ParentTreeNode {
51+
type: 'file'
52+
filepath: string
53+
projectName?: string
54+
projectNameColor: string
55+
collectDuration?: number
56+
setupDuration?: number
57+
environmentLoad?: number
58+
prepareDuration?: number
59+
}
60+
61+
export interface Filter {
62+
failed: boolean
63+
success: boolean
64+
skipped: boolean
65+
onlyTests: boolean
66+
}
67+
68+
export interface TreeFilterState extends Filter {
69+
search: string
70+
expandAll?: boolean
71+
}
72+
73+
export interface CollectorInfo {
74+
files: number
75+
time: string
76+
filesFailed: number
77+
filesSuccess: number
78+
filesIgnore: number
79+
filesRunning: number
80+
filesSkipped: number
81+
filesTodo: number
82+
filesSnapshotFailed: number
83+
testsFailed: number
84+
testsSuccess: number
85+
testsIgnore: number
86+
testsSkipped: number
87+
testsTodo: number
88+
totalTests: number
89+
failedSnapshot: boolean
90+
failedSnapshotEnabled: boolean
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { File, Task } from '@vitest/runner'
2+
import { isAtomTest } from '@vitest/runner/utils'
3+
import type { FileTreeNode, ParentTreeNode, SuiteTreeNode, UITaskTreeNode } from '~/composables/explorer/types'
4+
import { client } from '~/composables/client'
5+
import { getProjectNameColor, isSuite as isTaskSuite } from '~/utils/task'
6+
import { explorerTree } from '~/composables/explorer/index'
7+
import { openedTreeItemsSet } from '~/composables/explorer/state'
8+
9+
export function isTestNode(node: UITaskTreeNode): node is FileTreeNode {
10+
return node.type === 'test' || node.type === 'custom'
11+
}
12+
13+
export function isFileNode(node: UITaskTreeNode): node is FileTreeNode {
14+
return node.type === 'file'
15+
}
16+
17+
export function isSuiteNode(node: UITaskTreeNode): node is SuiteTreeNode {
18+
return node.type === 'suite'
19+
}
20+
21+
export function isParentNode(node: UITaskTreeNode): node is FileTreeNode | SuiteTreeNode {
22+
return node.type === 'file' || node.type === 'suite'
23+
}
24+
25+
export function createOrUpdateFileNode(
26+
file: File,
27+
collect = false,
28+
) {
29+
let fileNode = explorerTree.nodes.get(file.id) as FileTreeNode | undefined
30+
31+
if (fileNode) {
32+
// if (explorerTree.isUITaskDone(fileNode)) {
33+
// return
34+
// }
35+
36+
fileNode.state = file.result?.state
37+
fileNode.mode = file.mode
38+
fileNode.duration = file.result?.duration
39+
fileNode.collectDuration = file.collectDuration
40+
fileNode.setupDuration = file.setupDuration
41+
fileNode.environmentLoad = file.environmentLoad
42+
fileNode.prepareDuration = file.prepareDuration
43+
}
44+
else {
45+
fileNode = {
46+
id: file.id,
47+
parentId: 'root',
48+
name: file.name,
49+
mode: file.mode,
50+
expandable: true,
51+
// When the current run finish, we will expand all nodes when required, here we expand only the opened nodes
52+
expanded: openedTreeItemsSet.value.size > 0 && openedTreeItemsSet.value.has(file.id),
53+
type: 'file',
54+
children: new Set(),
55+
tasks: [],
56+
indent: 0,
57+
duration: file.result?.duration,
58+
filepath: file.filepath,
59+
projectName: file.projectName || '',
60+
projectNameColor: getProjectNameColor(file.projectName),
61+
collectDuration: file.collectDuration,
62+
setupDuration: file.setupDuration,
63+
environmentLoad: file.environmentLoad,
64+
prepareDuration: file.prepareDuration,
65+
state: file.result?.state,
66+
}
67+
explorerTree.nodes.set(file.id, fileNode)
68+
explorerTree.root.tasks.push(fileNode)
69+
}
70+
if (collect) {
71+
for (let i = 0; i < file.tasks.length; i++) {
72+
createOrUpdateNode(file.id, file.tasks[i], true)
73+
}
74+
}
75+
76+
// if (isTaskDone(file)) {
77+
// explorerTree.taskDone(fileNode.id)
78+
// }
79+
}
80+
81+
export function createOrUpdateSuiteTask(
82+
id: string,
83+
all: boolean,
84+
) {
85+
const node = explorerTree.nodes.get(id)
86+
if (!node || !isParentNode(node)/* || explorerTree.isUITaskDone(node) */) {
87+
return
88+
}
89+
90+
const task = client.state.idMap.get(id)
91+
// if no children just return
92+
if (!task || !isTaskSuite(task)) {
93+
return
94+
}
95+
96+
// update the node
97+
createOrUpdateNode(node.parentId, task, all && task.tasks.length > 0)
98+
99+
// if (isTaskDone(task)) {
100+
// explorerTree.taskDone(task.id)
101+
// }
102+
103+
return [node, task] as const
104+
}
105+
106+
export function createOrUpdateNodeTask(id: string) {
107+
const node = explorerTree.nodes.get(id)
108+
if (!node/* || explorerTree.isUITaskDone(node) */) {
109+
return
110+
}
111+
112+
const task = client.state.idMap.get(id)
113+
// if no children just return
114+
if (!task || !isAtomTest(task)) {
115+
return
116+
}
117+
118+
createOrUpdateNode(node.parentId, task, false)
119+
}
120+
121+
export function createOrUpdateNode(
122+
parentId: string,
123+
task: Task,
124+
createAll: boolean,
125+
) {
126+
const node = explorerTree.nodes.get(parentId) as ParentTreeNode | undefined
127+
let taskNode: UITaskTreeNode | undefined
128+
if (node) {
129+
taskNode = explorerTree.nodes.get(task.id)
130+
if (taskNode) {
131+
if (!node.children.has(task.id)) {
132+
node.tasks.push(taskNode)
133+
node.children.add(task.id)
134+
}
135+
/* if (explorerTree.isUITaskDone(taskNode)) {
136+
return
137+
} */
138+
139+
taskNode.mode = task.mode
140+
taskNode.duration = task.result?.duration
141+
taskNode.state = task.result?.state
142+
if (isSuiteNode(taskNode)) {
143+
taskNode.typecheck = !!task.meta && 'typecheck' in task.meta
144+
}
145+
}
146+
else {
147+
if (isAtomTest(task)) {
148+
taskNode = {
149+
id: task.id,
150+
parentId,
151+
name: task.name,
152+
mode: task.mode,
153+
type: task.type,
154+
expandable: false,
155+
expanded: false,
156+
indent: node.indent + 1,
157+
duration: task.result?.duration,
158+
state: task.result?.state,
159+
}
160+
}
161+
else {
162+
taskNode = {
163+
id: task.id,
164+
parentId,
165+
name: task.name,
166+
mode: task.mode,
167+
typecheck: !!task.meta && 'typecheck' in task.meta,
168+
type: 'suite',
169+
expandable: true,
170+
// When the current run finish, we will expand all nodes when required, here we expand only the opened nodes
171+
expanded: openedTreeItemsSet.value.size > 0 && openedTreeItemsSet.value.has(task.id),
172+
children: new Set(),
173+
tasks: [],
174+
indent: node.indent + 1,
175+
duration: task.result?.duration,
176+
state: task.result?.state,
177+
} as SuiteTreeNode
178+
}
179+
explorerTree.nodes.set(task.id, taskNode)
180+
node.tasks.push(taskNode)
181+
node.children.add(task.id)
182+
}
183+
184+
if (taskNode && createAll && isTaskSuite(task)) {
185+
for (let i = 0; i < task.tasks.length; i++) {
186+
createOrUpdateNode(taskNode.id, task.tasks[i], createAll)
187+
}
188+
}
189+
190+
/* if (isTaskDone(task)) {
191+
explorerTree.taskDone(task.id)
192+
} */
193+
}
194+
}

‎packages/ui/client/composables/navigation.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { File } from 'vitest'
2-
import { client, config, findById, testRunState } from './client'
1+
import type { File } from '@vitest/runner'
2+
import { client, config, findById } from './client'
3+
import { testRunState } from './client/state'
34
import { activeFileId } from './params'
45

56
export const currentModule = ref<File>()
@@ -24,10 +25,6 @@ export const detailSizes = useLocalStorage<[left: number, right: number]>(
2425
},
2526
)
2627

27-
export const openedTreeItems = useLocalStorage<string[]>(
28-
'vitest-ui_task-tree-opened',
29-
[],
30-
)
3128
// TODO
3229
// For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
3330
// Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.

‎packages/ui/client/composables/summary.ts

-109
This file was deleted.

‎packages/ui/client/utils/task.ts

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ export function isSuite(task: Task): task is Suite {
44
return Object.hasOwnProperty.call(task, 'tasks')
55
}
66

7+
export function isTaskDone(task: Task) {
8+
const state = task.result?.state
9+
const mode = task.mode
10+
11+
return state === 'pass' || state === 'fail' || state === 'skip' || mode === 'skip' || mode === 'todo'
12+
}
13+
714
export function caseInsensitiveMatch(target: string, str2: string) {
815
if (typeof target !== 'string' || typeof str2 !== 'string') {
916
return false

‎packages/ui/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
"flatted": "^3.3.1",
5454
"pathe": "^1.1.2",
5555
"picocolors": "^1.0.1",
56-
"sirv": "^2.0.4"
56+
"sirv": "^2.0.4",
57+
"vue-virtual-scroller": "2.0.0-beta.8"
5758
},
5859
"devDependencies": {
5960
"@faker-js/faker": "^8.4.1",

‎packages/ui/vite.config.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import Components from 'unplugin-vue-components/vite'
66
import AutoImport from 'unplugin-auto-import/vite'
77
import Unocss from 'unocss/vite'
88
import Pages from 'vite-plugin-pages'
9-
import { presetAttributify, presetIcons, presetUno } from 'unocss'
9+
import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss'
1010

1111
// for debug:
1212
// open a static file serve to share the report json
@@ -42,10 +42,15 @@ export const config: UserConfig = {
4242
'bg-active': 'bg-gray-500:8',
4343
'bg-hover': 'bg-gray-500:20',
4444
'border-base': 'border-gray-500:10',
45+
'focus-base': 'border-gray-500 dark:border-gray-400',
46+
'highlight': 'bg-[#eab306] text-[#323238] dark:bg-[#323238] dark:text-[#eab306]',
4547

4648
'tab-button': 'font-light op50 hover:op80 h-full px-4',
4749
'tab-button-active': 'op100 bg-gray-500:10',
4850
},
51+
transformers: [
52+
transformerDirectives(),
53+
],
4954
}),
5055
Components({
5156
dirs: ['client/components'],

‎pnpm-lock.yaml

+26-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/browser/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"test-mocking": "vitest --root ./fixtures/mocking",
1313
"test-snapshots": "vitest --root ./fixtures/update-snapshot",
1414
"coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes",
15+
"test:browser:preview": "PROVIDER=preview vitest",
1516
"test:browser:playwright": "PROVIDER=playwright vitest",
1617
"test:browser:webdriverio": "PROVIDER=webdriverio vitest"
1718
},

‎test/core/test/tab-effect.spec.mjs

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable eslint-comments/no-unlimited-disable */
21
/* eslint-disable */
32
import { expect, test, vi } from 'vitest'
43
import { join as joinPath } from 'node:path'

‎test/ui/test/ui.spec.ts

+15
Original file line numberDiff line numberDiff line change
@@ -107,5 +107,20 @@ test.describe('ui', () => {
107107
await page.getByPlaceholder('Search...').fill('add')
108108
await page.getByText('PASS (1)').click()
109109
await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeVisible()
110+
111+
// match only failing files when fail filter applied
112+
await page.getByPlaceholder('Search...').fill('')
113+
await page.getByText(/^Fail$/, { exact: true }).click()
114+
await page.getByText('FAIL (1)').click()
115+
await expect(page.getByTestId('details-panel').getByText('fixtures/error.test.ts', { exact: true })).toBeVisible()
116+
await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeHidden()
117+
118+
// match only pass files when fail filter applied
119+
await page.getByPlaceholder('Search...').fill('console')
120+
await page.getByText(/^Fail$/, { exact: true }).click()
121+
await page.locator('span').filter({ hasText: /^Pass$/ }).click()
122+
await page.getByText('PASS (1)').click()
123+
await expect(page.getByTestId('details-panel').getByText('fixtures/console.test.ts', { exact: true })).toBeVisible()
124+
await expect(page.getByTestId('details-panel').getByText('fixtures/sample.test.ts', { exact: true })).toBeHidden()
110125
})
111126
})

0 commit comments

Comments
 (0)
Please sign in to comment.