mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
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:
+198
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+161
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user