/** * @jest-environment jsdom * * Search-panel DOM tests — verify the panel wires the existing backend * (workspace-search / doc-qa) and renders results safely. We don't test * pixel layout; we test the contract: click a result → calls onOpenFile * with the right path. */ const { renderSearchPanel } = require('../src/sidebar/search-panel'); function mountPanel(deps = {}) { const container = document.createElement('div'); document.body.appendChild(container); const api = renderSearchPanel(container, { search: deps.search || jest.fn().mockResolvedValue([]), ask: deps.ask || jest.fn().mockResolvedValue({ question: '', chunks: [] }), getCurrentDir: deps.getCurrentDir || (() => '/notes'), onOpenFile: deps.onOpenFile || jest.fn(), }); return { container, api }; } async function flush() { // Drain any pending microtasks (search() → render) for (let i = 0; i < 5; i++) await Promise.resolve(); } describe('renderSearchPanel — DOM wiring', () => { test('mounts the panel structure inside its container', () => { const { container } = mountPanel(); expect(container.querySelector('.search-panel')).not.toBeNull(); expect(container.querySelector('#search-input')).not.toBeNull(); expect(container.querySelector('#search-run')).not.toBeNull(); expect(container.querySelectorAll('.search-mode-btn')).toHaveLength(2); }); test('seeds the folder input from getCurrentDir() on mount', () => { const { container } = mountPanel({ getCurrentDir: () => '/data/notes' }); expect(container.querySelector('#search-dir').value).toBe('/data/notes'); }); test('uses a helpful placeholder when no folder is available', () => { const { container } = mountPanel({ getCurrentDir: () => null }); const dirEl = container.querySelector('#search-dir'); expect(dirEl.value).toBe(''); expect(dirEl.placeholder).toMatch(/none/i); }); }); describe('renderSearchPanel — search mode', () => { test('Enter on the input triggers a search() with the typed query and dir', async () => { const search = jest.fn().mockResolvedValue([]); const { container } = mountPanel({ search }); const input = container.querySelector('#search-input'); input.value = 'rust async'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flush(); expect(search).toHaveBeenCalledWith({ query: 'rust async', dir: '/notes', limit: 50, }); }); test('clicking the run button triggers a search() too', async () => { const search = jest.fn().mockResolvedValue([]); const { container } = mountPanel({ search }); container.querySelector('#search-input').value = 'foo'; container.querySelector('#search-run').click(); await flush(); expect(search).toHaveBeenCalledTimes(1); }); test('renders result list when search returns hits', async () => { const search = jest.fn().mockResolvedValue([ { filePath: '/notes/a.md', snippet: 'rust async mention', score: 4.5, matchedTerms: ['rust'], matchedTags: [], matchedLinks: [], }, { filePath: '/notes/b.md', snippet: 'no match here', score: 1, matchedTerms: ['rust'], matchedTags: ['lang'], matchedLinks: [], }, ]); const { container } = mountPanel({ search }); container.querySelector('#search-input').value = 'rust'; container.querySelector('#search-run').click(); await flush(); const items = container.querySelectorAll('.search-result'); expect(items).toHaveLength(2); expect(items[0].textContent).toContain('a.md'); expect(items[0].textContent).toContain('rust async mention'); // tag facet surfaces in the meta line expect(items[1].textContent).toContain('#lang'); }); test('clicking a result calls onOpenFile with the right path', async () => { const onOpenFile = jest.fn(); const search = jest.fn().mockResolvedValue([ { filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] }, ]); const { container } = mountPanel({ search, onOpenFile }); container.querySelector('#search-input').value = 'x'; container.querySelector('#search-run').click(); await flush(); container.querySelector('.search-result').click(); expect(onOpenFile).toHaveBeenCalledWith('/notes/x.md', 0); }); test('shows "No matches." when the search returns []', async () => { const { container } = mountPanel({ search: jest.fn().mockResolvedValue([]) }); container.querySelector('#search-input').value = 'absent'; container.querySelector('#search-run').click(); await flush(); expect(container.querySelector('.search-empty')).not.toBeNull(); expect(container.querySelector('#search-status').textContent).toMatch(/0 matches/); }); test('shows an error message when search rejects', async () => { const search = jest.fn().mockRejectedValue(new Error('disk gone')); const { container } = mountPanel({ search }); container.querySelector('#search-input').value = 'x'; container.querySelector('#search-run').click(); await flush(); const status = container.querySelector('#search-status'); expect(status.textContent).toMatch(/disk gone/); expect(status.dataset.kind).toBe('error'); }); test('refuses to search when the query is empty', async () => { const search = jest.fn(); const { container } = mountPanel({ search }); container.querySelector('#search-run').click(); await flush(); expect(search).not.toHaveBeenCalled(); expect(container.querySelector('#search-status').textContent).toMatch(/Enter a query/); }); test('refuses to search when no folder is available', async () => { const search = jest.fn(); const { container } = mountPanel({ search, getCurrentDir: () => null }); container.querySelector('#search-input').value = 'x'; container.querySelector('#search-run').click(); await flush(); expect(search).not.toHaveBeenCalled(); expect(container.querySelector('#search-status').textContent).toMatch(/No folder/); }); }); describe('renderSearchPanel — Ask mode', () => { test('clicking the Ask tab switches placeholder and routes to ask()', async () => { const ask = jest.fn().mockResolvedValue({ question: '', chunks: [] }); const { container } = mountPanel({ ask }); container.querySelector('.search-mode-btn[data-mode="ask"]').click(); const input = container.querySelector('#search-input'); expect(input.placeholder).toMatch(/question/i); input.value = 'how does rust async work'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flush(); expect(ask).toHaveBeenCalledWith({ question: 'how does rust async work', dir: '/notes', topK: 5, }); }); test('renders Ask-mode chunks (filePath + snippet + offset)', async () => { const ask = jest.fn().mockResolvedValue({ question: 'q', chunks: [ { filePath: '/notes/c.md', offset: 1234, snippet: '...passage...', score: 3, mtimeMs: 1 }, ], }); const { container } = mountPanel({ ask }); container.querySelector('.search-mode-btn[data-mode="ask"]').click(); container.querySelector('#search-input').value = 'q'; container.querySelector('#search-run').click(); await flush(); const items = container.querySelectorAll('.search-result'); expect(items).toHaveLength(1); expect(items[0].textContent).toContain('c.md'); expect(items[0].textContent).toContain('+1234'); }); test('Ask-mode click passes the offset through to onOpenFile', async () => { const onOpenFile = jest.fn(); const ask = jest.fn().mockResolvedValue({ question: 'q', chunks: [{ filePath: '/notes/c.md', offset: 999, snippet: 's', score: 1 }], }); const { container } = mountPanel({ ask, onOpenFile }); container.querySelector('.search-mode-btn[data-mode="ask"]').click(); container.querySelector('#search-input').value = 'q'; container.querySelector('#search-run').click(); await flush(); container.querySelector('.search-result').click(); expect(onOpenFile).toHaveBeenCalledWith('/notes/c.md', 999); }); }); describe('renderSearchPanel — escaping', () => { test('result rows escape HTML in filenames and snippets', async () => { const search = jest.fn().mockResolvedValue([ { filePath: '/notes/.md', snippet: '', score: 1, matchedTerms: [], matchedTags: [''], }, ]); const { container } = mountPanel({ search }); container.querySelector('#search-input').value = 'x'; container.querySelector('#search-run').click(); await flush(); // No executable elements should have been built — escapeHtml() prevents // a hostile filename or snippet from running script/img in the panel. expect(container.querySelector('script')).toBeNull(); expect(container.querySelector('img')).toBeNull(); // The literal text still appears (decoded by the browser); the safety // is that it stays as text instead of becoming a node. expect(container.querySelector('.search-result').textContent).toContain('