Files
markdown-converter/tests/search-panel.test.js
T
amitwh b1b72f9a60 chore(release): bump v4.7.1 → v4.8.0; Prettier pass on overnight-session files
12 commits on master since v4.7.1 baseline, taking tests 524 → 914 across
27 new suites. This commit:
- Bumps package.json + README version to v4.8.0 (additive features → semver minor)
- Applies Prettier formatting to all files touched during the overnight
  session so release build is reproducible from a clean repo

New in v4.8.0:
- PDF encryption close-out (D1)
- KaTeX rendering test coverage
- Autosave buffer + crash recovery banner
- Daily notes + workspace search + doc-aware Q&A
- Search sidebar panel + Ask mode
- Status bar: word count, reading time, Flesch-Kincaid grade
- Footnote hover preview
- Smart paste (URL → link, CSV/TSV → table)
- Daily notes menu item + sidebar panel + Q&A deep-link
- Daily templates gallery
- Pluggable DocQA engine (TF-idF default + lazy neural embeddings)

Amit Haridas
2026-09-14 14:56:05 +05:30

285 lines
11 KiB
JavaScript

/**
* @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/<script>alert(1)</script>.md',
snippet: '<img src=x onerror=alert(1)>',
score: 1,
matchedTerms: [],
matchedTags: ['<unsafe>'],
},
]);
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('<script>');
expect(container.querySelector('.search-result').textContent).toContain('<img');
expect(container.querySelector('.search-result').textContent).toContain('#<unsafe>');
});
});
describe('renderSearchPanel — clear/escape behaviors', () => {
test('Escape on the input clears the query and results', async () => {
const search = jest.fn().mockResolvedValue([{ filePath: '/a.md', snippet: 's', score: 1 }]);
const { container, container: c2 } = mountPanel({ search });
const input = container.querySelector('#search-input');
input.value = 'x';
container.querySelector('#search-run').click();
await flush();
expect(container.querySelectorAll('.search-result')).toHaveLength(1);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flush();
expect(input.value).toBe('');
expect(container.querySelector('.search-empty')).not.toBeNull();
// Clean up the c2 reference just to silence the unused-var lint warning.
void c2;
});
test('Clear button resets input + results', async () => {
const { container } = mountPanel({ search: jest.fn().mockResolvedValue([]) });
const input = container.querySelector('#search-input');
input.value = 'x';
container.querySelector('#search-clear').click();
expect(input.value).toBe('');
expect(container.querySelector('.search-empty')).not.toBeNull();
});
test('returned API can set the dir + focus + clear from the host', () => {
const { container, api } = mountPanel();
api.setDir('/new/path');
expect(container.querySelector('#search-dir').value).toBe('/new/path');
// focus() and clear() are exposed; ensure they don't throw.
api.focus();
api.clear();
expect(container.querySelector('#search-input').value).toBe('');
});
});