import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SearchPanel } from './SearchPanel'
import type { VaultEntry } from '../types'
// Mock the mock-tauri module (component uses mockInvoke when isTauri() is false)
vi.mock('../mock-tauri', () => ({
mockInvoke: vi.fn(),
isTauri: () => false,
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
const NOW = Math.floor(Date.now() / 1000)
const SEARCH_INPUT_PLACEHOLDER = 'Search in all notes...'
type MockSearchResult = {
title: string
path: string
snippet: string
score: number
note_type: string | null
}
const MOCK_ENTRIES: VaultEntry[] = [
{
path: '/vault/essay/ai-apis.md',
filename: 'ai-apis.md',
title: 'How to Design AI-first APIs',
isA: 'Essay',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: NOW - 7200,
createdAt: NOW - 86400 * 30,
fileSize: 500,
snippet: 'A guide to designing APIs for AI',
wordCount: 1247,
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
properties: {},
},
{
path: '/vault/event/retreat.md',
filename: 'retreat.md',
title: 'Refactoring Retreat',
isA: 'Event',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: NOW - 86400 * 5,
createdAt: NOW - 86400 * 5,
fileSize: 300,
snippet: 'Team retreat event',
wordCount: 856,
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
outgoingLinks: ['person/bob'],
properties: {},
},
]
const THREE_RESULT_ENTRIES: VaultEntry[] = [
...MOCK_ENTRIES,
{
...MOCK_ENTRIES[0],
path: '/vault/topic/search.md',
filename: 'search.md',
title: 'Search Patterns',
},
]
const THREE_SEARCH_RESULTS: MockSearchResult[] = [
{ title: 'Result One', path: '/vault/essay/ai-apis.md', snippet: 'First result', score: 0.9, note_type: null },
{ title: 'Result Two', path: '/vault/event/retreat.md', snippet: 'Second result', score: 0.8, note_type: null },
{ title: 'Result Three', path: '/vault/topic/search.md', snippet: 'Third result', score: 0.7, note_type: null },
]
const API_SEARCH_RESULT: MockSearchResult = {
title: 'How to Design AI-first APIs',
path: '/vault/essay/ai-apis.md',
snippet: 'Content',
score: 0.9,
note_type: null,
}
function renderSearchPanel({
entries = MOCK_ENTRIES,
onClose = vi.fn(),
onSelectNote = vi.fn(),
}: {
entries?: VaultEntry[]
onClose?: () => void
onSelectNote?: (entry: VaultEntry) => void
} = {}) {
render(
,
)
}
function mockSearchResults(results: MockSearchResult[], elapsed_ms = 20) {
mockInvokeFn.mockResolvedValue({ results, elapsed_ms })
}
async function renderSearchWithResults({
elapsedMs = 20,
entries = THREE_RESULT_ENTRIES,
results = THREE_SEARCH_RESULTS,
query = 'test',
visibleTitle = 'Search Patterns',
onSelectNote = vi.fn(),
}: {
elapsedMs?: number
entries?: VaultEntry[]
results?: MockSearchResult[]
query?: string
visibleTitle?: string
onSelectNote?: (entry: VaultEntry) => void
} = {}) {
mockSearchResults(results, elapsedMs)
renderSearchPanel({ entries, onSelectNote })
const input = screen.getByPlaceholderText(SEARCH_INPUT_PLACEHOLDER)
fireEvent.change(input, { target: { value: query } })
await waitFor(() => {
expect(screen.getByText(visibleTitle)).toBeInTheDocument()
})
return { input, onSelectNote }
}
async function renderSingleResultSearch({
elapsedMs = 20,
entries = MOCK_ENTRIES,
query = 'api',
result = API_SEARCH_RESULT,
visibleTitle = 'How to Design AI-first APIs',
}: {
elapsedMs?: number
entries?: VaultEntry[]
query?: string
result?: MockSearchResult
visibleTitle?: string
} = {}) {
return renderSearchWithResults({
elapsedMs,
entries,
results: [result],
query,
visibleTitle,
})
}
async function expectOnlySecondResultAfterArrowDown(
pressArrowDown: (input: HTMLElement) => void,
) {
const { input } = await renderSearchWithResults()
await act(async () => pressArrowDown(input))
await waitFor(() => {
expectSelectedResult('Refactoring Retreat')
expectUnselectedResult('Search Patterns')
})
}
function resultRow(title: string) {
return screen.getByText(title).closest('[role="option"]')!
}
function expectSelectedResult(title: string) {
expect(resultRow(title).className).toContain('bg-accent')
}
function expectUnselectedResult(title: string) {
expect(resultRow(title).className).not.toContain('bg-accent')
}
function dispatchKeyboardEvent(
target: Element | Document,
type: 'keydown' | 'keyup',
key: string,
options: { repeat?: boolean; timeStamp?: number } = {},
) {
const event = new KeyboardEvent(type, {
key,
bubbles: true,
cancelable: true,
repeat: options.repeat,
})
if (options.timeStamp !== undefined) Object.defineProperty(event, 'timeStamp', { value: options.timeStamp })
fireEvent(target, event)
}
describe('SearchPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when closed', () => {
const { container } = render(
,
)
expect(container.innerHTML).toBe('')
})
it('renders search input when open', () => {
render(
,
)
expect(screen.getByPlaceholderText('Search in all notes...')).toBeInTheDocument()
})
it('shows empty state hint when no query', () => {
render(
,
)
expect(screen.getByText('Search across all note contents')).toBeInTheDocument()
expect(screen.getByText('Enter to open · Esc to close')).toBeInTheDocument()
})
it('has no keyword/semantic toggle', () => {
render(
,
)
expect(screen.queryByText('Keyword')).not.toBeInTheDocument()
expect(screen.queryByText('Semantic')).not.toBeInTheDocument()
})
it('calls onClose when clicking overlay', () => {
const onClose = vi.fn()
render(
,
)
const overlay = screen.getByPlaceholderText('Search in all notes...').closest('.fixed')!
fireEvent.click(overlay)
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on Escape key', () => {
const onClose = vi.fn()
render(
,
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('performs keyword search', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs for AI...', score: 0.87, note_type: 'Essay' },
],
elapsed_ms: 48,
})
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'api design' } })
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
vaultPath: '/vault',
query: 'api design',
mode: 'keyword',
limit: 20,
})
})
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
})
it('shows note title from VaultEntry instead of filename from search result', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'ai-apis', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs...', score: 0.87, note_type: null },
],
elapsed_ms: 12,
})
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'api' } })
await waitFor(() => {
// Should show VaultEntry title, not filename-based search result title
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
expect(screen.queryByText('ai-apis')).not.toBeInTheDocument()
})
})
it('shows no results message when search returns empty', async () => {
mockInvokeFn.mockResolvedValue({ results: [], elapsed_ms: 10 })
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'xyznonexistent' } })
await waitFor(() => {
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
it('navigates results with arrow keys', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'Result One', path: '/vault/essay/ai-apis.md', snippet: 'First result', score: 0.9, note_type: null },
{ title: 'Result Two', path: '/vault/event/retreat.md', snippet: 'Second result', score: 0.8, note_type: null },
],
elapsed_ms: 20,
})
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'test' } })
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
await act(async () => {
fireEvent.keyDown(input, { key: 'ArrowDown' })
})
await waitFor(() => {
const resultTwo = screen.getByText('Refactoring Retreat').closest('[class*="cursor-pointer"]')!
expect(resultTwo.className).toContain('bg-accent')
})
})
it('moves down one result when ArrowDown is pressed in the input', async () => {
await expectOnlySecondResultAfterArrowDown((input) => fireEvent.keyDown(input, { key: 'ArrowDown' }))
})
it('ignores duplicate native ArrowDown keydown events for one press', async () => {
await expectOnlySecondResultAfterArrowDown((input) => {
fireEvent.keyDown(input, { key: 'ArrowDown' })
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown', { repeat: true })
})
})
it('ignores a native ArrowDown duplicate bridged through an immediate keyup', async () => {
const { input, onSelectNote } = await renderSearchWithResults()
await act(async () => {
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown')
dispatchKeyboardEvent(input, 'keyup', 'ArrowDown')
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown')
dispatchKeyboardEvent(input, 'keydown', 'Enter')
})
expect(onSelectNote).toHaveBeenCalledWith(THREE_RESULT_ENTRIES[1])
})
it('allows a second ArrowDown press after the native duplicate window', async () => {
const { input } = await renderSearchWithResults()
await act(async () => {
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown')
dispatchKeyboardEvent(input, 'keyup', 'ArrowDown')
await new Promise(resolve => setTimeout(resolve, 520))
dispatchKeyboardEvent(input, 'keydown', 'ArrowDown')
})
await waitFor(() => expectSelectedResult('Search Patterns'))
})
it('keeps keyboard selection when a stationary mousemove lands on another row', async () => {
const { input } = await renderSearchWithResults()
await act(async () => {
fireEvent.keyDown(input, { key: 'ArrowDown' })
})
fireEvent.mouseMove(resultRow('Search Patterns'), { movementX: 0, movementY: 0 })
await waitFor(() => {
expectSelectedResult('Refactoring Retreat')
expectUnselectedResult('Search Patterns')
})
})
it('still opens clicked search results', async () => {
const { onSelectNote } = await renderSearchWithResults()
fireEvent.click(resultRow('Search Patterns'))
expect(onSelectNote).toHaveBeenCalledWith(THREE_RESULT_ENTRIES[2])
})
it('selects result on Enter and calls onSelectNote', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'First', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
const onSelectNote = vi.fn()
const onClose = vi.fn()
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'api' } })
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
await act(async () => {
fireEvent.keyDown(input, { key: 'Enter' })
})
await waitFor(() => {
expect(onSelectNote).toHaveBeenCalledWith(MOCK_ENTRIES[0])
})
expect(onClose).toHaveBeenCalled()
})
it('shows result count and elapsed time', async () => {
await renderSingleResultSearch({ elapsedMs: 123, query: 'test' })
await waitFor(() => {
expect(screen.getByText(/1 result/)).toBeInTheDocument()
expect(screen.getByText(/123ms/)).toBeInTheDocument()
})
})
it('displays note type badge from vault entries', async () => {
await renderSingleResultSearch()
await waitFor(() => {
expect(screen.getByText('Essay')).toBeInTheDocument()
})
})
it('shows workspace initials at the far right instead of prefixing result titles', async () => {
const personalWorkspace = {
id: 'personal',
label: 'Personal',
alias: 'personal',
path: '/personal',
shortLabel: 'PE',
color: 'blue',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: true,
}
const teamWorkspace = {
id: 'team',
label: 'Team',
alias: 'team',
path: '/team',
shortLabel: 'TE',
color: 'green',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: false,
}
const workspaceEntries = [
{ ...MOCK_ENTRIES[0], path: '/team/essay/ai-apis.md', workspace: teamWorkspace },
{ ...MOCK_ENTRIES[1], path: '/personal/event/retreat.md', workspace: personalWorkspace },
]
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/team/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
render(
,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
expect(screen.queryByText(/Team \//)).not.toBeInTheDocument()
const workspaceBadge = screen.getByTestId('search-result-workspace-badge')
expect(workspaceBadge).toHaveTextContent('TE')
expect(screen.getByText('Essay').compareDocumentPosition(workspaceBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('shows metadata subtitle with word count and links', async () => {
await renderSingleResultSearch()
await waitFor(() => {
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
expect(screen.getByText(/3 links/)).toBeInTheDocument()
})
})
it('omits links from subtitle when entry has zero outgoing links', async () => {
const noLinksEntries = MOCK_ENTRIES.map(e =>
e.path === '/vault/essay/ai-apis.md' ? { ...e, outgoingLinks: [] } : e,
)
await renderSingleResultSearch({ entries: noLinksEntries, result: { ...API_SEARCH_RESULT, snippet: '' } })
await waitFor(() => {
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
expect(screen.queryByText(/links/)).not.toBeInTheDocument()
})
})
it('shows loading spinner while searching', async () => {
const resolvers: ((v: unknown) => void)[] = []
mockInvokeFn.mockImplementation(
() => new Promise(resolve => { resolvers.push(resolve) }),
)
render(
,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
// Spinner appears when search starts (after debounce)
await waitFor(() => {
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
})
// Resolve keyword search
resolvers[0]({
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 30,
})
// Spinner disappears after search completes — VaultEntry title shown instead of search result title
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
})
})
it('discards stale results when query changes rapidly', async () => {
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record) => {
const q = (args as Record)?.query
if (q === 'second') {
return {
results: [{ title: 'Second Result', path: '/vault/event/retreat.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 30,
}
}
return { results: [], elapsed_ms: 0 }
})
render(
,
)
const input = screen.getByPlaceholderText('Search in all notes...')
// Type first query, then immediately change to second (within debounce)
fireEvent.change(input, { target: { value: 'first' } })
fireEvent.change(input, { target: { value: 'second' } })
// Only second query results should appear — VaultEntry title shown
await waitFor(() => {
expect(screen.getByText('Refactoring Retreat')).toBeInTheDocument()
})
})
it('deduplicates results when backend returns same note twice', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'keyword hit', score: 0.7, note_type: 'Essay' },
{ title: 'Refactoring Retreat', path: '/vault/event/retreat.md', snippet: 'unique', score: 0.6, note_type: 'Event' },
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'duplicate hit', score: 0.9, note_type: 'Essay' },
],
elapsed_ms: 48,
})
render(
,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
const titles = screen.getAllByText('How to Design AI-first APIs')
expect(titles).toHaveLength(1) // deduped — not 2
})
await waitFor(() => {
expect(screen.getByText(/2 results/)).toBeInTheDocument()
})
})
it('cancels inflight searches when panel closes', async () => {
const resolvers: ((v: unknown) => void)[] = []
mockInvokeFn.mockImplementation(
() => new Promise(resolve => { resolvers.push(resolve) }),
)
const { rerender } = render(
,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'slow query' } })
// Wait for keyword search to start
await waitFor(() => {
expect(resolvers).toHaveLength(1)
})
// Close the panel while search is inflight
rerender(
,
)
// Resolve the inflight keyword search — should be discarded (stale generation)
resolvers[0]({
results: [{ title: 'Stale Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 30,
})
// Reopen panel
rerender(
,
)
// Should NOT show the stale result — panel was reset
expect(screen.queryByText('Stale Result')).not.toBeInTheDocument()
expect(screen.getByText('Search across all note contents')).toBeInTheDocument()
})
})