From 0e6ac185f06d3d4314c94291c3d892d07f69a142 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:17:38 +0900 Subject: [PATCH 01/36] fix(ui): expand file matches during search Co-authored-by: Codex --- packages/ui/client/composables/explorer/filter.ts | 4 ++++ test/ui/test/ui.spec.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index bc9a315bfbe6..0aa59caccd34 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -92,6 +92,10 @@ export function* filterNode( // 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) + } } } diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts index d78a82394da4..4cf3a60348a9 100644 --- a/test/ui/test/ui.spec.ts +++ b/test/ui/test/ui.spec.ts @@ -501,6 +501,10 @@ async function testError(page: Page) { async function testSuiteReport(page: Page) { const report = page.getByTestId('report') + await expect(getExplorerItem(page, 'sample.test.ts')).toBeVisible() + await page.getByPlaceholder('Search...').fill('suite-report') + await expect(getExplorerItem(page, 'sample.test.ts')).toHaveCount(0) + await getExplorerItem(page, 'suite-report.test.ts').click() await expect(report).toContainText('before-all-marker') await expect(report).toContainText('direct-child-marker') From c7b0d345e1046c0011382ac0a4781de00101e90f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:06:28 +0900 Subject: [PATCH 02/36] docs(ui): clarify explorer state Co-authored-by: Codex --- packages/ui/client/composables/explorer/state.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/client/composables/explorer/state.ts b/packages/ui/client/composables/explorer/state.ts index dc2c83d3e32b..8690d66eebb8 100644 --- a/packages/ui/client/composables/explorer/state.ts +++ b/packages/ui/client/composables/explorer/state.ts @@ -9,8 +9,11 @@ import { createTagsFilter } from '../../../../vitest/src/runtime/runner/utils/ta import { config } from '../client' import { explorerTree } from './index' +// All root file nodes, independent of the current filter and expansion state. export const uiFiles = shallowRef([]) +// Flattened rows currently rendered by the explorer's virtual scroller. export const uiEntries = shallowRef([]) +// Persisted node IDs used to restore individually expanded branches. export const openedTreeItems = useLocalStorage( 'vitest-ui_task-tree-opened', [], From 52c14f39d271799c096c91811b780fd20bfea37d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:13:03 +0900 Subject: [PATCH 03/36] docs(ui): explain expanded entry collection Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 0da292044133..7e78842bc9cc 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -130,6 +130,12 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } } +/** + * Insert an expanded node's filtered children into the current flattened explorer entries. + * + * Children already present in the current entries are skipped to avoid duplicates. When + * expanding a file, the filtered children can include the file node itself. + */ function* collectExpandedNode( node: UITaskTreeNode, children: Set, From 197c5e7ab50dc9f1f7bbdcd31a36a01d2b588b4c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:14:53 +0900 Subject: [PATCH 04/36] docs(ui): clarify expanded entry rebuild Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 7e78842bc9cc..d6d510400016 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -131,10 +131,12 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } /** - * Insert an expanded node's filtered children into the current flattened explorer entries. + * Build the complete next explorer entry list by inserting an expanded node's filtered subtree. * - * Children already present in the current entries are skipped to avoid duplicates. When - * expanding a file, the filtered children can include the file node itself. + * `children` contains only entries produced by filtering the expanded subtree. This function + * walks the complete current `uiEntries`, inserts those children at `node`, and copies unrelated + * entries unchanged. Existing entries with the same IDs are skipped to avoid duplicates. When + * expanding a file, `children` can include the file node itself. */ function* collectExpandedNode( node: UITaskTreeNode, From bb02b383919d013d4f142122ec0def5aa9f64cb3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:15:47 +0900 Subject: [PATCH 05/36] docs(ui): describe expanded entry emission Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index d6d510400016..1bbd23796d9c 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -131,12 +131,13 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } /** - * Build the complete next explorer entry list by inserting an expanded node's filtered subtree. + * 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`, inserts those children at `node`, and copies unrelated - * entries unchanged. Existing entries with the same IDs are skipped to avoid duplicates. When - * expanding a file, `children` can include the file node itself. + * 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. */ function* collectExpandedNode( node: UITaskTreeNode, From 1351d11256de4de7fcbe6c076e884253f82792d8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:19:46 +0900 Subject: [PATCH 06/36] docs(ui): note expanded entry refactor Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 1bbd23796d9c..57f5d82d370a 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -143,6 +143,7 @@ function* collectExpandedNode( node: UITaskTreeNode, children: Set, ) { + // TODO: Make this a pure splice over explicit entries and keep expansion state changes in runExpandNode. const id = node.id const ids = new Set(Array.from(children).map(n => n.id)) From f2587f300cc3d624502d1dfcc4c30d86d854278a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:23:51 +0900 Subject: [PATCH 07/36] docs(ui): explain filter traversal state Co-authored-by: Codex --- packages/ui/client/composables/explorer/filter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 0aa59caccd34..56b720cc67b3 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -51,11 +51,13 @@ export function* filterNode( 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) { From daef937ed790c861ad04f77ca6ebabc1485920ab Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:32:32 +0900 Subject: [PATCH 08/36] docs(ui): move expanded entry todo Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 57f5d82d370a..ef36e15ae7a0 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -138,12 +138,14 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { * 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, ) { - // TODO: Make this a pure splice over explicit entries and keep expansion state changes in runExpandNode. const id = node.id const ids = new Set(Array.from(children).map(n => n.id)) From 065a11231e124a06a58ca37f3ec53df413824f79 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:52:11 +0900 Subject: [PATCH 09/36] docs(ui): note filter state ownership Co-authored-by: Codex --- packages/ui/client/composables/explorer/filter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 56b720cc67b3..c6dd43ef883c 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -101,6 +101,7 @@ export function* filterNode( } } + // TODO: Let filterParents own this traversal-local state. const filesToShow = new Set() const entries = [...filterParents( From 08f0f3a56baa7e541e185113dd56de5ba6fbb4c0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:42:08 +0900 Subject: [PATCH 10/36] refactor(ui): simplify explorer filtering Co-authored-by: Codex --- .../ui/client/composables/explorer/expand.ts | 46 ++- .../ui/client/composables/explorer/filter.ts | 300 +++++------------- 2 files changed, 104 insertions(+), 242 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index ef36e15ae7a0..759a8609900c 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,6 +1,6 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' import { findById } from '~/composables/client' -import { filterAll, filterNode } from '~/composables/explorer/filter' +import { createFilterNodeContext, filterAll, filterNode } from '~/composables/explorer/filter' import { explorerTree } from '~/composables/explorer/index' import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' import { createOrUpdateNode, createOrUpdateSuiteTask, isFileNode, isParentNode } from '~/composables/explorer/utils' @@ -52,11 +52,11 @@ export function runExpandNode( // the first node is itself only when it is a file const children = new Set(filterNode( node, - search, - filter, + filter.onlyTests, + createFilterNodeContext(search, filter), )) - const entries = [...collectExpandedNode(node, children)] + const entries = spliceExpandedEntries(uiEntries.value, node, children) 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 @@ -130,35 +130,25 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } } -/** - * 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( +function spliceExpandedEntries( + entries: readonly UITaskTreeNode[], node: UITaskTreeNode, - children: Set, + children: ReadonlySet, ) { - const id = node.id - const ids = new Set(Array.from(children).map(n => n.id)) + const childIds = new Set(Array.from(children, child => child.id)) + const nextEntries: UITaskTreeNode[] = [] - for (const child of uiEntries.value) { - if (child.id === id) { - child.expanded = true - if (!ids.has(child.id)) { - yield node + for (const entry of entries) { + if (entry.id === node.id) { + if (!childIds.has(entry.id)) { + nextEntries.push(entry) } - yield* children + nextEntries.push(...children) } - else if (!ids.has(child.id)) { - yield child + else if (!childIds.has(entry.id)) { + nextEntries.push(entry) } } + + return nextEntries } diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index c6dd43ef883c..859c28f34f7a 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -1,5 +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 { currentProjectName, filteredFiles, projectSort, uiEntries } from '~/composables/explorer/state' @@ -7,12 +7,37 @@ import { getSortedRootTasks, isFileNode, isParentNode, - isTestNode, } from '~/composables/explorer/utils' export function testMatcher(task: Task, search: SearchMatcher, filter: Filter) { - return task ? matchTask(task, search, filter) : false + return task ? matchTask(task, search, filter, config.value.slowTestThreshold) : false } + +interface FilterNodeContext { + nodes: ReadonlyMap + matches: (node: UITaskTreeNode) => boolean +} + +interface FilteredTreeNode { + node: UITaskTreeNode + children: FilteredTreeNode[] + subtreeMatches: boolean +} + +export function createFilterNodeContext( + search: SearchMatcher, + filter: Filter, +): FilterNodeContext { + const slowTestThreshold = config.value.slowTestThreshold + return { + nodes: explorerTree.nodes, + matches(node) { + const task = client.state.idMap.get(node.id) + return task ? matchTask(task, search, filter, slowTestThreshold) : false + }, + } +} + /** * Filter child nodes using search, filter and only tests. * @@ -37,200 +62,87 @@ export function* filterAll( ) { const project = currentProjectName.value const tasks = getSortedRootTasks(projectSort.value) + const context = createFilterNodeContext(search, filter) for (const node of tasks) { if (project && node.projectName !== project) { continue } - yield* filterNode(node, search, filter) + yield* filterNode(node, filter.onlyTests, context) } } export function* filterNode( node: UITaskTreeNode, - search: SearchMatcher, - filter: Filter, + onlyTests: boolean, + context: FilterNodeContext, ) { - // 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) - } - } + const file = isFileNode(node) + ? undefined + : 'fileId' in node + ? context.nodes.get(node.fileId as string) + : undefined + const ancestorMatches = !onlyTests && !!file && context.matches(file) + const filteredTree = filterTreeNode(node, onlyTests, context, ancestorMatches) + if (filteredTree) { + yield* flattenVisibleTree(filteredTree, isFileNode(node)) } - - // 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 - } - } - - // 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) - } +function filterTreeNode( + node: UITaskTreeNode, + onlyTests: boolean, + context: FilterNodeContext, + ancestorMatches: boolean, +): FilteredTreeNode | undefined { + const nodeMatches = (!onlyTests || node.type === 'test') && context.matches(node) + const descendantsInheritMatch = ancestorMatches || nodeMatches + const children = isParentNode(node) + ? node.tasks + .map(child => filterTreeNode( + child, + onlyTests, + context, + descendantsInheritMatch, + )) + .filter(child => child !== undefined) + : [] + const subtreeMatches = nodeMatches || children.some(child => child.subtreeMatches) + + if (!ancestorMatches && !subtreeMatches) { + return undefined + } - return child - } + if (isParentNode(node) && (ancestorMatches || subtreeMatches)) { + node.expanded = true } - 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 - } + return { + node, + children, + subtreeMatches, } } -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 - } - - 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 - } +function* flattenVisibleTree( + tree: FilteredTreeNode, + includeRoot: boolean, +): Generator { + if (includeRoot) { + yield tree.node + } + if (!isParentNode(tree.node) || !tree.node.expanded) { + return + } + for (const child of tree.children) { + yield* flattenVisibleTree(child, true) } } -function matchState(task: Task, filter: Filter) { +function matchState(task: Task, filter: Filter, slowTestThreshold: number | undefined) { if (filter.slow) { if (task.type === 'test') { - const threshold = config.value.slowTestThreshold - if (typeof threshold === 'number' && typeof task.result?.duration === 'number' && task.result.duration > threshold) { + if (typeof slowTestThreshold === 'number' && typeof task.result?.duration === 'number' && task.result.duration > slowTestThreshold) { return true } } @@ -258,12 +170,13 @@ function matchTask( task: Task, search: SearchMatcher, filter: Filter, + slowTestThreshold: number | undefined, ) { // 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 } } @@ -274,44 +187,3 @@ function matchTask( 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 -} From 394dc13ebb3e41aae63ff38a6d71c2d7881d9d97 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:20:15 +0900 Subject: [PATCH 11/36] refactor(ui): pass filter context explicitly Co-authored-by: Codex --- .../client/composables/explorer/collector.ts | 4 +- .../ui/client/composables/explorer/expand.ts | 13 +++-- .../ui/client/composables/explorer/filter.ts | 49 +++++++++++-------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/packages/ui/client/composables/explorer/collector.ts b/packages/ui/client/composables/explorer/collector.ts index a2ad8fb19285..090db102b8c3 100644 --- a/packages/ui/client/composables/explorer/collector.ts +++ b/packages/ui/client/composables/explorer/collector.ts @@ -2,7 +2,7 @@ import type { Arrayable } from '@vitest/utils' import type { RunnerTestFile as File, RunnerTask as Task, RunnerTaskResultPack as TaskResultPack, RunnerTestCase as Test, TestArtifact } from 'vitest' import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests, 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' @@ -385,7 +385,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++ diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 759a8609900c..bfb19274d446 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,6 +1,6 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' -import { findById } from '~/composables/client' -import { createFilterNodeContext, filterAll, filterNode } from '~/composables/explorer/filter' +import { client, config, findById } from '~/composables/client' +import { filterAll, filterNode } from '~/composables/explorer/filter' import { explorerTree } from '~/composables/explorer/index' import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' import { createOrUpdateNode, createOrUpdateSuiteTask, isFileNode, isParentNode } from '~/composables/explorer/utils' @@ -52,8 +52,13 @@ export function runExpandNode( // the first node is itself only when it is a file const children = new Set(filterNode( node, - filter.onlyTests, - createFilterNodeContext(search, filter), + { + nodes: explorerTree.nodes, + tasks: client.state.idMap, + search, + filter, + slowTestThreshold: config.value.slowTestThreshold, + }, )) const entries = spliceExpandedEntries(uiEntries.value, node, children) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 859c28f34f7a..9a2a69070028 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -9,13 +9,12 @@ import { isParentNode, } from '~/composables/explorer/utils' -export function testMatcher(task: Task, search: SearchMatcher, filter: Filter) { - return task ? matchTask(task, search, filter, config.value.slowTestThreshold) : false -} - -interface FilterNodeContext { +export interface FilterNodeContext { nodes: ReadonlyMap - matches: (node: UITaskTreeNode) => boolean + tasks: ReadonlyMap + search: SearchMatcher + filter: Filter + slowTestThreshold: number | undefined } interface FilteredTreeNode { @@ -24,18 +23,13 @@ interface FilteredTreeNode { subtreeMatches: boolean } -export function createFilterNodeContext( +export function testMatcher( + task: Task, search: SearchMatcher, filter: Filter, -): FilterNodeContext { - const slowTestThreshold = config.value.slowTestThreshold - return { - nodes: explorerTree.nodes, - matches(node) { - const task = client.state.idMap.get(node.id) - return task ? matchTask(task, search, filter, slowTestThreshold) : false - }, - } + slowTestThreshold: number | undefined, +) { + return matchTask(task, search, filter, slowTestThreshold) } /** @@ -62,27 +56,33 @@ export function* filterAll( ) { const project = currentProjectName.value const tasks = getSortedRootTasks(projectSort.value) - const context = createFilterNodeContext(search, filter) + const context: FilterNodeContext = { + nodes: explorerTree.nodes, + tasks: client.state.idMap, + search, + filter, + slowTestThreshold: config.value.slowTestThreshold, + } for (const node of tasks) { if (project && node.projectName !== project) { continue } - yield* filterNode(node, filter.onlyTests, context) + yield* filterNode(node, context) } } export function* filterNode( node: UITaskTreeNode, - onlyTests: boolean, context: FilterNodeContext, ) { + const { onlyTests } = context.filter const file = isFileNode(node) ? undefined : 'fileId' in node ? context.nodes.get(node.fileId as string) : undefined - const ancestorMatches = !onlyTests && !!file && context.matches(file) + const ancestorMatches = !onlyTests && !!file && matchesNode(file, context) const filteredTree = filterTreeNode(node, onlyTests, context, ancestorMatches) if (filteredTree) { yield* flattenVisibleTree(filteredTree, isFileNode(node)) @@ -95,7 +95,7 @@ function filterTreeNode( context: FilterNodeContext, ancestorMatches: boolean, ): FilteredTreeNode | undefined { - const nodeMatches = (!onlyTests || node.type === 'test') && context.matches(node) + const nodeMatches = (!onlyTests || node.type === 'test') && matchesNode(node, context) const descendantsInheritMatch = ancestorMatches || nodeMatches const children = isParentNode(node) ? node.tasks @@ -124,6 +124,13 @@ function filterTreeNode( } } +function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { + const task = context.tasks.get(node.id) + return task + ? matchTask(task, context.search, context.filter, context.slowTestThreshold) + : false +} + function* flattenVisibleTree( tree: FilteredTreeNode, includeRoot: boolean, From 8cfccabaf4aa26ddf3e8fd80320d0ccdd813516f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:23:51 +0900 Subject: [PATCH 12/36] refactor(ui): replace explorer generators Co-authored-by: Codex --- .../ui/client/composables/explorer/expand.ts | 19 +++++--- .../ui/client/composables/explorer/filter.ts | 44 ++++++++++++------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 0da292044133..fecb6a2a09eb 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -56,7 +56,7 @@ export function runExpandNode( filter, )) - const entries = [...collectExpandedNode(node, children)] + const entries = collectExpandedNode(node, children) 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 @@ -90,10 +90,10 @@ export function runExpandAll( filter: Filter, ) { expandAllNodes(explorerTree.root.tasks, false) - const entries = [...filterAll( + const entries = filterAll( search, filter, - )] + ) treeFilter.value.expandAll = false openedTreeItems.value = [] uiEntries.value = entries @@ -130,23 +130,28 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } } -function* collectExpandedNode( +function collectExpandedNode( node: UITaskTreeNode, children: Set, ) { const id = node.id const ids = new Set(Array.from(children).map(n => n.id)) + const entries: UITaskTreeNode[] = [] for (const child of uiEntries.value) { if (child.id === id) { child.expanded = true if (!ids.has(child.id)) { - yield node + entries.push(node) + } + for (const child of children) { + entries.push(child) } - yield* children } else if (!ids.has(child.id)) { - yield child + entries.push(child) } } + + return entries } diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index bc9a315bfbe6..ecf7e78b6be8 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -23,30 +23,35 @@ export function runFilter( search: SearchMatcher, filter: Filter, ) { - const entries = [...filterAll( + const entries = filterAll( search, filter, - )] + ) uiEntries.value = entries filteredFiles.value = entries.filter(isFileNode).map(f => findById(f.id)!) } -export function* filterAll( +export function filterAll( search: SearchMatcher, filter: Filter, ) { const project = currentProjectName.value const tasks = getSortedRootTasks(projectSort.value) + const entries: UITaskTreeNode[] = [] for (const node of tasks) { if (project && node.projectName !== project) { continue } - yield* filterNode(node, search, filter) + for (const entry of filterNode(node, search, filter)) { + entries.push(entry) + } } + + return entries } -export function* filterNode( +export function filterNode( node: UITaskTreeNode, search: SearchMatcher, filter: Filter, @@ -97,13 +102,13 @@ export function* filterNode( const filesToShow = new Set() - const entries = [...filterParents( + const entries = filterParents( list, filter.onlyTests, treeNodes, filesToShow, fileId, - )].reverse() + ).reverse() // We show only the files and parents whose parent is expanded. // Filtering will return all the nodes matching the filter and their parents. @@ -127,7 +132,7 @@ export function* filterNode( ) // collect files, and suites and tests whose parent is expanded - yield* entries.filter((node) => { + return entries.filter((node) => { // all file nodes or children of expanded parents return isFileNode(node) || (parents.has(node.parentId) && map.get(node.parentId)?.expanded) }) @@ -171,13 +176,15 @@ function expandCollapseNode( } } -function* filterParents( +function filterParents( list: FilterResult[], collapseParents: boolean, treeNodes: Set, filesToShow: Set, nodeId?: string, ) { + const entries: UITaskTreeNode[] = [] + for (let i = list.length - 1; i >= 0; i--) { const [match, child] = list[i] const isParent = isParentNode(child) @@ -193,7 +200,7 @@ function* filterParents( } parent = explorerTree.nodes.get(parent.parentId) } - yield child + entries.push(child) continue } @@ -206,7 +213,7 @@ function* filterParents( filesToShow, ) if (node) { - yield node + entries.push(node) } } else if (match) { @@ -214,9 +221,11 @@ function* filterParents( if (parent && isFileNode(parent)) { filesToShow.add(parent.id) } - yield child + entries.push(child) } } + + return entries } function matchState(task: Task, filter: Filter) { @@ -268,11 +277,12 @@ function matchTask( return false } -function* visitNode( +function visitNode( node: UITaskTreeNode, treeNodes: Set, matcher: (node: UITaskTreeNode) => boolean, -): Generator<[match: boolean, node: UITaskTreeNode]> { + entries: FilterResult[] = [], +) { const match = matcher(node) if (match) { @@ -296,12 +306,14 @@ function* visitNode( } } - yield [match, node] + entries.push([match, node]) if (isParentNode(node)) { for (let i = 0; i < node.tasks.length; i++) { - yield* visitNode(node.tasks[i], treeNodes, matcher) + visitNode(node.tasks[i], treeNodes, matcher, entries) } } + + return entries } function matcher(node: UITaskTreeNode, search: SearchMatcher, filter: Filter) { From a0b705fe416e360b8756fd9d69d445c0d6146b72 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:26:11 +0900 Subject: [PATCH 13/36] refactor(ui): share explorer subtree updates Co-authored-by: Codex --- .../client/composables/explorer/collapse.ts | 40 ++----------------- .../ui/client/composables/explorer/entries.ts | 23 +++++++++++ .../ui/client/composables/explorer/expand.ts | 33 +++------------ 3 files changed, 31 insertions(+), 65 deletions(-) create mode 100644 packages/ui/client/composables/explorer/entries.ts diff --git a/packages/ui/client/composables/explorer/collapse.ts b/packages/ui/client/composables/explorer/collapse.ts index e0bff4ab95de..6808a9d6877c 100644 --- a/packages/ui/client/composables/explorer/collapse.ts +++ b/packages/ui/client/composables/explorer/collapse.ts @@ -1,4 +1,5 @@ import type { UITaskTreeNode } from '~/composables/explorer/types' +import { replaceSubtreeEntries } from '~/composables/explorer/entries' import { explorerTree } from '~/composables/explorer/index' import { openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' import { isFileNode, isParentNode } from '~/composables/explorer/utils' @@ -24,9 +25,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 +70,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/entries.ts b/packages/ui/client/composables/explorer/entries.ts new file mode 100644 index 000000000000..6c9296c1beb3 --- /dev/null +++ b/packages/ui/client/composables/explorer/entries.ts @@ -0,0 +1,23 @@ +import type { UITaskTreeNode } from '~/composables/explorer/types' + +export function replaceSubtreeEntries( + entries: readonly UITaskTreeNode[], + node: UITaskTreeNode, + subtree: readonly UITaskTreeNode[], +) { + const start = entries.findIndex(entry => entry.id === node.id) + if (start < 0) { + return [...entries] + } + + let end = start + 1 + while (end < entries.length && entries[end].indent > node.indent) { + end++ + } + + return [ + ...entries.slice(0, start), + ...subtree, + ...entries.slice(end), + ] +} diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index bfb19274d446..52f2583fa928 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,5 +1,6 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' import { client, config, findById } from '~/composables/client' +import { replaceSubtreeEntries } from '~/composables/explorer/entries' import { filterAll, filterNode } from '~/composables/explorer/filter' import { explorerTree } from '~/composables/explorer/index' import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' @@ -48,9 +49,7 @@ export function runExpandNode( 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( + const filteredSubtree = [...filterNode( node, { nodes: explorerTree.nodes, @@ -59,9 +58,10 @@ export function runExpandNode( filter, slowTestThreshold: config.value.slowTestThreshold, }, - )) + )] - const entries = spliceExpandedEntries(uiEntries.value, node, children) + const subtree = isFileNode(node) ? filteredSubtree : [node, ...filteredSubtree] + 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 @@ -134,26 +134,3 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { openedTreeItems.value = [] } } - -function spliceExpandedEntries( - entries: readonly UITaskTreeNode[], - node: UITaskTreeNode, - children: ReadonlySet, -) { - const childIds = new Set(Array.from(children, child => child.id)) - const nextEntries: UITaskTreeNode[] = [] - - for (const entry of entries) { - if (entry.id === node.id) { - if (!childIds.has(entry.id)) { - nextEntries.push(entry) - } - nextEntries.push(...children) - } - else if (!childIds.has(entry.id)) { - nextEntries.push(entry) - } - } - - return nextEntries -} From 0ec87cac67671d494ab7714f16ea5a7dd035ec87 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:29:02 +0900 Subject: [PATCH 14/36] refactor(ui): remove explorer generators Co-authored-by: Codex --- .../client/composables/explorer/collapse.ts | 23 +++++++++++++------ .../client/composables/explorer/collector.ts | 17 +++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/ui/client/composables/explorer/collapse.ts b/packages/ui/client/composables/explorer/collapse.ts index e0bff4ab95de..a1a657ca64c4 100644 --- a/packages/ui/client/composables/explorer/collapse.ts +++ b/packages/ui/client/composables/explorer/collapse.ts @@ -26,7 +26,7 @@ export function runCollapseNode(id: string) { const treeItems = new Set(openedTreeItems.value) treeItems.delete(node.id) - const entries = [...collectCollapseNode(node)] + const entries = collectCollapseNode(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 @@ -69,28 +69,35 @@ function collapseAllNodes(nodes: UITaskTreeNode[]) { } } -function* collectChildNodes(node: UITaskTreeNode, itself: boolean): Generator { +function collectChildNodes( + node: UITaskTreeNode, + itself: boolean, + nodeIds: string[] = [], +) { if (itself) { - yield node.id + nodeIds.push(node.id) } if (isParentNode(node)) { for (let i = 0; i < node.tasks.length; i++) { - yield* collectChildNodes(node.tasks[i], true) + collectChildNodes(node.tasks[i], true, nodeIds) } } + + return nodeIds } -function* collectCollapseNode(node: UITaskTreeNode) { +function collectCollapseNode(node: UITaskTreeNode) { const id = node.id // collect children to remove from the list const childNodes = new Set(collectChildNodes(node, false)) + const entries: UITaskTreeNode[] = [] 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 + entries.push(child) continue } @@ -101,6 +108,8 @@ function* collectCollapseNode(node: UITaskTreeNode) { } // return the node - yield child + entries.push(child) } + + return entries } diff --git a/packages/ui/client/composables/explorer/collector.ts b/packages/ui/client/composables/explorer/collector.ts index a2ad8fb19285..79128f3bd4d5 100644 --- a/packages/ui/client/composables/explorer/collector.ts +++ b/packages/ui/client/composables/explorer/collector.ts @@ -121,14 +121,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') { @@ -477,16 +473,21 @@ export function collectTestsTotalData( return filesSummary } -function* testsCollector(suite: Arrayable): Generator { +function testsCollector( + suite: Arrayable, + tests: 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 + tests.push(s) } else { - yield* testsCollector(s.tasks) + testsCollector(s.tasks, tests) } } + + return tests } From ae4388edf639c89bca5c2f0c678a17911a18166a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:42:52 +0900 Subject: [PATCH 15/36] refactor(ui): clarify test accumulator Co-authored-by: Codex --- packages/ui/client/composables/explorer/collector.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/explorer/collector.ts b/packages/ui/client/composables/explorer/collector.ts index 79128f3bd4d5..ccbc511f87fd 100644 --- a/packages/ui/client/composables/explorer/collector.ts +++ b/packages/ui/client/composables/explorer/collector.ts @@ -475,19 +475,19 @@ export function collectTestsTotalData( function testsCollector( suite: Arrayable, - tests: Test[] = [], + collectedTests: Test[] = [], ) { const arraySuites = toArray(suite) let s: Task for (let i = 0; i < arraySuites.length; i++) { s = arraySuites[i] if (s.type === 'test') { - tests.push(s) + collectedTests.push(s) } else { - testsCollector(s.tasks, tests) + testsCollector(s.tasks, collectedTests) } } - return tests + return collectedTests } From 342ae4ad52f5e3688ec256d38dd26d77f7f9765b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:43:53 +0900 Subject: [PATCH 16/36] refactor(ui): clarify node accumulator Co-authored-by: Codex --- packages/ui/client/composables/explorer/collapse.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/explorer/collapse.ts b/packages/ui/client/composables/explorer/collapse.ts index a1a657ca64c4..98278550359e 100644 --- a/packages/ui/client/composables/explorer/collapse.ts +++ b/packages/ui/client/composables/explorer/collapse.ts @@ -72,19 +72,19 @@ function collapseAllNodes(nodes: UITaskTreeNode[]) { function collectChildNodes( node: UITaskTreeNode, itself: boolean, - nodeIds: string[] = [], + collectedNodeIds: string[] = [], ) { if (itself) { - nodeIds.push(node.id) + collectedNodeIds.push(node.id) } if (isParentNode(node)) { for (let i = 0; i < node.tasks.length; i++) { - collectChildNodes(node.tasks[i], true, nodeIds) + collectChildNodes(node.tasks[i], true, collectedNodeIds) } } - return nodeIds + return collectedNodeIds } function collectCollapseNode(node: UITaskTreeNode) { From fdb76e06f6af0ac2252ff2d7e9a54d6c3a8b19e8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:09:00 +0900 Subject: [PATCH 17/36] refactor(ui): clarify visit results Co-authored-by: Codex --- packages/ui/client/composables/explorer/filter.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 03d52c40bf27..ebff5a2eeb69 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -288,7 +288,7 @@ function visitNode( node: UITaskTreeNode, treeNodes: Set, matcher: (node: UITaskTreeNode) => boolean, - entries: FilterResult[] = [], + results: FilterResult[] = [], ) { const match = matcher(node) @@ -313,14 +313,14 @@ function visitNode( } } - entries.push([match, node]) + results.push([match, node]) if (isParentNode(node)) { for (let i = 0; i < node.tasks.length; i++) { - visitNode(node.tasks[i], treeNodes, matcher, entries) + visitNode(node.tasks[i], treeNodes, matcher, results) } } - return entries + return results } function matcher(node: UITaskTreeNode, search: SearchMatcher, filter: Filter) { From d0435dab303e69da12f52b62b95adfb9aca7051c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:10:16 +0900 Subject: [PATCH 18/36] docs(ui): preserve expanded entry todo Co-authored-by: Codex --- packages/ui/client/composables/explorer/expand.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index fecb6a2a09eb..eb12571d16df 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -130,6 +130,18 @@ function expandAllNodes(nodes: UITaskTreeNode[], updateState: boolean) { } } +/** + * Build the complete next explorer entry list by adding 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 adds 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, From 633ab4decd05985cc550dbfc7d5f6730382ab0e1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:42:43 +0900 Subject: [PATCH 19/36] refactor(ui): remove unused explorer filter exports Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/types.ts | 2 -- packages/ui/client/composables/explorer/utils.ts | 4 ---- 2 files changed, 6 deletions(-) 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..d65443245bfb 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' } From c6766abe3f958b7cbe0fa89f7305f5b304ab81f3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:42:43 +0900 Subject: [PATCH 20/36] fix(ui): inherit suite matches when expanding a node Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/filter.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index de15cef93f0d..8a87f082a6cd 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -71,12 +71,7 @@ export function filterAll(search: SearchMatcher, filter: Filter) { export function filterNode(node: UITaskTreeNode, context: FilterNodeContext) { const { onlyTests } = context.filter - const file = isFileNode(node) - ? undefined - : 'fileId' in node - ? context.nodes.get(node.fileId as string) - : undefined - const ancestorMatches = !onlyTests && !!file && matchesNode(file, context) + const ancestorMatches = !onlyTests && hasMatchingAncestor(node, context) const filteredTree = filterTreeNode(node, onlyTests, context, ancestorMatches) return filteredTree ? flattenVisibleTree(filteredTree, isFileNode(node)) : [] } @@ -100,7 +95,7 @@ function filterTreeNode( return undefined } - if (isParentNode(node) && (ancestorMatches || subtreeMatches)) { + if (isParentNode(node)) { node.expanded = true } @@ -111,6 +106,17 @@ function filterTreeNode( } } +function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext) { + let parent = context.nodes.get(node.parentId) + while (parent) { + if (matchesNode(parent, context)) { + return true + } + parent = context.nodes.get(parent.parentId) + } + return false +} + function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { const task = context.tasks.get(node.id) return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false From 6412c04e18bea35f0e47752d5e5ca7dfd3454f8d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:34:58 +0900 Subject: [PATCH 21/36] docs(ui): document replaceSubtreeEntries Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/entries.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/client/composables/explorer/entries.ts b/packages/ui/client/composables/explorer/entries.ts index 7e39dd5cbbc7..a928d0d4d184 100644 --- a/packages/ui/client/composables/explorer/entries.ts +++ b/packages/ui/client/composables/explorer/entries.ts @@ -1,5 +1,10 @@ import type { UITaskTreeNode } from '~/composables/explorer/types' +/** + * Replace the rows of `node` and its visible descendants with `subtree`. + * + * `entries` must be in tree order, so descendants are the rows right after `node` with a larger indent. + */ export function replaceSubtreeEntries( entries: readonly UITaskTreeNode[], node: UITaskTreeNode, From 839447a6a3782c859f1c9b3a6e7876363c589cf3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:46:39 +0900 Subject: [PATCH 22/36] refactor(ui): replace explorer subtree by descendant ids Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/entries.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/ui/client/composables/explorer/entries.ts b/packages/ui/client/composables/explorer/entries.ts index a928d0d4d184..757fa8120cd9 100644 --- a/packages/ui/client/composables/explorer/entries.ts +++ b/packages/ui/client/composables/explorer/entries.ts @@ -1,24 +1,29 @@ import type { UITaskTreeNode } from '~/composables/explorer/types' +import { isParentNode } from '~/composables/explorer/utils' -/** - * Replace the rows of `node` and its visible descendants with `subtree`. - * - * `entries` must be in tree order, so descendants are the rows right after `node` with a larger indent. - */ export function replaceSubtreeEntries( entries: readonly UITaskTreeNode[], node: UITaskTreeNode, subtree: readonly UITaskTreeNode[], ) { - const start = entries.findIndex((entry) => entry.id === node.id) - if (start < 0) { - return [...entries] + 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 +} - let end = start + 1 - while (end < entries.length && entries[end].indent > node.indent) { - end++ +function collectDescendantIds(node: UITaskTreeNode, ids: Set) { + if (isParentNode(node)) { + for (const child of node.tasks) { + ids.add(child.id) + collectDescendantIds(child, ids) + } } - - return [...entries.slice(0, start), ...subtree, ...entries.slice(end)] } From 897dc4c9d900ee204095f485f060861b4f601002 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:48:42 +0900 Subject: [PATCH 23/36] refactor(ui): move replaceSubtreeEntries to explorer utils Co-Authored-By: Claude Opus 5.5 --- .../client/composables/explorer/collapse.ts | 3 +- .../ui/client/composables/explorer/entries.ts | 29 ------------------- .../ui/client/composables/explorer/expand.ts | 2 +- .../ui/client/composables/explorer/utils.ts | 27 +++++++++++++++++ 4 files changed, 29 insertions(+), 32 deletions(-) delete mode 100644 packages/ui/client/composables/explorer/entries.ts diff --git a/packages/ui/client/composables/explorer/collapse.ts b/packages/ui/client/composables/explorer/collapse.ts index 603231aa9092..7ba11e0a0d97 100644 --- a/packages/ui/client/composables/explorer/collapse.ts +++ b/packages/ui/client/composables/explorer/collapse.ts @@ -1,8 +1,7 @@ import type { UITaskTreeNode } from '~/composables/explorer/types' -import { replaceSubtreeEntries } from '~/composables/explorer/entries' 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. diff --git a/packages/ui/client/composables/explorer/entries.ts b/packages/ui/client/composables/explorer/entries.ts deleted file mode 100644 index 757fa8120cd9..000000000000 --- a/packages/ui/client/composables/explorer/entries.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { UITaskTreeNode } from '~/composables/explorer/types' -import { isParentNode } from '~/composables/explorer/utils' - -export function replaceSubtreeEntries( - entries: readonly UITaskTreeNode[], - node: UITaskTreeNode, - subtree: readonly 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) { - if (isParentNode(node)) { - for (const child of node.tasks) { - ids.add(child.id) - collectDescendantIds(child, ids) - } - } -} diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 81618ff1f760..3ea0260a3cfa 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,6 +1,5 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' import { client, config, findById } from '~/composables/client' -import { replaceSubtreeEntries } from '~/composables/explorer/entries' import { filterAll, filterNode } from '~/composables/explorer/filter' import { explorerTree } from '~/composables/explorer/index' import { filteredFiles, openedTreeItems, treeFilter, uiEntries } from '~/composables/explorer/state' @@ -9,6 +8,7 @@ import { createOrUpdateSuiteTask, isFileNode, isParentNode, + replaceSubtreeEntries, } from '~/composables/explorer/utils' /** diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index d65443245bfb..d82e509a3909 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -249,3 +249,30 @@ export function removeNodeSubtree(nodes: Map, node: UITa } } } + +export function replaceSubtreeEntries( + entries: readonly UITaskTreeNode[], + node: UITaskTreeNode, + subtree: readonly 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) { + if (isParentNode(node)) { + for (const child of node.tasks) { + ids.add(child.id) + collectDescendantIds(child, ids) + } + } +} From 880aa77b75b6f04c693557ad375971f9e05c7f3a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:52:47 +0900 Subject: [PATCH 24/36] refactor(ui): include the root row in filtered subtrees Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/expand.ts | 5 ++--- packages/ui/client/composables/explorer/filter.ts | 14 ++++---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index 3ea0260a3cfa..d86e1b9e0ba2 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -47,7 +47,7 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) const treeItems = new Set(openedTreeItems.value) treeItems.add(node.id) - const filteredSubtree = filterNode(node, { + const subtree = filterNode(node, { nodes: explorerTree.nodes, tasks: client.state.idMap, search, @@ -55,8 +55,7 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) slowTestThreshold: config.value.slowTestThreshold, }) - const subtree = isFileNode(node) ? filteredSubtree : [node, ...filteredSubtree] - const entries = replaceSubtreeEntries(uiEntries.value, node, subtree) + const entries = replaceSubtreeEntries(uiEntries.value, node, subtree.length ? subtree : [node]) 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 diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 8a87f082a6cd..9b635352b47c 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -73,7 +73,7 @@ export function filterNode(node: UITaskTreeNode, context: FilterNodeContext) { const { onlyTests } = context.filter const ancestorMatches = !onlyTests && hasMatchingAncestor(node, context) const filteredTree = filterTreeNode(node, onlyTests, context, ancestorMatches) - return filteredTree ? flattenVisibleTree(filteredTree, isFileNode(node)) : [] + return filteredTree ? flattenVisibleTree(filteredTree) : [] } function filterTreeNode( @@ -122,19 +122,13 @@ function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false } -function flattenVisibleTree( - tree: FilteredTreeNode, - includeRoot: boolean, - entries: UITaskTreeNode[] = [], -) { - if (includeRoot) { - entries.push(tree.node) - } +function flattenVisibleTree(tree: FilteredTreeNode, entries: UITaskTreeNode[] = []) { + entries.push(tree.node) if (!isParentNode(tree.node) || !tree.node.expanded) { return entries } for (const child of tree.children) { - flattenVisibleTree(child, true, entries) + flattenVisibleTree(child, entries) } return entries From a7afdf82f4301a7aef0cfdd78146574b1f53863b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:58:13 +0900 Subject: [PATCH 25/36] refactor(ui): drop the expand fallback for filtered-out nodes Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/expand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index d86e1b9e0ba2..e04e5d836375 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -55,7 +55,7 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) slowTestThreshold: config.value.slowTestThreshold, }) - const entries = replaceSubtreeEntries(uiEntries.value, node, subtree.length ? subtree : [node]) + 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 From af2356152c79517791a53eef5c02e9c5b7b1a667 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:59:18 +0900 Subject: [PATCH 26/36] refactor(ui): unexport FilterNodeContext Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 9b635352b47c..8cfb5732ba26 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -10,7 +10,7 @@ import { } from '~/composables/explorer/state' import { getSortedRootTasks, isFileNode, isParentNode } from '~/composables/explorer/utils' -export interface FilterNodeContext { +interface FilterNodeContext { nodes: ReadonlyMap tasks: ReadonlyMap search: SearchMatcher From 0e40366fb27e79c707e2f0773be61a00fdb1f2f6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:36:30 +0900 Subject: [PATCH 27/36] refactor(ui): handle onlyTests in matchesNode Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/filter.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 8cfb5732ba26..b11df51d9fbc 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -70,23 +70,20 @@ export function filterAll(search: SearchMatcher, filter: Filter) { } export function filterNode(node: UITaskTreeNode, context: FilterNodeContext) { - const { onlyTests } = context.filter - const ancestorMatches = !onlyTests && hasMatchingAncestor(node, context) - const filteredTree = filterTreeNode(node, onlyTests, context, ancestorMatches) + const filteredTree = filterTreeNode(node, context, hasMatchingAncestor(node, context)) return filteredTree ? flattenVisibleTree(filteredTree) : [] } function filterTreeNode( node: UITaskTreeNode, - onlyTests: boolean, context: FilterNodeContext, ancestorMatches: boolean, ): FilteredTreeNode | undefined { - const nodeMatches = (!onlyTests || node.type === 'test') && matchesNode(node, context) + const nodeMatches = matchesNode(node, context) const descendantsInheritMatch = ancestorMatches || nodeMatches const children = isParentNode(node) ? node.tasks - .map((child) => filterTreeNode(child, onlyTests, context, descendantsInheritMatch)) + .map((child) => filterTreeNode(child, context, descendantsInheritMatch)) .filter((child) => child !== undefined) : [] const subtreeMatches = nodeMatches || children.some((child) => child.subtreeMatches) @@ -118,6 +115,9 @@ function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext) { } function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { + if (context.filter.onlyTests && node.type !== 'test') { + return false + } const task = context.tasks.get(node.id) return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false } From 2f36b6a2d2566a85cf5063f88e46d6404a3bf8c7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:42:48 +0900 Subject: [PATCH 28/36] docs(ui): document explorer filter functions Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/filter.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index b11df51d9fbc..683823b34474 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -34,7 +34,7 @@ export function testMatcher( } /** - * Filter child nodes using search, filter and only tests. + * Rebuild the explorer rows for the whole tree and publish them to `uiEntries` and `filteredFiles`. * * @param search The search applied. * @param filter The filter applied. @@ -45,6 +45,11 @@ export function runFilter(search: SearchMatcher, filter: Filter) { filteredFiles.value = entries.filter(isFileNode).map((f) => findById(f.id)!) } +/** + * Return the explorer rows for every file of the current project, in sort order. + * + * Kept parents are marked expanded. Nothing else is written. + */ export function filterAll(search: SearchMatcher, filter: Filter) { const project = currentProjectName.value const tasks = getSortedRootTasks(projectSort.value) @@ -69,11 +74,23 @@ export function filterAll(search: SearchMatcher, filter: Filter) { return entries } +/** + * 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) { const filteredTree = filterTreeNode(node, context, hasMatchingAncestor(node, context)) return filteredTree ? flattenVisibleTree(filteredTree) : [] } +/** + * 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`). Kept parents are marked expanded. + */ function filterTreeNode( node: UITaskTreeNode, context: FilterNodeContext, From 2fc9a86a124223b9240586d81db5a608446e7050 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:43:47 +0900 Subject: [PATCH 29/36] docs(ui): drop redundant runFilter params Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/filter.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 683823b34474..d2eb533840a2 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -35,9 +35,6 @@ export function testMatcher( /** * Rebuild the explorer rows for the whole tree and publish them to `uiEntries` and `filteredFiles`. - * - * @param search The search applied. - * @param filter The filter applied. */ export function runFilter(search: SearchMatcher, filter: Filter) { const entries = filterAll(search, filter) From 58b7ab8277332ad0d548dc8e44073c816a8e2b6e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:46:13 +0900 Subject: [PATCH 30/36] refactor(ui): pass root tasks to getSortedRootTasks explicitly Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/filter.ts | 5 +++-- packages/ui/client/composables/explorer/utils.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index d2eb533840a2..531aee6075c0 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -45,11 +45,12 @@ export function runFilter(search: SearchMatcher, filter: Filter) { /** * Return the explorer rows for every file of the current project, in sort order. * - * Kept parents are marked expanded. Nothing else is written. + * Reads the current project, sort order, explorer tree, task state, and config. Kept parents are marked + * expanded, and nothing else is written. */ export function filterAll(search: SearchMatcher, filter: Filter) { const project = currentProjectName.value - const tasks = getSortedRootTasks(projectSort.value) + const tasks = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) const entries: UITaskTreeNode[] = [] const context: FilterNodeContext = { nodes: explorerTree.nodes, diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index d82e509a3909..a22d5fe136f7 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -38,7 +38,7 @@ 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) { const sorted = [...tasks] sorted.sort((a, b) => { From b4b6ea54e5c4100c60b7a5457f07205d28c3de4d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:48:58 +0900 Subject: [PATCH 31/36] refactor(ui): annotate explorer filter return types Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/filter.ts | 21 +++++++++++-------- .../ui/client/composables/explorer/utils.ts | 9 +++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 531aee6075c0..c5156900c8e4 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -29,14 +29,14 @@ export function testMatcher( search: SearchMatcher, filter: Filter, slowTestThreshold: number | undefined, -) { +): boolean { return matchTask(task, search, filter, slowTestThreshold) } /** * Rebuild the explorer rows for the whole tree and publish them to `uiEntries` and `filteredFiles`. */ -export function runFilter(search: SearchMatcher, filter: Filter) { +export function runFilter(search: SearchMatcher, filter: Filter): void { const entries = filterAll(search, filter) uiEntries.value = entries filteredFiles.value = entries.filter(isFileNode).map((f) => findById(f.id)!) @@ -48,7 +48,7 @@ export function runFilter(search: SearchMatcher, filter: Filter) { * Reads the current project, sort order, explorer tree, task state, and config. Kept parents are marked * expanded, and nothing else is written. */ -export function filterAll(search: SearchMatcher, filter: Filter) { +export function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { const project = currentProjectName.value const tasks = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) const entries: UITaskTreeNode[] = [] @@ -78,7 +78,7 @@ export function filterAll(search: SearchMatcher, filter: Filter) { * * `node` can be anywhere in the tree, because matches inherited from its ancestors are checked too. */ -export function filterNode(node: UITaskTreeNode, context: FilterNodeContext) { +export function filterNode(node: UITaskTreeNode, context: FilterNodeContext): UITaskTreeNode[] { const filteredTree = filterTreeNode(node, context, hasMatchingAncestor(node, context)) return filteredTree ? flattenVisibleTree(filteredTree) : [] } @@ -118,7 +118,7 @@ function filterTreeNode( } } -function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext) { +function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext): boolean { let parent = context.nodes.get(node.parentId) while (parent) { if (matchesNode(parent, context)) { @@ -129,7 +129,7 @@ function hasMatchingAncestor(node: UITaskTreeNode, context: FilterNodeContext) { return false } -function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { +function matchesNode(node: UITaskTreeNode, context: FilterNodeContext): boolean { if (context.filter.onlyTests && node.type !== 'test') { return false } @@ -137,7 +137,10 @@ function matchesNode(node: UITaskTreeNode, context: FilterNodeContext) { return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false } -function flattenVisibleTree(tree: FilteredTreeNode, entries: UITaskTreeNode[] = []) { +function flattenVisibleTree( + tree: FilteredTreeNode, + entries: UITaskTreeNode[] = [], +): UITaskTreeNode[] { entries.push(tree.node) if (!isParentNode(tree.node) || !tree.node.expanded) { return entries @@ -149,7 +152,7 @@ function flattenVisibleTree(tree: FilteredTreeNode, entries: UITaskTreeNode[] = return entries } -function matchState(task: Task, filter: Filter, slowTestThreshold: number | undefined) { +function matchState(task: Task, filter: Filter, slowTestThreshold: number | undefined): boolean { if (filter.slow) { if (task.type === 'test') { if ( @@ -185,7 +188,7 @@ function matchTask( 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 diff --git a/packages/ui/client/composables/explorer/utils.ts b/packages/ui/client/composables/explorer/utils.ts index a22d5fe136f7..00190bd7370c 100644 --- a/packages/ui/client/composables/explorer/utils.ts +++ b/packages/ui/client/composables/explorer/utils.ts @@ -38,7 +38,10 @@ export function isSlowTestTask(task: Task) { return typeof threshold === 'number' && duration > threshold } -export function getSortedRootTasks(tasks: readonly FileTreeNode[], sort: SortUIType) { +export function getSortedRootTasks( + tasks: readonly FileTreeNode[], + sort: SortUIType, +): FileTreeNode[] { const sorted = [...tasks] sorted.sort((a, b) => { @@ -254,7 +257,7 @@ export function replaceSubtreeEntries( entries: readonly UITaskTreeNode[], node: UITaskTreeNode, subtree: readonly UITaskTreeNode[], -) { +): UITaskTreeNode[] { const descendants = new Set() collectDescendantIds(node, descendants) const result: UITaskTreeNode[] = [] @@ -268,7 +271,7 @@ export function replaceSubtreeEntries( return result } -function collectDescendantIds(node: UITaskTreeNode, ids: Set) { +function collectDescendantIds(node: UITaskTreeNode, ids: Set): void { if (isParentNode(node)) { for (const child of node.tasks) { ids.add(child.id) From ecb2ac7c9abff86f5ec09ce55d2c7235545549d2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:53:25 +0900 Subject: [PATCH 32/36] refactor(ui): rename filter context tasks to taskIdMap Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/expand.ts | 2 +- packages/ui/client/composables/explorer/filter.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index e04e5d836375..a60f38323b2a 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -49,7 +49,7 @@ export function runExpandNode(id: string, search: SearchMatcher, filter: Filter) treeItems.add(node.id) const subtree = filterNode(node, { nodes: explorerTree.nodes, - tasks: client.state.idMap, + taskIdMap: client.state.idMap, search, filter, slowTestThreshold: config.value.slowTestThreshold, diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index c5156900c8e4..c0b559246741 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -12,7 +12,7 @@ import { getSortedRootTasks, isFileNode, isParentNode } from '~/composables/expl interface FilterNodeContext { nodes: ReadonlyMap - tasks: ReadonlyMap + taskIdMap: ReadonlyMap search: SearchMatcher filter: Filter slowTestThreshold: number | undefined @@ -50,17 +50,17 @@ export function runFilter(search: SearchMatcher, filter: Filter): void { */ export function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { const project = currentProjectName.value - const tasks = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) + const files = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) const entries: UITaskTreeNode[] = [] const context: FilterNodeContext = { nodes: explorerTree.nodes, - tasks: client.state.idMap, + taskIdMap: client.state.idMap, search, filter, slowTestThreshold: config.value.slowTestThreshold, } - for (const node of tasks) { + for (const node of files) { if (project && node.projectName !== project) { continue } @@ -133,7 +133,7 @@ function matchesNode(node: UITaskTreeNode, context: FilterNodeContext): boolean if (context.filter.onlyTests && node.type !== 'test') { return false } - const task = context.tasks.get(node.id) + const task = context.taskIdMap.get(node.id) return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false } From fee04c46e0f7e302416a7eda831dfb6ddd8394b2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:02:03 +0900 Subject: [PATCH 33/36] refactor(ui): move expanded writes out of filterTreeNode Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/filter.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index c0b559246741..6f96032e8eaa 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -80,14 +80,14 @@ export function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode */ export function filterNode(node: UITaskTreeNode, context: FilterNodeContext): UITaskTreeNode[] { const filteredTree = filterTreeNode(node, context, hasMatchingAncestor(node, context)) - return filteredTree ? flattenVisibleTree(filteredTree) : [] + return filteredTree ? revealKeptTree(filteredTree) : [] } /** * 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`). Kept parents are marked expanded. + * matched (`subtreeMatches`). Nothing is written. */ function filterTreeNode( node: UITaskTreeNode, @@ -107,10 +107,6 @@ function filterTreeNode( return undefined } - if (isParentNode(node)) { - node.expanded = true - } - return { node, children, @@ -137,16 +133,16 @@ function matchesNode(node: UITaskTreeNode, context: FilterNodeContext): boolean return task ? matchTask(task, context.search, context.filter, context.slowTestThreshold) : false } -function flattenVisibleTree( - tree: FilteredTreeNode, - entries: UITaskTreeNode[] = [], -): UITaskTreeNode[] { +/** + * 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) { - return entries + if (isParentNode(tree.node)) { + tree.node.expanded = true } for (const child of tree.children) { - flattenVisibleTree(child, entries) + revealKeptTree(child, entries) } return entries From f0be12e9e20aeb543bad34ef3150bbb8eea5888f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:27:03 +0900 Subject: [PATCH 34/36] refactor(ui): reuse runFilter in runExpandAll Co-Authored-By: Claude Opus 5.5 --- packages/ui/client/composables/explorer/expand.ts | 10 ++++------ packages/ui/client/composables/explorer/filter.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/ui/client/composables/explorer/expand.ts b/packages/ui/client/composables/explorer/expand.ts index a60f38323b2a..5148e9e229a6 100644 --- a/packages/ui/client/composables/explorer/expand.ts +++ b/packages/ui/client/composables/explorer/expand.ts @@ -1,8 +1,8 @@ import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' -import { client, config, 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, @@ -86,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) { diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 6f96032e8eaa..7ddbe0759e44 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -48,7 +48,7 @@ export function runFilter(search: SearchMatcher, filter: Filter): void { * Reads the current project, sort order, explorer tree, task state, and config. Kept parents are marked * expanded, and nothing else is written. */ -export function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { +function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { const project = currentProjectName.value const files = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) const entries: UITaskTreeNode[] = [] From 49f9bc40f13560cda9000370c9f99379a7aba30b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:29:49 +0900 Subject: [PATCH 35/36] refactor(ui): inline filterAll into runFilter Co-Authored-By: Claude Opus 5.5 --- .../ui/client/composables/explorer/filter.ts | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 7ddbe0759e44..8b074b09663f 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -34,24 +34,12 @@ export function testMatcher( } /** - * Rebuild the explorer rows for the whole tree and publish them to `uiEntries` and `filteredFiles`. + * 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): void { - const entries = filterAll(search, filter) - uiEntries.value = entries - filteredFiles.value = entries.filter(isFileNode).map((f) => findById(f.id)!) -} - -/** - * Return the explorer rows for every file of the current project, in sort order. - * - * Reads the current project, sort order, explorer tree, task state, and config. Kept parents are marked - * expanded, and nothing else is written. - */ -function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { const project = currentProjectName.value const files = getSortedRootTasks(explorerTree.root.tasks, projectSort.value) - const entries: UITaskTreeNode[] = [] const context: FilterNodeContext = { nodes: explorerTree.nodes, taskIdMap: client.state.idMap, @@ -59,17 +47,11 @@ function filterAll(search: SearchMatcher, filter: Filter): UITaskTreeNode[] { filter, slowTestThreshold: config.value.slowTestThreshold, } - - for (const node of files) { - if (project && node.projectName !== project) { - continue - } - for (const entry of filterNode(node, context)) { - entries.push(entry) - } - } - - return entries + 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)!) } /** From 0dfffba8638e20082324c525203bf01873f58998 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:39:24 +0900 Subject: [PATCH 36/36] test(ui): cover nested tests for suite search matches Co-Authored-By: Claude Opus 5.5 --- test/ui/test/ui.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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' }) {