diff --git a/packages/ui/client/composables/explorer/collapse.ts b/packages/ui/client/composables/explorer/collapse.ts index 3d41d3395e1d..7ba11e0a0d97 100644 --- a/packages/ui/client/composables/explorer/collapse.ts +++ b/packages/ui/client/composables/explorer/collapse.ts @@ -1,7 +1,7 @@ import type { UITaskTreeNode } from '~/composables/explorer/types' import { explorerTree } from '~/composables/explorer/index' import { openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' -import { isFileNode, isParentNode } from '~/composables/explorer/utils' +import { isFileNode, isParentNode, replaceSubtreeEntries } from '~/composables/explorer/utils' /** * Collapse all nodes: all children collapsed. @@ -24,9 +24,10 @@ export function runCollapseNode(id: string) { return } + node.expanded = false const treeItems = new Set(openedTreeItems.value) treeItems.delete(node.id) - const entries = [...collectCollapseNode(node)] + const entries = replaceSubtreeEntries(uiEntries.value, node, [node]) openedTreeItems.value = Array.from(treeItems) // Keep expandAll state as it is: collapsing individual shouldn't prevent collapsing all the nodes ("collapse all" button) // There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items @@ -68,39 +69,3 @@ function collapseAllNodes(nodes: UITaskTreeNode[]) { } } } - -function* collectChildNodes(node: UITaskTreeNode, itself: boolean): Generator { - if (itself) { - yield node.id - } - - if (isParentNode(node)) { - for (let i = 0; i < node.tasks.length; i++) { - yield* collectChildNodes(node.tasks[i], true) - } - } -} - -function* collectCollapseNode(node: UITaskTreeNode) { - const id = node.id - // collect children to remove from the list - const childNodes = new Set(collectChildNodes(node, false)) - for (let i = 0; i < uiEntries.value.length; i++) { - const child = uiEntries.value[i] - // collapse current node and return it - if (child.id === id) { - child.expanded = false - yield child - continue - } - - // remove children from the list - if (childNodes.has(child.id)) { - childNodes.delete(child.id) - continue - } - - // return the node - yield child - } -} diff --git a/packages/ui/client/composables/explorer/collector.ts b/packages/ui/client/composables/explorer/collector.ts index 70247071bb78..8dac8fbed4aa 100644 --- a/packages/ui/client/composables/explorer/collector.ts +++ b/packages/ui/client/composables/explorer/collector.ts @@ -14,7 +14,7 @@ import type { SearchMatcher, } from '~/composables/explorer/types' import { toArray } from '@vitest/utils/helpers' -import { client, findById } from '~/composables/client' +import { client, config, findById } from '~/composables/client' import { testRunState } from '~/composables/client/state' import { expandNodesOnEndRun } from '~/composables/explorer/expand' import { runFilter, testMatcher } from '~/composables/explorer/filter' @@ -128,14 +128,10 @@ export function runCollect( doRunFilter(search, filter, end) } -function* collectRunningTodoTests() { - yield* uiEntries.value.filter(isRunningTestNode) -} - function updateRunningTodoTests() { const idMap = client.state.idMap let task: Task | undefined - for (const test of collectRunningTodoTests()) { + for (const test of uiEntries.value.filter(isRunningTestNode)) { // lookup the parent task = idMap.get(test.parentId) if (task && isSuite(task) && task.mode === 'todo') { @@ -372,7 +368,7 @@ function collectTests(file: File, search: SearchMatcher = () => true, filter?: F } satisfies CollectFilteredTests for (const t of testsCollector(file)) { - if (!filter || testMatcher(t, search, filter)) { + if (!filter || testMatcher(t, search, filter, config.value.slowTestThreshold)) { data.total++ if (isSlowTestTask(t)) { data.slow++ @@ -448,15 +444,17 @@ export function collectTestsTotalData( return filesSummary } -function* testsCollector(suite: Arrayable): Generator { +function testsCollector(suite: Arrayable, collectedTests: Test[] = []) { const arraySuites = toArray(suite) let s: Task for (let i = 0; i < arraySuites.length; i++) { s = arraySuites[i] if (s.type === 'test') { - yield s + collectedTests.push(s) } else { - yield* testsCollector(s.tasks) + testsCollector(s.tasks, collectedTests) } } + + return collectedTests } diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 328f1392869e..5148e9e229a6 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,13 +1,14 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' -import { findById } from '~/composables/client' -import { filterAll, filterNode } from '~/composables/explorer/filter' +import { client, config } from '~/composables/client' +import { filterNode, runFilter } from '~/composables/explorer/filter' import { explorerTree } from '~/composables/explorer/index' -import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' +import { openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' import { createOrUpdateNode, createOrUpdateSuiteTask, isFileNode, isParentNode, + replaceSubtreeEntries, } from '~/composables/explorer/utils' /** @@ -46,11 +47,15 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) const treeItems = new Set(openedTreeItems.value) treeItems.add(node.id) - // collect children - // the first node is itself only when it is a file - const children = new Set(filterNode(node, search, filter)) + const subtree = filterNode(node, { + nodes: explorerTree.nodes, + taskIdMap: client.state.idMap, + search, + filter, + slowTestThreshold: config.value.slowTestThreshold, + }) - const entries = [...collectExpandedNode(node, children)] + const entries = replaceSubtreeEntries(uiEntries.value, node, subtree) openedTreeItems.value = Array.from(treeItems) // Keep expandAll state as it is: expanding individual shouldn't prevent expanding all the nodes ("expand all" button) // There is a watcher on composable search.ts to reset to undefined expandAll if there are no opened items @@ -81,11 +86,9 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) */ export function runExpandAll(search: SearchMatcher, filter: Filter) { expandAllNodes(explorerTree.root.tasks, false) - const entries = [...filterAll(search, filter)] treeFilter.value.expandAll = false openedTreeItems.value = [] - uiEntries.value = entries - filteredFiles.value = entries.filter(isFileNode).map((f) => findById(f.id)!) + runFilter(search, filter) } export function expandNodesOnEndRun(ids: Set, end: boolean) { @@ -113,32 +116,3 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { openedTreeItems.value = [] } } - -/** - * Build the complete next explorer entry list by emitting an expanded node and its filtered - * subtree at the node's current position. - * - * `children` contains only entries produced by filtering the expanded subtree. This function - * walks the complete current `uiEntries`. When it reaches `node`, it emits the node unless - * `children` already contains it, followed by the children. Unrelated entries are copied - * unchanged, while existing entries with the same IDs are skipped to avoid duplicates. - * - * TODO: Make this a pure splice over explicit entries and keep expansion state changes in - * `runExpandNode`. - */ -function* collectExpandedNode(node: UITaskTreeNode, children: Set) { - const id = node.id - const ids = new Set(Array.from(children).map((n) => n.id)) - - for (const child of uiEntries.value) { - if (child.id === id) { - child.expanded = true - if (!ids.has(child.id)) { - yield node - } - yield* children - } else if (!ids.has(child.id)) { - yield child - } - } -} diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 39c12da2a979..8b074b09663f 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -1,12 +1,5 @@ import type { RunnerTask as Task } from 'vitest' -import type { - FileTreeNode, - Filter, - FilterResult, - ParentTreeNode, - SearchMatcher, - UITaskTreeNode, -} from '~/composables/explorer/types' +import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' import { client, config, findById } from '~/composables/client' import { explorerTree } from '~/composables/explorer/index' import { @@ -15,209 +8,135 @@ import { projectSort, uiEntries, } from '~/composables/explorer/state' -import { - getSortedRootTasks, - isFileNode, - isParentNode, - isTestNode, -} from '~/composables/explorer/utils' +import { getSortedRootTasks, isFileNode, isParentNode } from '~/composables/explorer/utils' + +interface FilterNodeContext { + nodes: ReadonlyMap + taskIdMap: ReadonlyMap + search: SearchMatcher + filter: Filter + slowTestThreshold: number | undefined +} -export function testMatcher(task: Task, search: SearchMatcher, filter: Filter) { - return task ? matchTask(task, search, filter) : false +interface FilteredTreeNode { + node: UITaskTreeNode + children: FilteredTreeNode[] + subtreeMatches: boolean } + +export function testMatcher( + task: Task, + search: SearchMatcher, + filter: Filter, + slowTestThreshold: number | undefined, +): boolean { + return matchTask(task, search, filter, slowTestThreshold) +} + /** - * Filter child nodes using search, filter and only tests. - * - * @param search The search applied. - * @param filter The filter applied. + * Rebuild the explorer rows for every file of the current project, in sort order, and publish them to + * `uiEntries` and `filteredFiles`. */ -export function runFilter(search: SearchMatcher, filter: Filter) { - const entries = [...filterAll(search, filter)] +export function runFilter(search: SearchMatcher, filter: Filter): void { + const project = currentProjectName.value + const files = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) + const context: FilterNodeContext = { + nodes: explorerTree.nodes, + taskIdMap: client.state.idMap, + search, + filter, + slowTestThreshold: config.value.slowTestThreshold, + } + const entries = files + .filter((file) => !project || file.projectName === project) + .flatMap((file) => filterNode(file, context)) uiEntries.value = entries filteredFiles.value = entries.filter(isFileNode).map((f) => findById(f.id)!) } -export function* filterAll(search: SearchMatcher, filter: Filter) { - const project = currentProjectName.value - const tasks = getSortedRootTasks(projectSort.value) - - for (const node of tasks) { - if (project && node.projectName !== project) { - continue - } - yield* filterNode(node, search, filter) - } +/** + * Return the rows the full filter shows for `node` and its subtree: `node` first, then its visible + * descendants in tree order, or nothing when `node` does not pass the filter. + * + * `node` can be anywhere in the tree, because matches inherited from its ancestors are checked too. + */ +export function filterNode(node: UITaskTreeNode, context: FilterNodeContext): UITaskTreeNode[] { + const filteredTree = filterTreeNode(node, context, hasMatchingAncestor(node, context)) + return filteredTree ? revealKeptTree(filteredTree) : [] } -export function* filterNode(node: UITaskTreeNode, search: SearchMatcher, filter: Filter) { - // Parent node IDs that match or contain a matching descendant. - const treeNodes = new Set() - // Direct match state of visited parents, used to include their immediate children. - const parentsMap = new Map() - // Visited nodes with their effective match state, consumed bottom-up by filterParents. - const list: FilterResult[] = [] - // Matching file ID, or owning file ID when filtering an expanded subtree. - let fileId: string | undefined - - if (filter.onlyTests) { - for (const [match, child] of visitNode(node, treeNodes, (n) => matcher(n, search, filter))) { - list.push([match, child]) - } - } else { - for (const [match, child] of visitNode(node, treeNodes, (n) => matcher(n, search, filter))) { - if (isParentNode(child)) { - parentsMap.set(child.id, match) - if (isFileNode(child)) { - if (match) { - fileId = child.id - } - list.push([match, child]) - } else { - list.push([match || parentsMap.get(child.parentId) === true, child]) - } - } else { - list.push([match || parentsMap.get(child.parentId) === true, child]) - } - } - // when expanding a non-file node - if (!fileId && !isFileNode(node) && 'fileId' in node) { - fileId = node.fileId as string - const file = explorerTree.nodes.get(fileId) - if (file && matcher(file, search, filter)) { - treeNodes.add(fileId) - } - } +/** + * Return the kept part of the subtree under `node`, or `undefined` when `node` is not kept. + * + * A node is kept when an ancestor matched (`ancestorMatches`) or when the node or one of its descendants + * matched (`subtreeMatches`). Nothing is written. + */ +function filterTreeNode( + node: UITaskTreeNode, + context: FilterNodeContext, + ancestorMatches: boolean, +): FilteredTreeNode | undefined { + const nodeMatches = matchesNode(node, context) + const descendantsInheritMatch = ancestorMatches || nodeMatches + const children = isParentNode(node) + ? node.tasks + .map((child) => filterTreeNode(child, context, descendantsInheritMatch)) + .filter((child) => child !== undefined) + : [] + const subtreeMatches = nodeMatches || children.some((child) => child.subtreeMatches) + + if (!ancestorMatches && !subtreeMatches) { + return undefined } - // TODO: Let filterParents own this traversal-local state. - const filesToShow = new Set() - - const entries = [ - ...filterParents(list, filter.onlyTests, treeNodes, filesToShow, fileId), - ].reverse() - - // We show only the files and parents whose parent is expanded. - // Filtering will return all the nodes matching the filter and their parents. - // Once we've the tree, we need to remove the children from not expanded parents. - // For example, if we have a suite with only one test, when collapsing the suite node, - // we still need to show the suite, but the test must be removed from the list to render. - - const map = explorerTree.nodes - - // When searching, expand parent nodes of matching tests so they are visible - for (const id of treeNodes) { - const treeNode = map.get(id) - if (treeNode && 'expanded' in treeNode) { - treeNode.expanded = true - } + return { + node, + children, + subtreeMatches, } - - // collect files and all suites whose parent is expanded - const parents = new Set( - entries - .filter((e) => isFileNode(e) || (isParentNode(e) && map.get(e.parentId)?.expanded)) - .map((e) => e.id), - ) - - // collect files, and suites and tests whose parent is expanded - yield* entries.filter((node) => { - // all file nodes or children of expanded parents - return isFileNode(node) || (parents.has(node.parentId) && map.get(node.parentId)?.expanded) - }) } -function expandCollapseNode( - match: boolean, - child: FileTreeNode | ParentTreeNode, - treeNodes: Set, - collapseParents: boolean, - filesToShow: Set, -) { - if (collapseParents) { - if (isFileNode(child)) { - if (filesToShow.has(child.id)) { - return child - } - - return undefined - } - // show the parent if at least one child matches the filter - if (treeNodes.has(child.id)) { - const parent = explorerTree.nodes.get(child.parentId) - if (parent && isFileNode(parent)) { - filesToShow.add(parent.id) - } - - return child - } - } else { - // show the parent if matches the filter or at least one child matches the filter - if (match || treeNodes.has(child.id) || filesToShow.has(child.id)) { - const parent = explorerTree.nodes.get(child.parentId) - if (parent && isFileNode(parent)) { - filesToShow.add(parent.id) - } - - return child +function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext): boolean { + let parent = context.nodes.get(node.parentId) + while (parent) { + if (matchesNode(parent, context)) { + return true } + parent = context.nodes.get(parent.parentId) } + return false } -function* filterParents( - list: FilterResult[], - collapseParents: boolean, - treeNodes: Set, - filesToShow: Set, - nodeId?: string, -) { - for (let i = list.length - 1; i >= 0; i--) { - const [match, child] = list[i] - const isParent = isParentNode(child) - if ( - !collapseParents && - nodeId && - treeNodes.has(nodeId) && - 'fileId' in child && - child.fileId === nodeId - ) { - if (isParent) { - treeNodes.add(child.id) - } - let parent = explorerTree.nodes.get(child.parentId) - while (parent) { - treeNodes.add(parent.id) - if (isFileNode(parent)) { - filesToShow.add(parent.id) - } - parent = explorerTree.nodes.get(parent.parentId) - } - yield child - continue - } +function matchesNode(node: UITaskTreeNode, context: FilterNodeContext): boolean { + if (context.filter.onlyTests && node.type !== 'test') { + return false + } + const task = context.taskIdMap.get(node.id) + return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false +} - if (isParent) { - const node = expandCollapseNode(match, child, treeNodes, collapseParents, filesToShow) - if (node) { - yield node - } - } else if (match) { - const parent = explorerTree.nodes.get(child.parentId) - if (parent && isFileNode(parent)) { - filesToShow.add(parent.id) - } - yield child - } +/** + * Mark every kept parent expanded and return all kept nodes in tree order. + */ +function revealKeptTree(tree: FilteredTreeNode, entries: UITaskTreeNode[] = []): UITaskTreeNode[] { + entries.push(tree.node) + if (isParentNode(tree.node)) { + tree.node.expanded = true + } + for (const child of tree.children) { + revealKeptTree(child, entries) } + + return entries } -function matchState(task: Task, filter: Filter) { +function matchState(task: Task, filter: Filter, slowTestThreshold: number | undefined): boolean { if (filter.slow) { if (task.type === 'test') { - const threshold = config.value.slowTestThreshold if ( - typeof threshold === 'number' && + typeof slowTestThreshold === 'number' && typeof task.result?.duration === 'number' && - task.result.duration > threshold + task.result.duration > slowTestThreshold ) { return true } @@ -242,12 +161,17 @@ function matchState(task: Task, filter: Filter) { return false } -function matchTask(task: Task, search: SearchMatcher, filter: Filter) { +function matchTask( + task: Task, + search: SearchMatcher, + filter: Filter, + slowTestThreshold: number | undefined, +): boolean { // search and filter will apply together if (search(task)) { const hasStatusFilter = filter.success || filter.failed || filter.skipped || filter.slow if (hasStatusFilter) { - if (matchState(task, filter)) { + if (matchState(task, filter, slowTestThreshold)) { return true } } else { @@ -257,42 +181,3 @@ function matchTask(task: Task, search: SearchMatcher, filter: Filter) { return false } - -function* visitNode( - node: UITaskTreeNode, - treeNodes: Set, - matcher: (node: UITaskTreeNode) => boolean, -): Generator<[match: boolean, node: UITaskTreeNode]> { - const match = matcher(node) - - if (match) { - if (isTestNode(node)) { - let parent = explorerTree.nodes.get(node.parentId) - while (parent) { - treeNodes.add(parent.id) - parent = explorerTree.nodes.get(parent.parentId) - } - } else if (isFileNode(node)) { - treeNodes.add(node.id) - } else { - treeNodes.add(node.id) - let parent = explorerTree.nodes.get(node.parentId) - while (parent) { - treeNodes.add(parent.id) - parent = explorerTree.nodes.get(parent.parentId) - } - } - } - - yield [match, node] - if (isParentNode(node)) { - for (let i = 0; i < node.tasks.length; i++) { - yield* visitNode(node.tasks[i], treeNodes, matcher) - } - } -} - -function matcher(node: UITaskTreeNode, search: SearchMatcher, filter: Filter) { - const task = client.state.idMap.get(node.id) - return task ? matchTask(task, search, filter) : false -} diff --git a/packages/ui/client/composables/explorer/types.ts b/packages/ui/client/composables/explorer/types.ts index 72e3c5ab1758..9fb2cc731496 100644 --- a/packages/ui/client/composables/explorer/types.ts +++ b/packages/ui/client/composables/explorer/types.ts @@ -1,7 +1,5 @@ import type { RunMode, RunnerTask as Task, TaskState } from 'vitest' -export type FilterResult = [match: boolean, node: UITaskTreeNode] - export interface FilteredTests { failed: number success: number diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index de9b09bba4bd..00190bd7370c 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -12,10 +12,6 @@ import { explorerTree } from '~/composables/explorer/index' import { openedTreeItemsSet } from '~/composables/explorer/state' import { isSuite as isTaskSuite } from '~/utils/task' -export function isTestNode(node: UITaskTreeNode): node is TestTreeNode { - return node.type === 'test' -} - export function isRunningTestNode(node: UITaskTreeNode): node is TestTreeNode { return node.mode === 'run' && node.type === 'test' } @@ -42,7 +38,10 @@ export function isSlowTestTask(task: Task) { return typeof threshold === 'number' && duration > threshold } -export function getSortedRootTasks(sort: SortUIType, tasks = explorerTree.root.tasks) { +export function getSortedRootTasks( + tasks: readonly FileTreeNode[], + sort: SortUIType, +): FileTreeNode[] { const sorted = [...tasks] sorted.sort((a, b) => { @@ -253,3 +252,30 @@ export function removeNodeSubtree(nodes: Map, node: UITa } } } + +export function replaceSubtreeEntries( + entries: readonly UITaskTreeNode[], + node: UITaskTreeNode, + subtree: readonly UITaskTreeNode[], +): UITaskTreeNode[] { + const descendants = new Set() + collectDescendantIds(node, descendants) + const result: UITaskTreeNode[] = [] + for (const entry of entries) { + if (entry.id === node.id) { + result.push(...subtree) + } else if (!descendants.has(entry.id)) { + result.push(entry) + } + } + return result +} + +function collectDescendantIds(node: UITaskTreeNode, ids: Set): void { + if (isParentNode(node)) { + for (const child of node.tasks) { + ids.add(child.id) + collectDescendantIds(child, ids) + } + } +} diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts index c231b7ecb64e..45202e530142 100644 --- a/test/ui/test/ui.spec.ts +++ b/test/ui/test/ui.spec.ts @@ -565,6 +565,21 @@ async function testSuiteReport(page: Page) { .getByRole('button', { name: 'Expand successful suite', exact: true }) .click() await expect(getExplorerItem(page, 'successful child')).toBeVisible() + + // a suite match shows its whole subtree, also after re-expanding a nested suite + await page.getByPlaceholder('Search...').fill('nested failure') + await expect(getExplorerItem(page, 'successful suite')).toHaveCount(0) + await expect(getExplorerItem(page, 'failing nested child')).toBeVisible() + + const nestedSuite = getExplorerItem(page, 'failing nested suite') + await nestedSuite + .getByRole('button', { name: 'Collapse failing nested suite', exact: true }) + .dispatchEvent('click') + await expect(getExplorerItem(page, 'failing nested child')).not.toBeVisible() + await nestedSuite + .getByRole('button', { name: 'Expand failing nested suite', exact: true }) + .dispatchEvent('click') + await expect(getExplorerItem(page, 'failing nested child')).toBeVisible() } async function testTagsFilter(page: Page, options: { mode: 'ui' | 'static' }) {