[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): replace navigation tree with test explorer (#5907)

authored by

Joaquín Sánchez and committed by
GitHub
(Jun 20, 2024, 2:24 PM +0200) 45dfc95a 486fd116

+2221 -733
+26 -1
pnpm-lock.yaml
··· 713 713 vitest: 714 714 specifier: workspace:* 715 715 version: link:../vitest 716 + vue-virtual-scroller: 717 + specifier: 2.0.0-beta.8 718 + version: 2.0.0-beta.8(vue@3.4.29) 716 719 devDependencies: 717 720 '@faker-js/faker': 718 721 specifier: ^8.4.1 ··· 12413 12416 yallist: 4.0.0 12414 12417 dev: true 12415 12418 12419 + /mitt@2.1.0: 12420 + resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==} 12421 + dev: false 12422 + 12416 12423 /mitt@3.0.0: 12417 12424 resolution: {integrity: sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==} 12418 12425 dev: true ··· 15982 15989 - supports-color 15983 15990 dev: true 15984 15991 15992 + /vue-observe-visibility@2.0.0-alpha.1(vue@3.4.29): 15993 + resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==} 15994 + peerDependencies: 15995 + vue: ^3.0.0 15996 + dependencies: 15997 + vue: 3.4.29(typescript@5.4.5) 15998 + dev: false 15999 + 15985 16000 /vue-resize@2.0.0-alpha.1(vue@3.4.29): 15986 16001 resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} 15987 16002 peerDependencies: 15988 16003 vue: ^3.0.0 15989 16004 dependencies: 15990 16005 vue: 3.4.29(typescript@5.4.5) 15991 - dev: true 15992 16006 15993 16007 /vue-router@4.3.3(vue@3.4.29): 15994 16008 resolution: {integrity: sha512-8Q+u+WP4N2SXY38FDcF2H1dUEbYVHVPtPCPZj/GTZx8RCbiB8AtJP9+YIxn4Vs0svMTNQcLIzka4GH7Utkx9xQ==} ··· 16018 16032 semver: 7.5.4 16019 16033 typescript: 5.2.2 16020 16034 dev: true 16035 + 16036 + /vue-virtual-scroller@2.0.0-beta.8(vue@3.4.29): 16037 + resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==} 16038 + peerDependencies: 16039 + vue: ^3.2.0 16040 + dependencies: 16041 + mitt: 2.1.0 16042 + vue: 3.4.29(typescript@5.4.5) 16043 + vue-observe-visibility: 2.0.0-alpha.1(vue@3.4.29) 16044 + vue-resize: 2.0.0-alpha.1(vue@3.4.29) 16045 + dev: false 16021 16046 16022 16047 /vue@2.7.10: 16023 16048 resolution: {integrity: sha512-HmFC70qarSHPXcKtW8U8fgIkF6JGvjEmDiVInTkKZP0gIlEPhlVlcJJLkdGIDiNkIeA2zJPQTWJUI4iWe+AVfg==}
+2 -1
packages/ui/package.json
··· 53 53 "flatted": "^3.3.1", 54 54 "pathe": "^1.1.2", 55 55 "picocolors": "^1.0.1", 56 - "sirv": "^2.0.4" 56 + "sirv": "^2.0.4", 57 + "vue-virtual-scroller": "2.0.0-beta.8" 57 58 }, 58 59 "devDependencies": { 59 60 "@faker-js/faker": "^8.4.1",
+6 -1
packages/ui/vite.config.ts
··· 6 6 import AutoImport from 'unplugin-auto-import/vite' 7 7 import Unocss from 'unocss/vite' 8 8 import Pages from 'vite-plugin-pages' 9 - import { presetAttributify, presetIcons, presetUno } from 'unocss' 9 + import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss' 10 10 11 11 // for debug: 12 12 // open a static file serve to share the report json ··· 42 42 'bg-active': 'bg-gray-500:8', 43 43 'bg-hover': 'bg-gray-500:20', 44 44 '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]', 45 47 46 48 'tab-button': 'font-light op50 hover:op80 h-full px-4', 47 49 'tab-button-active': 'op100 bg-gray-500:10', 48 50 }, 51 + transformers: [ 52 + transformerDirectives(), 53 + ], 49 54 }), 50 55 Components({ 51 56 dirs: ['client/components'],
+1
test/browser/package.json
··· 12 12 "test-mocking": "vitest --root ./fixtures/mocking", 13 13 "test-snapshots": "vitest --root ./fixtures/update-snapshot", 14 14 "coverage": "vitest --coverage.enabled --coverage.provider=istanbul --browser.headless=yes", 15 + "test:browser:preview": "PROVIDER=preview vitest", 15 16 "test:browser:playwright": "PROVIDER=playwright vitest", 16 17 "test:browser:webdriverio": "PROVIDER=webdriverio vitest" 17 18 },
-16
packages/ui/client/auto-imports.d.ts
··· 46 46 const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] 47 47 const effectScope: typeof import('vue')['effectScope'] 48 48 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'] 57 49 const getCurrentBrowserIframe: typeof import('./composables/api')['getCurrentBrowserIframe'] 58 50 const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 59 51 const getCurrentScope: typeof import('vue')['getCurrentScope'] ··· 128 120 const syncRef: typeof import('@vueuse/core')['syncRef'] 129 121 const syncRefs: typeof import('@vueuse/core')['syncRefs'] 130 122 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'] 137 123 const throttledRef: typeof import('@vueuse/core')['throttledRef'] 138 124 const throttledWatch: typeof import('@vueuse/core')['throttledWatch'] 139 - const time: typeof import('./composables/summary')['time'] 140 125 const toRaw: typeof import('vue')['toRaw'] 141 126 const toReactive: typeof import('@vueuse/core')['toReactive'] 142 127 const toRef: typeof import('vue')['toRef'] 143 128 const toRefs: typeof import('vue')['toRefs'] 144 129 const toValue: typeof import('vue')['toValue'] 145 130 const toggleDark: typeof import('./composables/dark')['toggleDark'] 146 - const totalTests: typeof import('./composables/summary')['totalTests'] 147 131 const triggerRef: typeof import('vue')['triggerRef'] 148 132 const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] 149 133 const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
+3 -3
packages/ui/client/components.d.ts
··· 15 15 DashboardEntry: typeof import('./components/dashboard/DashboardEntry.vue')['default'] 16 16 DetailsPanel: typeof import('./components/DetailsPanel.vue')['default'] 17 17 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'] 18 20 FileDetails: typeof import('./components/FileDetails.vue')['default'] 21 + FilterStatus: typeof import('./components/FilterStatus.vue')['default'] 19 22 IconAction: typeof import('./components/IconAction.vue')['default'] 20 23 IconButton: typeof import('./components/IconButton.vue')['default'] 21 24 Modal: typeof import('./components/Modal.vue')['default'] ··· 25 28 RouterLink: typeof import('vue-router')['RouterLink'] 26 29 RouterView: typeof import('vue-router')['RouterView'] 27 30 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'] 31 31 TestFilesEntry: typeof import('./components/dashboard/TestFilesEntry.vue')['default'] 32 32 TestsEntry: typeof import('./components/dashboard/TestsEntry.vue')['default'] 33 33 TestsFilesContainer: typeof import('./components/dashboard/TestsFilesContainer.vue')['default']
-1
test/core/test/tab-effect.spec.mjs
··· 1 - /* eslint-disable eslint-comments/no-unlimited-disable */ 2 1 /* eslint-disable */ 3 2 import { expect, test, vi } from 'vitest' 4 3 import { join as joinPath } from 'node:path'
+15
test/ui/test/ui.spec.ts
··· 107 107 await page.getByPlaceholder('Search...').fill('add') 108 108 await page.getByText('PASS (1)').click() 109 109 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() 110 125 }) 111 126 })
+33
packages/ui/client/components/FilterStatus.vue
··· 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>
+1 -1
packages/ui/client/components/IconButton.vue
··· 18 18 class="w-1.4em h-1.4em flex" :class="[{ 'bg-gray-500:35 op100': active }]" 19 19 > 20 20 <slot> 21 - <div :class="icon" ma /> 21 + <span :class="icon" ma block /> 22 22 </slot> 23 23 </button> 24 24 </template>
+26 -41
packages/ui/client/components/Navigation.vue
··· 1 1 <script setup lang="ts"> 2 - import { hasFailedSnapshot } from '@vitest/ws-client' 3 2 import { Tooltip as VueTooltip } from 'floating-vue' 4 3 import type { File, Task } from 'vitest' 5 4 import { ··· 9 8 currentModule, 10 9 dashboardVisible, 11 10 disableCoverage, 12 - openedTreeItems, 13 11 showCoverage, 14 12 showDashboard, 15 13 } from '~/composables/navigation' 16 - import { client, files, findById, isReport, runAll } from '~/composables/client' 14 + import { client, findById, isReport, runAll, runFiles } from '~/composables/client' 17 15 import { isDark, toggleDark } from '~/composables' 18 16 import { activeFileId } from '~/composables/params' 17 + import { explorerTree } from '~/composables/explorer' 18 + import { initialized, shouldShowExpandAll } from '~/composables/explorer/state' 19 19 20 - const failedSnapshot = computed( 21 - () => files.value && hasFailedSnapshot(files.value), 22 - ) 23 20 function updateSnapshot() { 24 21 return client.rpc.updateSnapshot() 25 22 } 26 23 27 - const toggleMode = computed(() => (isDark.value ? 'light' : 'dark')) 24 + const toggleMode = computed(() => isDark.value ? 'light' : 'dark') 28 25 29 26 function onItemClick(task: Task) { 30 27 activeFileId.value = task.file.id ··· 41 38 await nextTick() 42 39 } 43 40 } 44 - await runAll(files) 41 + if (files?.length) { 42 + await runFiles(files) 43 + } 44 + else { 45 + await runAll() 46 + } 45 47 } 46 48 47 49 function collapseTests() { 48 - openedTreeItems.value = [] 50 + explorerTree.collapseAllNodes() 49 51 } 50 52 51 53 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() 57 55 } 58 56 </script> 59 57 60 58 <template> 61 59 <!-- 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 }"> 71 62 <img w-6 h-6 src="/favicon.svg" alt="Vitest logo"> 72 63 <span font-light text-sm flex-1>Vitest</span> 73 64 <div class="flex text-lg"> 74 65 <IconButton 75 - v-show="openedTreeItems.length > 0" 66 + v-show="!shouldShowExpandAll" 76 67 v-tooltip.bottom="'Collapse tests'" 77 68 title="Collapse tests" 69 + :disabled="!initialized" 78 70 icon="i-carbon:collapse-all" 79 71 @click="collapseTests()" 80 72 /> 81 73 <IconButton 82 - v-show="openedTreeItems.length === 0" 74 + v-show="shouldShowExpandAll" 83 75 v-tooltip.bottom="'Expand tests'" 76 + :disabled="!initialized" 84 77 title="Expand tests" 85 78 icon="i-carbon:expand-all" 86 79 @click="expandTests()" ··· 101 94 > 102 95 <div class="i-carbon:folder-off ma" /> 103 96 <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]"> 108 98 <div class="i-carbon:information-square w-1.5em h-1.5em" /> 109 99 <div>Coverage enabled but missing html reporter.</div> 110 100 <div style="grid-column: 2"> ··· 125 115 @click="showCoverage()" 126 116 /> 127 117 <IconButton 128 - v-if="failedSnapshot && !isReport" 118 + v-if="(explorerTree.summary.failedSnapshot && !isReport)" 129 119 v-tooltip.bottom="'Update all failed snapshot(s)'" 130 120 icon="i-carbon:result-old" 131 - @click="updateSnapshot()" 121 + :disabled="!explorerTree.summary.failedSnapshotEnabled" 122 + @click="explorerTree.summary.failedSnapshotEnabled && updateSnapshot()" 132 123 /> 133 124 <IconButton 134 125 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" 143 128 icon="i-carbon:play" 144 - @click="onRunAll(filteredTests)" 129 + @click="onRunAll(filteredFiles)" 145 130 /> 146 131 <IconButton 147 132 v-tooltip.bottom="`Toggle to ${toggleMode} mode`" ··· 150 135 /> 151 136 </div> 152 137 </template> 153 - </TasksList> 138 + </Explorer> 154 139 </template>
+12 -15
packages/ui/client/components/ProgressBar.vue
··· 1 1 <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' 4 4 5 5 const { width } = useWindowSize() 6 6 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) { 9 9 return '!bg-gray-4 !dark:bg-gray-7 in-progress' 10 10 } 11 11 else if (!finished.value) { ··· 14 14 15 15 return null 16 16 }) 17 - const total = computed(() => files.value.length) 18 - const pass = computed(() => filesSuccess.value.length) 19 - const failed = computed(() => filesFailed.value.length) 20 17 21 18 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 24 21 }) 25 22 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 28 25 }) 29 26 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 32 29 }) 33 30 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 36 33 }) 37 34 </script> 38 35
-120
packages/ui/client/components/TaskItem.vue
··· 1 - <script setup lang="ts"> 2 - import type { Task } from 'vitest' 3 - import { getProjectNameColor } from '~/utils/task' 4 - import { activeFileId } from '~/composables/params' 5 - import { isReport } from '~/constants' 6 - 7 - const props = defineProps<{ 8 - task: Task 9 - opened: boolean 10 - failedSnapshot: boolean 11 - }>() 12 - 13 - const emit = defineEmits<{ 14 - run: [] 15 - preview: [] 16 - fixSnapshot: [] 17 - }>() 18 - 19 - const duration = computed(() => { 20 - const { result } = props.task 21 - return result && Math.round(result.duration || 0) 22 - }) 23 - </script> 24 - 25 - <template> 26 - <div 27 - v-if="task" 28 - flex="~ row" 29 - items-center 30 - p="x-2 y-1" 31 - border-rounded 32 - cursor-pointer 33 - hover="bg-active" 34 - class="item-wrapper" 35 - :aria-label="task.name" 36 - :data-current="activeFileId === task.id" 37 - > 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> 42 - <StatusIcon :task="task" mr-2 /> 43 - <div 44 - v-if="task.type === 'suite' && task.meta.typecheck" 45 - i-logos:typescript-icon 46 - flex-shrink-0 47 - mr-2 48 - /> 49 - <div 50 - flex 51 - items-end 52 - gap-2 53 - :text="task?.result?.state === 'fail' ? 'red-500' : ''" 54 - overflow-hidden 55 - > 56 - <span text-sm truncate font-light> 57 - <!-- only show [] in files view --> 58 - <span 59 - v-if="'filepath' in task && task.projectName" 60 - :style="{ color: getProjectNameColor(task.file.projectName) }" 61 - > 62 - [{{ task.file.projectName }}] 63 - </span> 64 - {{ task.name }} 65 - </span> 66 - <span 67 - v-if="typeof duration === 'number'" 68 - text="xs" 69 - op20 70 - style="white-space: nowrap" 71 - > 72 - {{ duration > 0 ? duration : "< 1" }}ms 73 - </span> 74 - </div> 75 - <div 76 - v-if="task.type === 'suite' && 'filepath' in task" 77 - gap-1 78 - justify-end 79 - flex-grow-1 80 - pl-1 81 - class="test-actions" 82 - > 83 - <IconAction 84 - v-if="!isReport && failedSnapshot" 85 - v-tooltip.bottom="'Fix failed snapshot(s)'" 86 - data-testid="btn-fix-snapshot" 87 - title="Fix failed snapshot(s)" 88 - icon="i-carbon-result-old" 89 - @click.prevent.stop="emit('fixSnapshot')" 90 - /> 91 - <IconAction 92 - v-tooltip.bottom="'Open test details'" 93 - data-testid="btn-open-details" 94 - title="Open test details" 95 - icon="i-carbon-intrusion-prevention" 96 - @click.prevent.stop="emit('preview')" 97 - /> 98 - <IconAction 99 - v-if="!isReport" 100 - v-tooltip.bottom="'Run current test'" 101 - data-testid="btn-run-test" 102 - title="Run current test" 103 - icon="i-carbon:play-filled-alt" 104 - text-green5 105 - @click.prevent.stop="emit('run')" 106 - /> 107 - </div> 108 - </div> 109 - </template> 110 - 111 - <style scoped> 112 - .test-actions { 113 - display: none; 114 - } 115 - 116 - .item-wrapper:hover .test-actions, 117 - .item-wrapper[data-current="true"] .test-actions { 118 - display: flex; 119 - } 120 - </style>
-94
packages/ui/client/components/TaskTree.vue
··· 1 - <script setup lang="ts"> 2 - import type { Task } from 'vitest' 3 - import { nextTick } from 'vue' 4 - import { client, runFiles } from '~/composables/client' 5 - import { caseInsensitiveMatch } from '~/utils/task' 6 - import { coverageEnabled, openedTreeItems } from '~/composables/navigation' 7 - 8 - defineOptions({ inheritAttrs: false }) 9 - 10 - // TODO: better handling of "opened" - it means to forcefully open the tree item and set in TasksList right now 11 - const { 12 - task, 13 - indent = 0, 14 - nested = false, 15 - search, 16 - onItemClick, 17 - opened = false, 18 - } = defineProps<{ 19 - task: Task 20 - failedSnapshot: boolean 21 - indent?: number 22 - opened?: boolean 23 - nested?: boolean 24 - search?: string 25 - onItemClick?: (task: Task) => void 26 - }>() 27 - 28 - const isOpened = computed( 29 - () => opened || openedTreeItems.value.includes(task.id), 30 - ) 31 - 32 - function toggleOpen() { 33 - if (isOpened.value) { 34 - const tasksIds = 'tasks' in task ? task.tasks.map(t => t.id) : [] 35 - openedTreeItems.value = openedTreeItems.value.filter( 36 - id => id !== task.id && !tasksIds.includes(id), 37 - ) 38 - } 39 - else { 40 - openedTreeItems.value = [...openedTreeItems.value, task.id] 41 - } 42 - } 43 - 44 - async function onRun() { 45 - onItemClick?.(task) 46 - if (coverageEnabled.value) { 47 - disableCoverage.value = true 48 - await nextTick() 49 - } 50 - await runFiles([task.file]) 51 - } 52 - 53 - function updateSnapshot() { 54 - return client.rpc.updateSnapshot(task.file) 55 - } 56 - </script> 57 - 58 - <template> 59 - <!-- maybe provide a KEEP STRUCTURE mode, do not filter by search keyword --> 60 - <!-- v-if = keepStructure || (!search || caseInsensitiveMatch(task.name, search)) --> 61 - <TaskItem 62 - v-if=" 63 - opened || !nested || !search || caseInsensitiveMatch(task.name, search) 64 - " 65 - v-bind="$attrs" 66 - :task="task" 67 - :style="{ 68 - paddingLeft: indent 69 - ? `${indent * 0.75 + (task.type === 'suite' ? 0.5 : 1.75)}rem` 70 - : '1rem', 71 - }" 72 - :opened="isOpened && task.type === 'suite' && task.tasks.length" 73 - :failed-snapshot="failedSnapshot" 74 - @click="toggleOpen()" 75 - @run="onRun()" 76 - @fix-snapshot="updateSnapshot()" 77 - @preview="onItemClick?.(task)" 78 - /> 79 - <div 80 - v-if="nested && task.type === 'suite' && task.tasks.length" 81 - v-show="isOpened" 82 - > 83 - <TaskTree 84 - v-for="suite in task.tasks" 85 - :key="suite.id" 86 - :failed-snapshot="false" 87 - :task="suite" 88 - :nested="nested" 89 - :indent="indent + 1" 90 - :search="search" 91 - :on-item-click="onItemClick" 92 - /> 93 - </div> 94 - </template>
-243
packages/ui/client/components/TasksList.vue
··· 1 - <script setup lang="ts"> 2 - import type { ComputedRef } from 'vue' 3 - import type { File, Task } from 'vitest' 4 - import { hasFailedSnapshot } from '@vitest/ws-client' 5 - import { findById } from '~/composables/client' 6 - import { activeFileId } from '~/composables/params' 7 - import { caseInsensitiveMatch, isSuite } from '~/utils/task' 8 - 9 - defineOptions({ inheritAttrs: false }) 10 - 11 - const { 12 - tasks, 13 - // indent = 0, 14 - nested = false, 15 - groupByType = false, 16 - onItemClick, 17 - } = defineProps<{ 18 - tasks: Task[] 19 - indent?: number 20 - nested?: boolean 21 - groupByType?: boolean 22 - onItemClick?: (task: Task) => void 23 - }>() 24 - 25 - const emit = defineEmits<{ 26 - (event: 'run', files?: File[]): void 27 - }>() 28 - 29 - const search = ref<string>('') 30 - const searchBox = ref<HTMLInputElement | undefined>() 31 - const isFiltered = computed(() => search.value.trim() !== '') 32 - 33 - const filtered = computed(() => { 34 - if (!search.value.trim()) { 35 - return tasks 36 - } 37 - 38 - return tasks.filter(task => matchTasks([task], search.value)) 39 - }) 40 - const filteredTests: ComputedRef<File[]> = computed(() => 41 - isFiltered.value 42 - ? filtered.value.map(task => findById(task.id)!).filter(Boolean) 43 - : [], 44 - ) 45 - 46 - const failed = computed(() => 47 - filtered.value.filter(task => task.result?.state === 'fail'), 48 - ) 49 - const success = computed(() => 50 - filtered.value.filter(task => task.result?.state === 'pass'), 51 - ) 52 - const skipped = computed(() => 53 - filtered.value.filter(task => task.mode === 'skip' || task.mode === 'todo'), 54 - ) 55 - const running = computed(() => 56 - filtered.value.filter( 57 - task => 58 - !failed.value.includes(task) 59 - && !success.value.includes(task) 60 - && !skipped.value.includes(task), 61 - ), 62 - ) 63 - 64 - const disableClearSearch = computed(() => search.value === '') 65 - 66 - const throttledRunning = useThrottle(running, 250) 67 - 68 - function clearSearch(focus: boolean) { 69 - search.value = '' 70 - focus && searchBox.value?.focus() 71 - } 72 - 73 - function matchTasks(tasks: Task[], search: string): boolean { 74 - let result = false 75 - 76 - for (let i = 0; i < tasks.length; i++) { 77 - const task = tasks[i] 78 - 79 - if (caseInsensitiveMatch(task.name, search)) { 80 - result = true 81 - break 82 - } 83 - 84 - // walk whole task tree 85 - if (isSuite(task) && task.tasks) { 86 - result = matchTasks(task.tasks, search) 87 - if (result) { 88 - break 89 - } 90 - } 91 - } 92 - 93 - return result 94 - } 95 - </script> 96 - 97 - <template> 98 - <div h="full" flex="~ col"> 99 - <div> 100 - <div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base"> 101 - <slot 102 - name="header" 103 - :filtered-tests="isFiltered ? filteredTests : undefined" 104 - /> 105 - </div> 106 - <div p="l3 y2 r2" flex="~ gap-2" items-center bg-header border="b-2 base"> 107 - <div class="i-carbon:search" flex-shrink-0 /> 108 - <input 109 - ref="searchBox" 110 - v-model="search" 111 - placeholder="Search..." 112 - outline="none" 113 - bg="transparent" 114 - font="light" 115 - text="sm" 116 - flex-1 117 - pl-1 118 - :op="search.length ? '100' : '50'" 119 - @keydown.esc="clearSearch(false)" 120 - @keydown.enter="emit('run', isFiltered ? filteredTests : undefined)" 121 - > 122 - <IconButton 123 - v-tooltip.bottom="'Clear search'" 124 - :disabled="disableClearSearch" 125 - title="Clear search" 126 - icon="i-carbon:filter-remove" 127 - @click.passive="clearSearch(true)" 128 - /> 129 - </div> 130 - </div> 131 - 132 - <div class="scrolls" flex-auto py-1> 133 - <template v-if="groupByType"> 134 - <DetailsPanel v-if="failed.length"> 135 - <template #summary> 136 - <div text-red5> 137 - FAIL ({{ failed.length }}) 138 - </div> 139 - </template> 140 - <TaskTree 141 - v-for="task in failed" 142 - :key="task.id" 143 - :task="task" 144 - :nested="nested" 145 - :search="search" 146 - :opened="isFiltered" 147 - :failed-snapshot="hasFailedSnapshot(task)" 148 - :class="activeFileId === task.id ? 'bg-active' : ''" 149 - :on-item-click="onItemClick" 150 - /> 151 - </DetailsPanel> 152 - <DetailsPanel v-if="throttledRunning.length"> 153 - <template #summary> 154 - <div text-yellow5> 155 - RUNNING ({{ throttledRunning.length }}) 156 - </div> 157 - </template> 158 - <TaskTree 159 - v-for="task in throttledRunning" 160 - :key="task.id" 161 - :task="task" 162 - :nested="nested" 163 - :search="search" 164 - :opened="isFiltered" 165 - :failed-snapshot="hasFailedSnapshot(task)" 166 - :class="activeFileId === task.id ? 'bg-active' : ''" 167 - :on-item-click="onItemClick" 168 - /> 169 - </DetailsPanel> 170 - <DetailsPanel v-if="success.length"> 171 - <template #summary> 172 - <div text-green5> 173 - PASS ({{ success.length }}) 174 - </div> 175 - </template> 176 - <TaskTree 177 - v-for="task in success" 178 - :key="task.id" 179 - :task="task" 180 - :nested="nested" 181 - :opened="isFiltered" 182 - :failed-snapshot="hasFailedSnapshot(task)" 183 - :search="search" 184 - :class="activeFileId === task.id ? 'bg-active' : ''" 185 - :on-item-click="onItemClick" 186 - /> 187 - </DetailsPanel> 188 - <DetailsPanel v-if="skipped.length"> 189 - <template #summary> 190 - <div class="text-purple5:50"> 191 - SKIP ({{ skipped.length }}) 192 - </div> 193 - </template> 194 - <TaskTree 195 - v-for="task in skipped" 196 - :key="task.id" 197 - :task="task" 198 - :opened="isFiltered" 199 - :nested="nested" 200 - :failed-snapshot="hasFailedSnapshot(task)" 201 - :search="search" 202 - :class="activeFileId === task.id ? 'bg-active' : ''" 203 - :on-item-click="onItemClick" 204 - /> 205 - </DetailsPanel> 206 - </template> 207 - 208 - <!-- flat --> 209 - <template v-else> 210 - <TaskTree 211 - v-for="task in filtered" 212 - :key="task.id" 213 - :task="task" 214 - :nested="nested" 215 - :opened="isFiltered" 216 - :failed-snapshot="hasFailedSnapshot(task)" 217 - :search="search" 218 - :class="activeFileId === task.id ? 'bg-active' : ''" 219 - :on-item-click="onItemClick" 220 - /> 221 - </template> 222 - <!-- empty-state --> 223 - <template v-if="isFiltered && filtered.length === 0"> 224 - <div flex="~ col" items-center p="x4 y4" font-light> 225 - <div op30> 226 - No matched test 227 - </div> 228 - <button 229 - font-light 230 - op="50 hover:100" 231 - text-sm 232 - border="~ gray-400/50 rounded" 233 - p="x2 y0.5" 234 - m="t2" 235 - @click.passive="clearSearch(true)" 236 - > 237 - Clear 238 - </button> 239 - </div> 240 - </template> 241 - </div> 242 - </div> 243 - </template>
+3 -6
packages/ui/client/composables/navigation.ts
··· 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' 3 4 import { activeFileId } from './params' 4 5 5 6 export const currentModule = ref<File>() ··· 24 25 }, 25 26 ) 26 27 27 - export const openedTreeItems = useLocalStorage<string[]>( 28 - 'vitest-ui_task-tree-opened', 29 - [], 30 - ) 31 28 // TODO 32 29 // For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report. 33 30 // Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.
-109
packages/ui/client/composables/summary.ts
··· 1 - import { hasFailedSnapshot } from '@vitest/ws-client' 2 - import type { Custom, Task, Test } from 'vitest' 3 - import { files, testRunState } from '~/composables/client' 4 - 5 - type Nullable<T> = T | null | undefined 6 - type Arrayable<T> = T | Array<T> 7 - 8 - // files 9 - export const filesFailed = computed(() => 10 - files.value.filter(f => f.result?.state === 'fail'), 11 - ) 12 - export const filesSuccess = computed(() => 13 - files.value.filter(f => f.result?.state === 'pass'), 14 - ) 15 - export const filesIgnore = computed(() => 16 - files.value.filter(f => f.mode === 'skip' || f.mode === 'todo'), 17 - ) 18 - export const filesRunning = computed(() => 19 - files.value.filter( 20 - f => 21 - !filesFailed.value.includes(f) 22 - && !filesSuccess.value.includes(f) 23 - && !filesIgnore.value.includes(f), 24 - ), 25 - ) 26 - export const filesSkipped = computed(() => 27 - filesIgnore.value.filter(f => f.mode === 'skip'), 28 - ) 29 - export const filesSnapshotFailed = computed(() => 30 - files.value.filter(hasFailedSnapshot), 31 - ) 32 - export const filesTodo = computed(() => 33 - filesIgnore.value.filter(f => f.mode === 'todo'), 34 - ) 35 - export const finished = computed(() => testRunState.value === 'idle') 36 - // tests 37 - export const tests = computed(() => { 38 - return getTests(files.value) 39 - }) 40 - export const testsFailed = computed(() => { 41 - return tests.value.filter(f => f.result?.state === 'fail') 42 - }) 43 - export const testsSuccess = computed(() => { 44 - return tests.value.filter(f => f.result?.state === 'pass') 45 - }) 46 - export const testsIgnore = computed(() => 47 - tests.value.filter(f => f.mode === 'skip' || f.mode === 'todo'), 48 - ) 49 - export const testsSkipped = computed(() => 50 - testsIgnore.value.filter(f => f.mode === 'skip'), 51 - ) 52 - export const testsTodo = computed(() => 53 - testsIgnore.value.filter(f => f.mode === 'todo'), 54 - ) 55 - export const totalTests = computed( 56 - () => testsFailed.value.length + testsSuccess.value.length, 57 - ) 58 - export const time = computed(() => { 59 - const t = files.value.reduce((acc, t) => { 60 - acc += Math.max(0, t.collectDuration || 0) 61 - acc += Math.max(0, t.setupDuration || 0) 62 - acc += Math.max(0, t.result?.duration || 0) 63 - acc += Math.max(0, t.environmentLoad || 0) 64 - acc += Math.max(0, t.prepareDuration || 0) 65 - return acc 66 - }, 0) 67 - 68 - if (t > 1000) { 69 - return `${(t / 1000).toFixed(2)}s` 70 - } 71 - 72 - return `${Math.round(t)}ms` 73 - }) 74 - 75 - function toArray<T>(array?: Nullable<Arrayable<T>>): Array<T> { 76 - array = array || [] 77 - if (Array.isArray(array)) { 78 - return array 79 - } 80 - return [array] 81 - } 82 - 83 - function isAtomTest(s: Task): s is Test | Custom { 84 - return s.type === 'test' || s.type === 'custom' 85 - } 86 - 87 - function getTests(suite: Arrayable<Task>): (Test | Custom)[] { 88 - const tests: (Test | Custom)[] = [] 89 - const arraySuites = toArray(suite) 90 - for (const s of arraySuites) { 91 - if (isAtomTest(s)) { 92 - tests.push(s) 93 - } 94 - else { 95 - for (const task of s.tasks) { 96 - if (isAtomTest(task)) { 97 - tests.push(task) 98 - } 99 - else { 100 - const taskTests = getTests(task) 101 - for (const test of taskTests) { 102 - tests.push(test) 103 - } 104 - } 105 - } 106 - } 107 - } 108 - return tests 109 - }
+7
packages/ui/client/utils/task.ts
··· 4 4 return Object.hasOwnProperty.call(task, 'tasks') 5 5 } 6 6 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 + 7 14 export function caseInsensitiveMatch(target: string, str2: string) { 8 15 if (typeof target !== 'string' || typeof str2 !== 'string') { 9 16 return false
+21 -24
packages/ui/client/components/dashboard/TestFilesEntry.vue
··· 1 1 <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' 9 4 </script> 10 5 11 6 <template> ··· 23 18 <div i-carbon-document /> 24 19 <div>Files</div> 25 20 <div class="number" data-testid="num-files"> 26 - {{ files.length }} 21 + {{ explorerTree.summary.files }} 27 22 </div> 28 23 29 - <template v-if="filesSuccess.length"> 24 + <template v-if="explorerTree.summary.filesSuccess"> 30 25 <div i-carbon-checkmark /> 31 26 <div>Pass</div> 32 27 <div class="number"> 33 - {{ filesSuccess.length }} 28 + {{ explorerTree.summary.filesSuccess }} 34 29 </div> 35 30 </template> 36 31 37 - <template v-if="filesFailed.length"> 32 + <template v-if="explorerTree.summary.filesFailed"> 38 33 <div i-carbon-close /> 39 - <div>Fail</div> 34 + <div> 35 + Fail 36 + </div> 40 37 <div class="number" text-red5> 41 - {{ filesFailed.length }} 38 + {{ explorerTree.summary.filesFailed }} 42 39 </div> 43 40 </template> 44 41 45 - <template v-if="filesSnapshotFailed.length"> 42 + <template v-if="explorerTree.summary.filesSnapshotFailed"> 46 43 <div i-carbon-compare /> 47 - <div>Snapshot Fail</div> 44 + <div> 45 + Snapshot Fail 46 + </div> 48 47 <div class="number" text-red5> 49 - {{ filesSnapshotFailed.length }} 48 + {{ explorerTree.summary.filesSnapshotFailed }} 50 49 </div> 51 50 </template> 52 51 53 52 <template v-if="unhandledErrors.length"> 54 53 <div i-carbon-checkmark-outline-error /> 55 - <div>Errors</div> 54 + <div> 55 + Errors 56 + </div> 56 57 <div class="number" text-red5> 57 58 {{ unhandledErrors.length }} 58 59 </div> ··· 61 62 <div i-carbon-timer /> 62 63 <div>Time</div> 63 64 <div class="number" data-testid="run-time"> 64 - {{ time }} 65 + {{ explorerTree.summary.time }} 65 66 </div> 66 67 </div> 67 68 <template v-if="unhandledErrors.length"> ··· 70 71 Unhandled Errors 71 72 </h3> 72 73 <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. 79 76 </p> 80 77 <details 81 78 data-testid="unhandled-errors-details"
+15 -25
packages/ui/client/components/dashboard/TestsEntry.vue
··· 1 1 <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' 19 3 </script> 20 4 21 5 <template> ··· 25 9 Pass 26 10 </template> 27 11 <template #body> 28 - {{ pass }} 12 + {{ explorerTree.summary.testsSuccess }} 29 13 </template> 30 14 </DashboardEntry> 31 15 <DashboardEntry 32 - :class="{ 'text-red5': failed, 'op50': !failed }" 16 + :class="{ 'text-red5': explorerTree.summary.testsFailed, 'op50': !explorerTree.summary.testsFailed }" 33 17 data-testid="fail-entry" 34 18 > 35 19 <template #header> 36 20 Fail 37 21 </template> 38 22 <template #body> 39 - {{ failed }} 23 + {{ explorerTree.summary.testsFailed }} 40 24 </template> 41 25 </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 + > 43 30 <template #header> 44 31 Skip 45 32 </template> 46 33 <template #body> 47 - {{ skipped }} 34 + {{ explorerTree.summary.testsSkipped }} 48 35 </template> 49 36 </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 + > 51 41 <template #header> 52 42 Todo 53 43 </template> 54 44 <template #body> 55 - {{ todo }} 45 + {{ explorerTree.summary.testsTodo }} 56 46 </template> 57 47 </DashboardEntry> 58 48 <DashboardEntry :tail="true" data-testid="total-entry"> ··· 60 50 Total 61 51 </template> 62 52 <template #body> 63 - {{ total }} 53 + {{ explorerTree.summary.totalTests }} 64 54 </template> 65 55 </DashboardEntry> 66 56 </div>
+222
packages/ui/client/components/explorer/Explorer.vue
··· 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>
+192
packages/ui/client/components/explorer/ExplorerItem.vue
··· 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>
+58 -32
packages/ui/client/composables/client/index.ts
··· 1 1 import { createClient, getTasks } from '@vitest/ws-client' 2 2 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' 6 5 import { createFileTask } from '@vitest/runner/utils' 7 - import type { BrowserRunnerState, RunState } from '../../../types' 6 + import type { BrowserRunnerState } from '../../../types' 8 7 import { ENTRY_URL, isReport } from '../../constants' 9 8 import { parseError } from '../error' 10 9 import { activeFileId } from '../params' 11 10 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' 12 15 13 16 export { ENTRY_URL, PORT, HOST, isReport } from '../../constants' 14 - 15 - export const testRunState: Ref<RunState> = ref('idle') 16 - export const unhandledErrors: Ref<ErrorWithDiff[]> = ref([]) 17 17 18 18 export const client = (function createVitestClient() { 19 19 if (isReport) { ··· 21 21 } 22 22 else { 23 23 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 + }, 25 27 handlers: { 26 - onTaskUpdate() { 28 + onTaskUpdate(packs: TaskResultPack[]) { 29 + explorerTree.resumeRun(packs) 27 30 testRunState.value = 'running' 28 31 }, 29 32 onFinished(_files, errors) { 33 + explorerTree.endRun() 30 34 testRunState.value = 'idle' 31 35 unhandledErrors.value = (errors || []).map(parseError) 32 36 }, ··· 42 46 } 43 47 })() 44 48 45 - function sort(a: File, b: File) { 46 - return a.name.localeCompare(b.name) 47 - } 48 - 49 49 export const config = shallowRef<ResolvedConfig>({} as any) 50 50 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() || []) 61 58 62 59 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 64 62 } 65 63 66 64 export const isConnected = computed(() => status.value === 'OPEN') 67 65 export const isConnecting = computed(() => status.value === 'CONNECTING') 68 66 export const isDisconnected = computed(() => status.value === 'CLOSED') 69 67 70 - export function runAll(files = client.state.getFiles()) { 71 - return runFiles(files) 68 + export function runAll() { 69 + return runFiles(client.state.getFiles()/* , true */) 72 70 } 73 71 74 - export function runFiles(files: File[]) { 75 - files.forEach((f) => { 72 + function clearResults(useFiles: File[]) { 73 + const map = explorerTree.nodes 74 + useFiles.forEach((f) => { 76 75 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 + } 78 95 }) 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)) 80 104 } 81 105 82 106 export function runCurrent() { ··· 98 122 ws.addEventListener('open', async () => { 99 123 status.value = 'OPEN' 100 124 client.state.filesMap.clear() 101 - const [files, _config, errors] = await Promise.all([ 125 + const [remoteFiles, _config, errors] = await Promise.all([ 102 126 client.rpc.getFiles(), 103 127 client.rpc.getConfig(), 104 128 client.rpc.getUnhandledErrors(), ··· 111 135 client.state.collectFiles(files) 112 136 } 113 137 else { 114 - client.state.collectFiles(files) 138 + explorerTree.loadFiles(remoteFiles) 139 + client.state.collectFiles(remoteFiles) 140 + explorerTree.startRun() 115 141 } 116 142 unhandledErrors.value = (errors || []).map(parseError) 117 143 config.value = _config
+7
packages/ui/client/composables/client/state.ts
··· 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([])
+106
packages/ui/client/composables/explorer/collapse.ts
··· 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 + }
+400
packages/ui/client/composables/explorer/collector.ts
··· 1 + import type { Custom, File, Task, TaskResultPack, Test } from '@vitest/runner' 2 + import { isAtomTest } from '@vitest/runner/utils' 3 + import type { Arrayable } from '@vitest/utils' 4 + import { toArray } from '@vitest/utils' 5 + import { hasFailedSnapshot } from '@vitest/ws-client' 6 + import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests } from '~/composables/explorer/types' 7 + import { client, findById } from '~/composables/client' 8 + import { runFilter, testMatcher } from '~/composables/explorer/filter' 9 + import { createOrUpdateFileNode, createOrUpdateNodeTask, createOrUpdateSuiteTask } from '~/composables/explorer/utils' 10 + import { isSuite } from '~/utils/task' 11 + import { openedTreeItems, treeFilter, uiFiles } from '~/composables/explorer/state' 12 + import { explorerTree } from '~/composables/explorer/index' 13 + import { expandNodesOnEndRun } from '~/composables/explorer/expand' 14 + 15 + export function runLoadFiles( 16 + remoteFiles: File[], 17 + collect: boolean, 18 + search: string, 19 + filter: Filter, 20 + ) { 21 + remoteFiles.map(f => [`${f.filepath}:${f.projectName || ''}`, f] as const) 22 + .sort(([a], [b]) => a.localeCompare(b)) 23 + .map(([, f]) => createOrUpdateFileNode(f, collect)) 24 + 25 + uiFiles.value = [...explorerTree.root.tasks] 26 + runFilter(search.trim(), { 27 + failed: filter.failed, 28 + success: filter.success, 29 + skipped: filter.skipped, 30 + onlyTests: filter.onlyTests, 31 + }) 32 + } 33 + 34 + export function preparePendingTasks(packs: TaskResultPack[]) { 35 + queueMicrotask(() => { 36 + const pending = explorerTree.pendingTasks 37 + const idMap = client.state.idMap 38 + for (const pack of packs) { 39 + const result = pack[1] 40 + if (result) { 41 + const task = idMap.get(pack[0]) 42 + if (task) { 43 + let file = pending.get(task.file.id) 44 + if (!file) { 45 + file = new Set() 46 + pending.set(task.file.id, file) 47 + } 48 + file.add(task.id) 49 + } 50 + } 51 + } 52 + }) 53 + } 54 + 55 + export function runCollect( 56 + start: boolean, 57 + end: boolean, 58 + summary: CollectorInfo, 59 + search: string, 60 + filter: Filter, 61 + ) { 62 + if (start) { 63 + resetCollectorInfo(summary) 64 + } 65 + 66 + const collect = !start 67 + queueMicrotask(() => { 68 + if (end) { 69 + traverseFiles(collect) 70 + } 71 + else { 72 + traverseReceivedFiles(collect) 73 + } 74 + }) 75 + 76 + queueMicrotask(() => { 77 + collectData(summary) 78 + }) 79 + 80 + queueMicrotask(() => { 81 + if (end) { 82 + summary.failedSnapshot = uiFiles.value && hasFailedSnapshot( 83 + uiFiles.value.map(f => findById(f.id)!), 84 + ) 85 + summary.failedSnapshotEnabled = true 86 + } 87 + }) 88 + 89 + queueMicrotask(() => { 90 + doRunFilter(search, filter, end) 91 + }) 92 + } 93 + 94 + function traverseFiles(collect: boolean) { 95 + const rootTasks = explorerTree.root.tasks 96 + // collect remote children 97 + for (let i = 0; i < rootTasks.length; i++) { 98 + const fileNode = rootTasks[i] 99 + const file = findById(fileNode.id) 100 + if (!file) { 101 + continue 102 + } 103 + 104 + createOrUpdateFileNode(file, collect) 105 + const tasks = file.tasks 106 + if (!tasks?.length) { 107 + continue 108 + } 109 + 110 + createOrUpdateEntry(file.tasks) 111 + } 112 + } 113 + 114 + function traverseReceivedFiles(collect: boolean) { 115 + const rootTasks = explorerTree.root.tasks 116 + const updatedFiles = new Map(explorerTree.pendingTasks.entries()) 117 + explorerTree.pendingTasks.clear() 118 + const idMap = client.state.idMap 119 + // collect remote children 120 + for (let i = 0; i < rootTasks.length; i++) { 121 + const fileNode = rootTasks[i] 122 + const file = findById(fileNode.id) 123 + if (!file) { 124 + continue 125 + } 126 + const entries = updatedFiles.get(file.id) 127 + if (!entries) { 128 + continue 129 + } 130 + createOrUpdateFileNode(file, collect) 131 + createOrUpdateEntry(Array.from(entries).map(id => idMap.get(id)).filter(Boolean) as Task[]) 132 + } 133 + } 134 + 135 + function doRunFilter( 136 + search: string, 137 + filter: Filter, 138 + end = false, 139 + ) { 140 + // refresh explorer 141 + 142 + const expandAll = treeFilter.value.expandAll 143 + const resetExpandAll = expandAll !== true 144 + const ids = new Set(openedTreeItems.value) 145 + const applyExpandNodes = (ids.size > 0 && expandAll === false) || resetExpandAll 146 + 147 + // refresh explorer 148 + queueMicrotask(() => { 149 + runFilter(search, filter) 150 + }) 151 + 152 + if (applyExpandNodes) { 153 + // expand all nodes 154 + queueMicrotask(() => { 155 + expandNodesOnEndRun(ids, end) 156 + if (resetExpandAll) { 157 + treeFilter.value.expandAll = false 158 + } 159 + }) 160 + // refresh explorer 161 + queueMicrotask(() => runFilter(search, filter)) 162 + } 163 + } 164 + 165 + function createOrUpdateEntry(tasks: Task[]) { 166 + let task: Task 167 + for (let i = 0; i < tasks.length; i++) { 168 + task = tasks[i] 169 + if (isSuite(task)) { 170 + createOrUpdateSuiteTask(task.id, true) 171 + } 172 + else { 173 + createOrUpdateNodeTask(task.id) 174 + } 175 + } 176 + } 177 + 178 + export function resetCollectorInfo(summary: CollectorInfo) { 179 + summary.files = 0 180 + summary.time = '' 181 + summary.filesFailed = 0 182 + summary.filesSuccess = 0 183 + summary.filesIgnore = 0 184 + summary.filesRunning = 0 185 + summary.filesSkipped = 0 186 + summary.filesTodo = 0 187 + summary.testsFailed = 0 188 + summary.testsSuccess = 0 189 + summary.testsIgnore = 0 190 + summary.testsSkipped = 0 191 + summary.testsTodo = 0 192 + summary.totalTests = 0 193 + summary.failedSnapshotEnabled = false 194 + } 195 + 196 + function collectData(summary: CollectorInfo) { 197 + const idMap = client.state.idMap 198 + const filesMap = new Map(explorerTree.root.tasks.filter(f => idMap.has(f.id)).map(f => [f.id, f])) 199 + const useFiles = Array.from(filesMap.values()).map(file => [file.id, findById(file.id)] as const) 200 + const data = { 201 + files: filesMap.size, 202 + time: '', 203 + filesFailed: 0, 204 + filesSuccess: 0, 205 + filesIgnore: 0, 206 + filesRunning: 0, 207 + filesSkipped: 0, 208 + filesTodo: 0, 209 + filesSnapshotFailed: 0, 210 + testsFailed: 0, 211 + testsSuccess: 0, 212 + testsIgnore: 0, 213 + testsSkipped: 0, 214 + testsTodo: 0, 215 + totalTests: 0, 216 + failedSnapshot: false, 217 + failedSnapshotEnabled: false, 218 + } satisfies CollectorInfo 219 + 220 + let time = 0 221 + for (const [id, f] of useFiles) { 222 + if (!f) { 223 + continue 224 + } 225 + const file = filesMap.get(id) 226 + if (file) { 227 + file.mode = f.mode 228 + file.setupDuration = f.setupDuration 229 + file.prepareDuration = f.prepareDuration 230 + file.environmentLoad = f.environmentLoad 231 + file.collectDuration = f.collectDuration 232 + file.duration = f.result?.duration 233 + file.state = f.result?.state 234 + } 235 + time += Math.max(0, f.collectDuration || 0) 236 + time += Math.max(0, f.setupDuration || 0) 237 + time += Math.max(0, f.result?.duration || 0) 238 + time += Math.max(0, f.environmentLoad || 0) 239 + time += Math.max(0, f.prepareDuration || 0) 240 + data.time = time > 1000 ? `${(time / 1000).toFixed(2)}s` : `${Math.round(time)}ms` 241 + if (f.result?.state === 'fail') { 242 + data.filesFailed++ 243 + } 244 + else if (f.result?.state === 'pass') { 245 + data.filesSuccess++ 246 + } 247 + else if (f.mode === 'skip') { 248 + data.filesIgnore++ 249 + data.filesSkipped++ 250 + } 251 + else if (f.mode === 'todo') { 252 + data.filesIgnore++ 253 + data.filesTodo++ 254 + } 255 + else { 256 + data.filesRunning++ 257 + } 258 + 259 + const { 260 + failed, 261 + success, 262 + skipped, 263 + total, 264 + ignored, 265 + todo, 266 + } = collectTests(f) 267 + 268 + data.totalTests += total 269 + data.testsFailed += failed 270 + data.testsSuccess += success 271 + data.testsSkipped += skipped 272 + data.testsTodo += todo 273 + data.testsIgnore += ignored 274 + } 275 + 276 + summary.files = data.files 277 + summary.time = data.time 278 + summary.filesFailed = data.filesFailed 279 + summary.filesSuccess = data.filesSuccess 280 + summary.filesIgnore = data.filesIgnore 281 + summary.filesRunning = data.filesRunning 282 + summary.filesSkipped = data.filesSkipped 283 + summary.filesTodo = data.filesTodo 284 + summary.testsFailed = data.testsFailed 285 + summary.testsSuccess = data.testsSuccess 286 + summary.testsFailed = data.testsFailed 287 + summary.testsTodo = data.testsTodo 288 + summary.testsIgnore = data.testsIgnore 289 + summary.testsSkipped = data.testsSkipped 290 + summary.totalTests = data.totalTests 291 + } 292 + 293 + function collectTests(file: File, search = '', filter?: Filter) { 294 + const data = { 295 + failed: 0, 296 + success: 0, 297 + skipped: 0, 298 + running: 0, 299 + total: 0, 300 + ignored: 0, 301 + todo: 0, 302 + } satisfies CollectFilteredTests 303 + 304 + for (const t of testsCollector(file)) { 305 + if (!filter || testMatcher(t, search, filter)) { 306 + data.total++ 307 + if (t.result?.state === 'fail') { 308 + data.failed++ 309 + } 310 + else if (t.result?.state === 'pass') { 311 + data.success++ 312 + } 313 + else if (t.mode === 'skip') { 314 + data.ignored++ 315 + data.skipped++ 316 + } 317 + else if (t.mode === 'todo') { 318 + data.ignored++ 319 + data.todo++ 320 + } 321 + } 322 + } 323 + 324 + data.running = data.total - data.failed - data.success - data.ignored 325 + 326 + return data 327 + } 328 + 329 + export function collectTestsTotalData( 330 + filtered: boolean, 331 + onlyTests: boolean, 332 + tests: File[], 333 + filesSummary: FilteredTests, 334 + search: string, 335 + filter: Filter, 336 + ) { 337 + if (onlyTests) { 338 + // todo: apply similar logic when filtered 339 + return tests 340 + .map(file => collectTests(file, search, filter)) 341 + .reduce((acc, { 342 + failed, 343 + success, 344 + ignored, 345 + running, 346 + }) => { 347 + acc.failed += failed 348 + acc.success += success 349 + acc.skipped += ignored 350 + acc.running += running 351 + return acc 352 + }, { failed: 0, success: 0, skipped: 0, running: 0 }) 353 + } 354 + else if (filtered) { 355 + const data = { 356 + failed: 0, 357 + success: 0, 358 + skipped: 0, 359 + running: 0, 360 + } satisfies FilteredTests 361 + // will match when the filter entry is active or filter is inactive (skipped excluded) 362 + // for example, we should update all when the filter is empty 363 + // but shouldn't update failed if we're filtering by success 364 + const empty = !filter.success && !filter.failed 365 + const applyFailed = filter.failed || empty 366 + const applySuccess = filter.success || empty 367 + for (const f of tests) { 368 + if (f.result?.state === 'fail') { 369 + data.failed += applyFailed ? 1 : 0 370 + } 371 + else if (f.result?.state === 'pass') { 372 + data.success += applySuccess ? 1 : 0 373 + } 374 + else if (f.mode === 'skip' || f.mode === 'todo') { 375 + // just ignore 376 + } 377 + else { 378 + data.running++ 379 + } 380 + } 381 + 382 + return data 383 + } 384 + 385 + return filesSummary 386 + } 387 + 388 + function* testsCollector(suite: Arrayable<Task>): Generator<Test | Custom> { 389 + const arraySuites = toArray(suite) 390 + let s: Task 391 + for (let i = 0; i < arraySuites.length; i++) { 392 + s = arraySuites[i] 393 + if (isAtomTest(s)) { 394 + yield s 395 + } 396 + else { 397 + yield * testsCollector(s.tasks) 398 + } 399 + } 400 + }
+152
packages/ui/client/composables/explorer/expand.ts
··· 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 + }
+236
packages/ui/client/composables/explorer/filter.ts
··· 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 + }
+3
packages/ui/client/composables/explorer/index.ts
··· 1 + import { ExplorerTree } from './tree' 2 + 3 + export const explorerTree = new ExplorerTree()
+107
packages/ui/client/composables/explorer/search.ts
··· 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 + }
+79
packages/ui/client/composables/explorer/state.ts
··· 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 + })
+203
packages/ui/client/composables/explorer/tree.ts
··· 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 + }
+91
packages/ui/client/composables/explorer/types.ts
··· 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 + }
+194
packages/ui/client/composables/explorer/utils.ts
··· 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 + }