From 0a38e847fde979b9d7b79e8bb3d78384ca05c849 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 18 Sep 2026 19:38:24 -0400 Subject: [PATCH] test(session-search): cover the legacy title filter while indexing is off The panel test fails on the pre-fix code with no session rows rendered. --- .../AiVaultPanel.legacy-filter.test.tsx | 160 ++++++++++++++++++ .../use-ai-vault-search.test.tsx | 45 ++++- 2 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx new file mode 100644 index 00000000000..598a4736fe8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultSessionGroup } from './ai-vault-session-filters' + +const mockState: { + settings: { aiVaultSearch?: { enabled: boolean } } + runtimeEnvironments: never[] + folderWorkspaces: Record + projectGroups: never[] + repos: never[] + worktreesByRepo: Record +} = { + settings: {}, + runtimeEnvironments: [], + folderWorkspaces: {}, + projectGroups: [], + repos: [], + worktreesByRepo: {} +} +const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => { + mockState.settings = next +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((select: (state: typeof mockState) => unknown) => select(mockState), { + getState: () => ({ ...mockState, updateSettingsOrThrow }) + }) +})) +vi.mock('@/store/selectors', () => ({ + useActiveRepo: () => null, + useActiveWorktree: () => null, + useActiveWorktreeId: () => null, + useAllWorktrees: () => [], + useProjectHostSetupProjection: () => ({ projects: [], setups: [] }), + useRepos: () => [] +})) + +const sessions: AiVaultSession[] = [ + vaultSession('claude:1', 'Fix the foo pipeline'), + vaultSession('claude:2', 'Rename the bar widget') +] +vi.mock('./ai-vault-session-refresh', () => ({ + useAiVaultSessionRefresh: () => ({ + error: null, + loading: false, + refresh: vi.fn(), + scanResult: { sessions, issues: [], scannedAt: '2026-05-01T10:10:00.000Z' }, + sessions + }) +})) +vi.mock('./ai-vault-session-launch-actions', () => ({ + useAiVaultSessionLaunchActions: () => ({ + buildResumeStartup: vi.fn(), + copyResumeCommand: vi.fn(), + handleResume: vi.fn(), + handleResumeInNewChat: vi.fn(), + handleContinueInNewSession: vi.fn(), + continuationRequest: null, + handleContinuationDialogOpenChange: vi.fn() + }) +})) +vi.mock('./ai-vault-original-pane-actions', () => ({ + useAiVaultOriginalPaneActions: () => ({ + getOriginalPaneTarget: vi.fn(), + getSessionLiveState: vi.fn(), + jumpToOriginalPane: vi.fn(), + jumpToWorktree: vi.fn() + }) +})) +vi.mock('./ai-vault-session-delete-action', () => ({ + useAiVaultSessionDeleteAction: () => vi.fn() +})) +// The virtualizer measures a zero-height viewport under happy-dom; the rows it would +// choose are exactly the grouped sessions, so render those instead. +vi.mock('./AiVaultSessionVirtualList', () => ({ + AiVaultSessionVirtualList: ({ groups }: { groups: readonly AiVaultSessionGroup[] }) => ( +
    + {groups.flatMap((group) => + group.sessions.map((session) =>
  • {session.title}
  • ) + )} +
+ ) +})) + +const searchSessions = vi.fn() + +function vaultSession(id: string, title: string): AiVaultSession { + return { + id, + executionHostId: 'local', + agent: 'claude', + sessionId: id, + title, + cwd: '/Users/ada/repo', + branch: null, + model: null, + filePath: `/Users/ada/.claude/${id}.jsonl`, + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume', + subagent: null + } +} + +beforeEach(() => { + mockState.settings = {} + searchSessions.mockReset().mockResolvedValue({ + kind: 'results', + hits: [], + page: { cursor: null, hasMore: false }, + generation: 1, + durationMs: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { searchSessions }, ui: { writeClipboardText: vi.fn() } } + }) +}) +afterEach(cleanup) + +async function typeQuery(text: string) { + const { default: AiVaultPanel } = await import('./AiVaultPanel') + render() + await userEvent.type(screen.getByLabelText('Search sessions'), text) +} + +it('offers indexing above the title-filtered history instead of hiding every session', async () => { + await typeQuery('foo') + + expect(screen.getByRole('status').textContent).toContain('Enable full-text search?') + expect(screen.getByRole('button', { name: 'Enable' })).toBeTruthy() + expect(screen.getByText('Fix the foo pipeline')).toBeTruthy() + expect(screen.queryByText('Rename the bar widget')).toBeNull() + expect(searchSessions).not.toHaveBeenCalled() +}) + +it('switches to index search with the same query once indexing is enabled', async () => { + await typeQuery('foo') + + await userEvent.click(screen.getByRole('button', { name: 'Enable' })) + + expect(updateSettingsOrThrow).toHaveBeenCalledWith({ + aiVaultSearch: { enabled: true, historyDays: null } + }) + await waitFor(() => + expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local') + ) + expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull() +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index 92cb800d945..a90f53f0f8d 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -9,9 +9,10 @@ import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/exe import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' +const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} vi.mock('@/store', () => ({ - useAppStore: (select: (state: { settings: undefined }) => unknown) => - select({ settings: undefined }) + useAppStore: (select: (state: { settings: typeof mockSettings }) => unknown) => + select({ settings: mockSettings }) })) const ALL_AGENTS = ['codex' as const] @@ -35,6 +36,7 @@ beforeEach(() => { value: { aiVault: { searchSessions } } }) searchSessions.mockReset().mockResolvedValue(empty) + delete mockSettings.aiVaultSearch }) afterEach(() => vi.useRealTimers()) async function debounce() { @@ -267,3 +269,42 @@ it('restarts page one under the all scope when the merged cursor goes stale', as expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local']) unmount() }) + +it('leaves the box as the legacy title filter while local indexing consent is pending', async () => { + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).not.toHaveBeenCalled() + expect(result.current.searching).toBe(false) + expect(result.current.consentOffer).toBe(true) + expect(result.current.loading).toBe(false) + expect(result.current.sessions).toEqual([]) + unmount() +}) + +it('searches the local index with the same query once consent is on', async () => { + mockSettings.aiVaultSearch = { enabled: true } + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).toHaveBeenCalledExactlyOnceWith( + { ...ALL_REQUEST, cursor: undefined }, + 'local' + ) + expect(result.current.searching).toBe(true) + expect(result.current.consentOffer).toBe(false) + unmount() +}) + +it('offers nothing and searches nothing for a blank query', async () => { + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch(' ', ALL_AGENTS, undefined, 'local') + ) + await debounce() + expect(searchSessions).not.toHaveBeenCalled() + expect(result.current.searching).toBe(false) + expect(result.current.consentOffer).toBe(false) + unmount() +})