test(file-search): pin request-key listings against the real runtime shape (#22312)

* test(file-search): pin request-key listings against the real runtime shape

* test(file-search): pin intermediate renders and late remote answers

Strengthen the stale-answer guard to assert every intermediate render reads as loading (null), add a late-answer drop case, make the tab-entry loading pin non-vacuous, and correct the e2e comment for local listings.

* test(file-search): keep only the non-duplicate runtime-listing pins

Drop the remote projection cases already covered at the hook level, collapse
the classifier integration to the loading pin, drop the local-only rapid-edit
e2e, and fix the brittle README absent-file assertion that failed CI.
This commit is contained in:
Jinjing
2026-09-23 13:35:47 -07:00
committed by GitHub
parent 845db9e5e2
commit 90f0c5c8ae
3 changed files with 412 additions and 0 deletions
@@ -0,0 +1,198 @@
// @vitest-environment happy-dom
//
// Why this file exists separately from use-file-explorer-name-filter.test.ts: that suite mocks
// useRuntimeFileListForWorktree, so it can only assert the name filter against a hand-written
// RuntimeFileListState. #21423 shipped a P0 through exactly that gap — the mock encoded a listing
// shape local workspaces never produce, and the filter discarded every local result. These specs
// mock only the IPC boundary so the filter runs against the listing the hook really returns.
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { useFileExplorerNameFilter } from './use-file-explorer-name-filter'
const listRuntimeFilesMock = vi.hoisted(() => vi.fn())
const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn())
const searchRuntimeFilePathsMock = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-file-client', () => ({
listRuntimeFiles: listRuntimeFilesMock,
cancelRuntimeFileList: cancelRuntimeFileListMock,
searchRuntimeFilePaths: searchRuntimeFilePathsMock
}))
const initialAppState = useAppStore.getInitialState()
const LOCAL_KEY = folderWorkspaceKey('local-workspace')
const REMOTE_KEY = folderWorkspaceKey('remote-workspace')
function projectGroup(id: string, parentPath: string, connectionId: string | null): ProjectGroup {
return {
id,
name: id,
parentPath,
connectionId,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
function folderWorkspace(
id: string,
projectGroupId: string,
folderPath: string,
connectionId: string | null
): FolderWorkspace {
return {
id,
projectGroupId,
name: id,
folderPath,
connectionId,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
}
function seedWorkspaces(): void {
const workspaces: Partial<AppState> = {
folderWorkspaces: [
folderWorkspace('local-workspace', 'local-group', '/local/proj', null),
folderWorkspace('remote-workspace', 'remote-group', '/srv/remote', 'ssh-1')
],
projectGroups: [
projectGroup('local-group', '/local/proj', null),
projectGroup('remote-group', '/srv/remote', 'ssh-1')
],
repos: [],
worktreesByRepo: {}
}
useAppStore.setState(workspaces)
}
/** Drain the request/settle microtask chain well past any intermediate render. */
async function settle(): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
await act(async () => {
await Promise.resolve()
})
}
}
function renderNameFilter(activeWorktreeId: string) {
return renderHook(() => useFileExplorerNameFilter({ isFilesViewActive: true, activeWorktreeId }))
}
beforeEach(() => {
useAppStore.setState(initialAppState, true)
listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json', 'src/main.ts'])
cancelRuntimeFileListMock.mockReset()
searchRuntimeFilePathsMock.mockReset().mockResolvedValue({ files: [], truncated: false })
seedWorkspaces()
})
afterEach(() => {
cleanup()
useAppStore.setState(initialAppState, true)
})
describe('useFileExplorerNameFilter over the real runtime listing', () => {
// #21423 regression: a local workspace has no host-side path search, so the hook returns a
// complete listing the filter narrows itself. Discarding it left every query empty forever.
it('projects a settled local listing instead of dropping it', async () => {
const { result } = renderNameFilter(LOCAL_KEY)
await act(async () => {
result.current.setNameFilterQuery('package.')
})
await settle()
expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1)
expect(result.current.nameFilterSource?.relativePaths).toEqual([
'packages/app/package.json',
'src/main.ts'
])
})
it('keeps the local listing across query edits without refetching it', async () => {
const { result } = renderNameFilter(LOCAL_KEY)
await act(async () => {
result.current.setNameFilterQuery('pack')
})
await settle()
await act(async () => {
result.current.setNameFilterQuery('package.json')
})
await settle()
expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1)
expect(result.current.nameFilterSource?.relativePaths).toEqual([
'packages/app/package.json',
'src/main.ts'
])
})
// The host answers one query at a time, so the previous answer must never be shown as the
// current one — it would name files that do not match what the user typed., so the previous answer must never be shown as the
// current one — it would name files that do not match what the user typed.
it('never projects the previous query answer after a remote query edit', async () => {
vi.useFakeTimers()
searchRuntimeFilePathsMock.mockResolvedValue({ files: ['first/hit.ts'], truncated: false })
const projected: (readonly string[] | null | undefined)[] = []
try {
const { result } = renderHook(() => {
const filter = useFileExplorerNameFilter({
isFilesViewActive: true,
activeWorktreeId: REMOTE_KEY
})
projected.push(filter.nameFilterSource?.relativePaths)
return filter
})
await act(async () => {
result.current.setNameFilterQuery('first')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(120)
})
await settle()
expect(result.current.nameFilterSource?.relativePaths).toEqual(['first/hit.ts'])
searchRuntimeFilePathsMock.mockResolvedValue({ files: ['second/hit.ts'], truncated: false })
const rendersBeforeEdit = projected.length
await act(async () => {
result.current.setNameFilterQuery('second')
})
// Why: the render before the effect restarts the request is the one that can leak.
expect(projected.length).toBeGreaterThan(rendersBeforeEdit)
for (const paths of projected.slice(rendersBeforeEdit)) {
expect(paths).toBeNull()
}
await act(async () => {
await vi.advanceTimersByTimeAsync(120)
})
await settle()
expect(result.current.nameFilterSource?.relativePaths).toEqual(['second/hit.ts'])
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,161 @@
// @vitest-environment happy-dom
//
// Why: every other TabBarCreateEntry suite mocks useRuntimeFileListForWorktree, so the classifier
// has only ever been graded against hand-written RuntimeFileListState values. That is the same
// seam #21423 shipped a P0 through in the file explorer. This spec runs the classifier on the
// listing the real hook returns, mocking only the IPC boundary.
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { useRuntimeFileListForWorktree, type RuntimeFileListState } from '../quick-open-file-list'
import { getTabEntryOptions } from './tab-create-entry-classifier'
const listRuntimeFilesMock = vi.hoisted(() => vi.fn())
const cancelRuntimeFileListMock = vi.hoisted(() => vi.fn())
const searchRuntimeFilePathsMock = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-file-client', () => ({
listRuntimeFiles: listRuntimeFilesMock,
cancelRuntimeFileList: cancelRuntimeFileListMock,
searchRuntimeFilePaths: searchRuntimeFilePathsMock
}))
const initialAppState = useAppStore.getInitialState()
const WORKSPACE_KEY = folderWorkspaceKey('local-workspace')
const roots: Root[] = []
function seedLocalWorkspace(): void {
const group: ProjectGroup = {
id: 'local-group',
name: 'local-group',
parentPath: '/local/proj',
connectionId: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const workspace: FolderWorkspace = {
id: 'local-workspace',
projectGroupId: 'local-group',
name: 'local-workspace',
folderPath: '/local/proj',
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const seeded: Partial<AppState> = {
folderWorkspaces: [workspace],
projectGroups: [group],
repos: [],
worktreesByRepo: {}
}
useAppStore.setState(seeded)
}
function HookProbe({
onState,
worktreeId
}: {
onState: (state: RuntimeFileListState) => void
worktreeId: string
}): null {
onState(useRuntimeFileListForWorktree({ enabled: true, worktreeId }))
return null
}
/** Render the real list hook and return every state it rendered, in order. */
async function renderFileList(): Promise<RuntimeFileListState[]> {
const states: RuntimeFileListState[] = []
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
createElement(HookProbe, {
worktreeId: WORKSPACE_KEY,
onState: (state: RuntimeFileListState) => states.push(state)
})
)
})
return states
}
async function drainMicrotasks(): Promise<void> {
for (let attempt = 0; attempt < 50; attempt += 1) {
await act(async () => {
await Promise.resolve()
})
}
}
function latestState(states: RuntimeFileListState[]): RuntimeFileListState {
const latest = states.at(-1)
if (!latest) {
throw new Error('the file list hook never rendered')
}
return latest
}
beforeEach(() => {
useAppStore.setState(initialAppState, true)
listRuntimeFilesMock.mockReset().mockResolvedValue(['packages/app/package.json', 'src/main.ts'])
cancelRuntimeFileListMock.mockReset()
searchRuntimeFilePathsMock.mockReset().mockResolvedValue({ files: [], truncated: false })
seedLocalWorkspace()
})
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount()
})
}
roots.length = 0
useAppStore.setState(initialAppState, true)
})
describe('tab entry options over the real runtime listing', () => {
// A listing the hook fetched but hid would leave the entry stuck on its loading placeholder.
it('does not report the settled listing as still loading', async () => {
let resolveListing: (files: string[]) => void = () => {}
listRuntimeFilesMock.mockImplementationOnce(
() =>
new Promise<string[]>((resolve) => {
resolveListing = resolve
})
)
const states = await renderFileList()
const blockedIds = (fileList: RuntimeFileListState): string[] =>
getTabEntryOptions('packages/app/package.json', fileList, 4)
.filter((option) => option.classification.kind === 'blocked')
.map((option) => option.id)
// Pins that the settled assertion below is not vacuous: the pending listing does block.
expect(blockedIds(latestState(states))).toContain('loading')
await act(async () => {
resolveListing(['packages/app/package.json', 'src/main.ts'])
})
await drainMicrotasks()
expect(blockedIds(latestState(states))).not.toContain('loading')
})
})
@@ -0,0 +1,53 @@
import type { Locator, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { openFileExplorer } from './helpers/file-explorer'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
// Why: useRuntimeFileListForWorktree scopes listings/loading to a request key so a
// query change never shows the previous listing or a stale "no results" flash.
// Unit tests pin the intermediate hook renders; these specs pin the user-visible
// wiring (filter input -> rows / "No files match this filter") against regressions.
function rowByName(explorer: Locator, page: Page, name: string): Locator {
return explorer
.locator('[data-file-explorer-row]')
.filter({ has: page.locator('[data-file-explorer-row-name]', { hasText: name }) })
}
test('name filter narrows to the matching file', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await openFileExplorer(orcaPage)
const explorer = orcaPage.locator('[data-orca-explorer-shell]')
await expect(explorer).toBeVisible({ timeout: 10_000 })
const input = orcaPage.getByPlaceholder('Find files')
await expect(input).toBeVisible({ timeout: 10_000 })
await input.fill('package.')
await expect(rowByName(explorer, orcaPage, 'package.json').first()).toBeVisible({
timeout: 10_000
})
// Why no absent-file assertion here: the seeded repo's contents decide what a
// non-match is, and that made this spec fail on rows unrelated to the filter.
// True no-match behavior is pinned by the next spec.
await expect(explorer.getByText('No files match this filter')).toHaveCount(0)
})
test('name filter shows the empty message only for a true no-match', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await openFileExplorer(orcaPage)
const explorer = orcaPage.locator('[data-orca-explorer-shell]')
await expect(explorer).toBeVisible({ timeout: 10_000 })
const input = orcaPage.getByPlaceholder('Find files')
await expect(input).toBeVisible({ timeout: 10_000 })
await input.fill('zz-no-such-file-12345')
await expect(explorer.getByText('No files match this filter')).toBeVisible({ timeout: 10_000 })
await input.fill('')
await expect(rowByName(explorer, orcaPage, 'README.md').first()).toBeVisible({
timeout: 10_000
})
})