···11-import type { File } from 'vitest'
22-import { client, config, findById, testRunState } from './client'
11+import type { File } from '@vitest/runner'
22+import { client, config, findById } from './client'
33+import { testRunState } from './client/state'
34import { activeFileId } from './params'
4556export const currentModule = ref<File>()
···2425 },
2526)
26272727-export const openedTreeItems = useLocalStorage<string[]>(
2828- 'vitest-ui_task-tree-opened',
2929- [],
3030-)
3128// TODO
3229// For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
3330// Handling other cases seems difficult, so this limitation is mentioned in the documentation for now.
···11+import type { UITaskTreeNode } from '~/composables/explorer/types'
22+import { isFileNode, isParentNode } from '~/composables/explorer/utils'
33+import { openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state'
44+import { explorerTree } from '~/composables/explorer/index'
55+66+/**
77+ * Collapse all nodes: all children collapsed.
88+ *
99+ * This method will use current `uiEntries`, we don't need to traverse the full tree.
1010+ * The action will be applied on the current items in the test results explorer.
1111+ *
1212+ * If the node is not a parent node, nothing will happen.
1313+ *
1414+ * Calling this method will:
1515+ * - collapse all nodes
1616+ * - remove opened tree items for the node and any children
1717+ * - update uiEntries without child nodes
1818+ *
1919+ * @param id The node id to collapse.
2020+ */
2121+export function runCollapseNode(id: string) {
2222+ const node = explorerTree.nodes.get(id)
2323+ if (!node || !isParentNode(node)) {
2424+ return
2525+ }
2626+2727+ const treeItems = new Set(openedTreeItems.value)
2828+ treeItems.delete(node.id)
2929+ const entries = [...collectCollapseNode(node)]
3030+ openedTreeItems.value = Array.from(treeItems)
3131+ // Keep expandAll state as it is: collapsing individual shouldn't prevent collapsing all the nodes ("collapse all" button)
3232+ // There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items
3333+ // treeFilter.value.expandAll = true
3434+ uiEntries.value = entries
3535+}
3636+3737+/**
3838+ * Collapse all nodes: any child collapsed.
3939+ *
4040+ * This method will use current `uiEntries`, we don't need to traverse the full tree.
4141+ * The action will be applied on the current items in the test results explorer.
4242+ *
4343+ * We'll use current `uiEntries`, we don't need to traverse the full tree.
4444+ *
4545+ * Calling this method will:
4646+ * - collapse all nodes
4747+ * - clear stored opened tree items
4848+ * - update the filtered expandAll state to true
4949+ * - update uiEntries without child nodes
5050+ *
5151+ */
5252+export function runCollapseAllTask() {
5353+ // collapse all nodes
5454+ collapseAllNodes(explorerTree.root.tasks)
5555+ const entries = [...uiEntries.value.filter(isFileNode)]
5656+ collapseAllNodes(entries)
5757+ // collapse all nodes
5858+ openedTreeItems.value = []
5959+ treeFilter.value.expandAll = true
6060+ uiEntries.value = entries
6161+}
6262+6363+function collapseAllNodes(nodes: UITaskTreeNode[]) {
6464+ for (const node of nodes) {
6565+ if (isParentNode(node)) {
6666+ node.expanded = false
6767+ collapseAllNodes(node.tasks)
6868+ }
6969+ }
7070+}
7171+7272+function * collectChildNodes(node: UITaskTreeNode, itself: boolean): Generator<string> {
7373+ if (itself) {
7474+ yield node.id
7575+ }
7676+7777+ if (isParentNode(node)) {
7878+ for (let i = 0; i < node.tasks.length; i++) {
7979+ yield * collectChildNodes(node.tasks[i], true)
8080+ }
8181+ }
8282+}
8383+8484+function* collectCollapseNode(node: UITaskTreeNode) {
8585+ const id = node.id
8686+ // collect children to remove from the list
8787+ const childNodes = new Set<string>(collectChildNodes(node, false))
8888+ for (let i = 0; i < uiEntries.value.length; i++) {
8989+ const child = uiEntries.value[i]
9090+ // collapse current node and return it
9191+ if (child.id === id) {
9292+ child.expanded = false
9393+ yield child
9494+ continue
9595+ }
9696+9797+ // remove children from the list
9898+ if (childNodes.has(child.id)) {
9999+ childNodes.delete(child.id)
100100+ continue
101101+ }
102102+103103+ // return the node
104104+ yield child
105105+ }
106106+}
···11+import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state'
22+import type { Filter, UITaskTreeNode } from '~/composables/explorer/types'
33+import { createOrUpdateNode, createOrUpdateSuiteTask, isFileNode, isParentNode } from '~/composables/explorer/utils'
44+import { filterAll, filterNode } from '~/composables/explorer/filter'
55+import { findById } from '~/composables/client'
66+import { explorerTree } from '~/composables/explorer/index'
77+88+/**
99+ * Expand the node: only direct children will be expanded
1010+ *
1111+ * This method will use current `uiEntries`, we don't need to traverse the full tree.
1212+ * The action will be applied on the current items in the test results explorer.
1313+ *
1414+ * **Note:** we only need to apply the filter on child nodes, when filtering, parent nodes
1515+ * are not present in the explorer if don't match the filter, only those matching the criteria
1616+ * will be there, we only need to filter the children of the node to expand.
1717+ *
1818+ * Calling this method will:
1919+ * - remove opened tree items for the node and any children
2020+ * - update uiEntries including child nodes
2121+ *
2222+ * @param id The node id to expand.
2323+ * @param search The search applied.
2424+ * @param filter The filter applied.
2525+ */
2626+export function runExpandNode(
2727+ id: string,
2828+ search: string,
2929+ filter: Filter,
3030+) {
3131+ const entry = createOrUpdateSuiteTask(
3232+ id,
3333+ false,
3434+ )
3535+ if (!entry) {
3636+ return
3737+ }
3838+3939+ const [node, task] = entry
4040+4141+ // create only direct children
4242+ for (const subtask of task.tasks) {
4343+ createOrUpdateNode(node.id, subtask, false)
4444+ }
4545+4646+ // expand the node
4747+ node.expanded = true
4848+4949+ const treeItems = new Set(openedTreeItems.value)
5050+ treeItems.add(node.id)
5151+ // collect children
5252+ // the first node is itself only when it is a file
5353+ const children = new Set(filterNode(
5454+ node,
5555+ search,
5656+ filter,
5757+ ))
5858+5959+ const entries = [...collectExpandedNode(node, children)]
6060+ openedTreeItems.value = Array.from(treeItems)
6161+ // Keep expandAll state as it is: expanding individual shouldn't prevent expanding all the nodes ("expand all" button)
6262+ // There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items
6363+ // treeFilter.value.expandAll = false
6464+ uiEntries.value = entries
6565+}
6666+6767+/**
6868+ * Expand all nodes: any child expanded.
6969+ *
7070+ * This method will use current `uiEntries`, we don't need to traverse the full tree.
7171+ * The action will be applied on the current items in the test results explorer.
7272+ *
7373+ * Any already expanded child will be shown as expanded: collapsing nodes will not collapse any child.
7474+ *
7575+ * **Note:** we don't need to apply the filter here, we'll use the current `uiEntries`, when filtering,
7676+ * parent nodes are not present in the explorer, only those matching the criteria will be there.
7777+ * The filter will be applied to the full tree.
7878+ *
7979+ * Calling this method will:
8080+ * - expand all nodes
8181+ * - add stored opened tree items
8282+ * - update the filtered expandAll state to false
8383+ * - update uiEntries with child nodes
8484+ *
8585+ * @param search The search applied.
8686+ * @param filter The filter applied.
8787+ */
8888+export function runExpandAll(
8989+ search: string,
9090+ filter: Filter,
9191+) {
9292+ expandAllNodes(explorerTree.root.tasks, false)
9393+ const entries = [...filterAll(
9494+ search,
9595+ filter,
9696+ )]
9797+ treeFilter.value.expandAll = false
9898+ openedTreeItems.value = []
9999+ uiEntries.value = entries
100100+ filteredFiles.value = entries.filter(isFileNode).map(f => findById(f.id)!)
101101+}
102102+103103+export function expandNodesOnEndRun(
104104+ ids: Set<string>,
105105+ end: boolean,
106106+) {
107107+ if (ids.size) {
108108+ for (const node of uiEntries.value) {
109109+ if (ids.has(node.id)) {
110110+ node.expanded = true
111111+ }
112112+ }
113113+ }
114114+ else if (end) {
115115+ expandAllNodes(uiEntries.value.filter(isFileNode), true)
116116+ }
117117+}
118118+119119+export function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) {
120120+ for (const node of nodes) {
121121+ if (isParentNode(node)) {
122122+ node.expanded = true
123123+ expandAllNodes(node.tasks, false)
124124+ }
125125+ }
126126+127127+ if (updateState) {
128128+ treeFilter.value.expandAll = false
129129+ openedTreeItems.value = []
130130+ }
131131+}
132132+133133+function* collectExpandedNode(
134134+ node: UITaskTreeNode,
135135+ children: Set<UITaskTreeNode>,
136136+) {
137137+ const id = node.id
138138+ const ids = new Set(Array.from(children).map(n => n.id))
139139+140140+ for (const child of uiEntries.value) {
141141+ if (child.id === id) {
142142+ child.expanded = true
143143+ if (!ids.has(child.id)) {
144144+ yield node
145145+ }
146146+ yield * children
147147+ }
148148+ else if (!ids.has(child.id)) {
149149+ yield child
150150+ }
151151+ }
152152+}
+236
packages/ui/client/composables/explorer/filter.ts
···11+import type { Task } from '@vitest/runner'
22+import { caseInsensitiveMatch } from '~/utils/task'
33+import type { FileTreeNode, Filter, FilterResult, ParentTreeNode, UITaskTreeNode } from '~/composables/explorer/types'
44+import {
55+ isFileNode,
66+ isParentNode,
77+ isTestNode,
88+} from '~/composables/explorer/utils'
99+import { client, findById } from '~/composables/client'
1010+import { filteredFiles, uiEntries } from '~/composables/explorer/state'
1111+import { explorerTree } from '~/composables/explorer/index'
1212+1313+export function testMatcher(task: Task, search: string, filter: Filter) {
1414+ return task ? matchTask(task, search, filter) : false
1515+}
1616+/**
1717+ * Filter child nodes using search, filter and only tests.
1818+ *
1919+ * @param search The search applied.
2020+ * @param filter The filter applied.
2121+ */
2222+export function runFilter(
2323+ search: string,
2424+ filter: Filter,
2525+) {
2626+ const entries = [...filterAll(
2727+ search,
2828+ filter,
2929+ )]
3030+ uiEntries.value = entries
3131+ filteredFiles.value = entries.filter(isFileNode).map(f => findById(f.id)!)
3232+}
3333+3434+export function* filterAll(
3535+ search: string,
3636+ filter: Filter,
3737+) {
3838+ for (const node of explorerTree.root.tasks) {
3939+ yield * filterNode(node, search, filter)
4040+ }
4141+}
4242+4343+export function* filterNode(
4444+ node: UITaskTreeNode,
4545+ search: string,
4646+ filter: Filter,
4747+) {
4848+ const treeNodes = new Set<string>()
4949+5050+ const list: FilterResult[] = []
5151+5252+ for (const entry of visitNode(
5353+ node,
5454+ treeNodes,
5555+ n => matcher(n, search, filter),
5656+ )) {
5757+ list.push(entry)
5858+ }
5959+6060+ const filesToShow = new Set<string>()
6161+6262+ const entries = [...filterParents(
6363+ list,
6464+ filter.onlyTests,
6565+ treeNodes,
6666+ filesToShow,
6767+ )].reverse()
6868+6969+ // We show only the files and parents whose parent is expanded.
7070+ // Filtering will return all the nodes matching the filter and their parents.
7171+ // Once we've the tree, we need to remove the children from not expanded parents.
7272+ // For example, if we have a suite with only one test, when collapsing the suite node,
7373+ // we still need to show the suite, but the test must be removed from the list to render.
7474+7575+ const map = explorerTree.nodes
7676+ // collect files and all suites whose parent is expanded
7777+ const parents = new Set(
7878+ entries.filter(e => isFileNode(e) || (isParentNode(e) && map.get(e.parentId)?.expanded)).map(e => e.id),
7979+ )
8080+8181+ // collect files, and suites and tests whose parent is expanded
8282+ yield * entries.filter((node) => {
8383+ // all file nodes or children of expanded parents
8484+ return isFileNode(node) || (parents.has(node.parentId) && map.get(node.parentId)?.expanded)
8585+ })
8686+}
8787+8888+function expandCollapseNode(
8989+ match: boolean,
9090+ child: FileTreeNode | ParentTreeNode,
9191+ treeNodes: Set<string>,
9292+ collapseParents: boolean,
9393+ filesToShow: Set<string>,
9494+) {
9595+ if (collapseParents) {
9696+ if (isFileNode(child)) {
9797+ if (filesToShow.has(child.id)) {
9898+ return child
9999+ }
100100+101101+ return undefined
102102+ }
103103+ // show the parent if at least one child matches the filter
104104+ if (treeNodes.has(child.id)) {
105105+ const parent = explorerTree.nodes.get(child.parentId)
106106+ if (parent && isFileNode(parent)) {
107107+ filesToShow.add(parent.id)
108108+ }
109109+110110+ return child
111111+ }
112112+ }
113113+ else {
114114+ // show the parent if matches the filter or at least one child matches the filter
115115+ if (match || treeNodes.has(child.id) || filesToShow.has(child.id)) {
116116+ const parent = explorerTree.nodes.get(child.parentId)
117117+ if (parent && isFileNode(parent)) {
118118+ filesToShow.add(parent.id)
119119+ }
120120+121121+ return child
122122+ }
123123+ }
124124+}
125125+126126+function* filterParents(
127127+ list: FilterResult[],
128128+ collapseParents: boolean,
129129+ treeNodes: Set<string>,
130130+ filesToShow: Set<string>,
131131+) {
132132+ for (let i = list.length - 1; i >= 0; i--) {
133133+ const [match, child] = list[i]
134134+ if (isParentNode(child)) {
135135+ const node = expandCollapseNode(
136136+ match,
137137+ child,
138138+ treeNodes,
139139+ collapseParents,
140140+ filesToShow,
141141+ )
142142+ if (node) {
143143+ yield node
144144+ }
145145+ }
146146+ else if (match) {
147147+ const parent = explorerTree.nodes.get(child.parentId)
148148+ if (parent && isFileNode(parent)) {
149149+ filesToShow.add(parent.id)
150150+ }
151151+ yield child
152152+ }
153153+ }
154154+}
155155+156156+function matchState(task: Task, filter: Filter) {
157157+ if (filter.success || filter.failed) {
158158+ if ('result' in task) {
159159+ if (filter.success && task.result?.state === 'pass') {
160160+ return true
161161+ }
162162+ if (filter.failed && task.result?.state === 'fail') {
163163+ return true
164164+ }
165165+ }
166166+ }
167167+168168+ if (filter.skipped && 'mode' in task) {
169169+ return task.mode === 'skip' || task.mode === 'todo'
170170+ }
171171+172172+ return false
173173+}
174174+175175+function matchTask(
176176+ task: Task,
177177+ search: string,
178178+ filter: Filter,
179179+) {
180180+ const match = search.length === 0 || caseInsensitiveMatch(task.name, search)
181181+182182+ // search and filter will apply together
183183+ if (match) {
184184+ if (filter.success || filter.failed || filter.skipped) {
185185+ if (matchState(task, filter)) {
186186+ return true
187187+ }
188188+ }
189189+ else {
190190+ return true
191191+ }
192192+ }
193193+194194+ return false
195195+}
196196+197197+function* visitNode(
198198+ node: UITaskTreeNode,
199199+ treeNodes: Set<string>,
200200+ matcher: (node: UITaskTreeNode) => boolean,
201201+): Generator<[match: boolean, node: UITaskTreeNode]> {
202202+ const match = matcher(node)
203203+204204+ if (match) {
205205+ if (isTestNode(node)) {
206206+ let parent = explorerTree.nodes.get(node.parentId)
207207+ while (parent) {
208208+ treeNodes.add(parent.id)
209209+ parent = explorerTree.nodes.get(parent.parentId)
210210+ }
211211+ }
212212+ else if (isFileNode(node)) {
213213+ treeNodes.add(node.id)
214214+ }
215215+ else {
216216+ treeNodes.add(node.id)
217217+ let parent = explorerTree.nodes.get(node.parentId)
218218+ while (parent) {
219219+ treeNodes.add(parent.id)
220220+ parent = explorerTree.nodes.get(parent.parentId)
221221+ }
222222+ }
223223+ }
224224+225225+ yield [match, node]
226226+ if (isParentNode(node)) {
227227+ for (let i = 0; i < node.tasks.length; i++) {
228228+ yield * visitNode(node.tasks[i], treeNodes, matcher)
229229+ }
230230+ }
231231+}
232232+233233+function matcher(node: UITaskTreeNode, search: string, filter: Filter) {
234234+ const task = client.state.idMap.get(node.id)
235235+ return task ? matchTask(task, search, filter) : false
236236+}
+3
packages/ui/client/composables/explorer/index.ts
···11+import { ExplorerTree } from './tree'
22+33+export const explorerTree = new ExplorerTree()