From 90f0c5c8aeffa5572e2d9f412c4d6f9cc9bc6d5b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:35:47 -0700 Subject: [PATCH] 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. --- ...lorer-name-filter.runtime-listing.test.tsx | 198 ++++++++++++++++++ ...-entry-classifier.runtime-listing.test.tsx | 161 ++++++++++++++ tests/e2e/file-explorer-name-filter.spec.ts | 53 +++++ 3 files changed, 412 insertions(+) create mode 100644 src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx create mode 100644 tests/e2e/file-explorer-name-filter.spec.ts diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx new file mode 100644 index 00000000000..d20c83d61b9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.runtime-listing.test.tsx @@ -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 = { + 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 { + 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() + } + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx new file mode 100644 index 00000000000..6b2dfce0092 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx @@ -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 = { + 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 { + 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 { + 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((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') + }) +}) diff --git a/tests/e2e/file-explorer-name-filter.spec.ts b/tests/e2e/file-explorer-name-filter.spec.ts new file mode 100644 index 00000000000..ec20b50f066 --- /dev/null +++ b/tests/e2e/file-explorer-name-filter.spec.ts @@ -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 + }) +})