diff --git a/packages/app/e2e/fast-search.spec.ts b/packages/app/e2e/fast-search.spec.ts index dd62020..8c74366 100644 --- a/packages/app/e2e/fast-search.spec.ts +++ b/packages/app/e2e/fast-search.spec.ts @@ -130,6 +130,32 @@ test('session page can submit a new search without returning home first', async await expect(window.locator('[data-testid="fragment-row"]').first()).toContainText('TROMBONE_CANARY_99') }) +test('clicking an All-view fragment row jumps to message with flash highlight', async () => { + const { window } = ctx + + await search(window, 'XYLOPHONE_CANARY_42') + await window.locator('[data-testid="fragment-row"]').first().click() + + const target = window.locator('[data-testid="target-message"]') + await expect(target).toBeVisible({ timeout: 5000 }) + await expect(target).toHaveAttribute('data-highlighted', '1') + await expect(target).not.toHaveAttribute('data-highlighted', '1', { timeout: 5000 }) +}) + +test('source-filtered click still jumps to message with flash highlight', async () => { + const { window } = ctx + + // Multi-source query so the filter tabs render. + await search(window, 'CANARY') + // Switch to the claude filter tab, then click a surviving row. + await window.getByRole('button', { name: 'Claude Code' }).click() + await window.locator('[data-testid="fragment-row"]').first().click() + + const target = window.locator('[data-testid="target-message"]') + await expect(target).toBeVisible({ timeout: 5000 }) + await expect(target).toHaveAttribute('data-highlighted', '1') +}) + test('session page supports cmd or ctrl + f find-in-page', async () => { const { window } = ctx diff --git a/packages/app/e2e/helpers/launch.ts b/packages/app/e2e/helpers/launch.ts index 7f68a51..0974a78 100644 --- a/packages/app/e2e/helpers/launch.ts +++ b/packages/app/e2e/helpers/launch.ts @@ -11,6 +11,7 @@ const APP_DIR = join(__dirname, '..', '..') export interface AppContext { app: ElectronApplication window: Page + dbPath: string cleanup: () => Promise } @@ -33,6 +34,7 @@ export async function launchApp(opts: { mockAgent?: 'success' | 'error' } = {}): SPOOL_GEMINI_DIR: geminiCliHome, GEMINI_CLI_HOME: geminiCliHome, ELECTRON_DISABLE_GPU: '1', + SPOOL_E2E_TEST: '1', } if (opts.mockAgent) { @@ -55,6 +57,7 @@ export async function launchApp(opts: { mockAgent?: 'success' | 'error' } = {}): return { app, window, + dbPath: join(tmpDir, 'data', 'spool.db'), cleanup: async () => { await app.close() rmSync(tmpDir, { recursive: true, force: true }) diff --git a/packages/app/e2e/helpers/seed.ts b/packages/app/e2e/helpers/seed.ts new file mode 100644 index 0000000..9c6fb6e --- /dev/null +++ b/packages/app/e2e/helpers/seed.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'node:crypto' +import type { ElectronApplication } from '@playwright/test' + +export interface SeedCapture { + platform: string + platformId: string + title: string + url: string + content?: string + connectorId: string + author?: string +} + +/** + * Insert a capture + its M:N attribution into the app's DB by delegating + * to a test-only hook installed on `globalThis` in the main process. The + * hook is registered in `main/index.ts` when `SPOOL_E2E_TEST=1` is set, + * which the launch helper always does. This avoids: + * + * - Loading `better-sqlite3` in the test process (the app rebuilds it for + * the electron ABI, which can't be `require`d from a plain Node process). + * - Shelling out to the `sqlite3` CLI, whose FTS5 support is missing on + * macOS GitHub runners (the captures_fts triggers would fail). + */ +export async function seedCapture( + app: ElectronApplication, + capture: SeedCapture, +): Promise { + const captureUuid = randomUUID() + await app.evaluate(({}, args) => { + const g = globalThis as unknown as { + __spoolSeedCapture?: (args: unknown) => void + } + if (!g.__spoolSeedCapture) { + throw new Error('SPOOL_E2E_TEST hook not installed; did launchApp set the env var?') + } + g.__spoolSeedCapture(args) + }, { ...capture, captureUuid }) +} diff --git a/packages/app/e2e/home-preview.spec.ts b/packages/app/e2e/home-preview.spec.ts new file mode 100644 index 0000000..7edce03 --- /dev/null +++ b/packages/app/e2e/home-preview.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test' +import { launchApp, waitForSync, type AppContext } from './helpers/launch' +import { seedCapture } from './helpers/seed' + +let ctx: AppContext + +test.beforeAll(async () => { + ctx = await launchApp() + await waitForSync(ctx.window) + // Seed a connector capture whose text is unique so no session fixture + // accidentally matches the same keyword. This proves the preview surfaces + // captures even when there are zero session hits. + await seedCapture(ctx.app, { + platform: 'reddit', + platformId: 't3_seedtest', + title: 'ZZQCAPTURE_ONLY_UNIQUE post title', + url: 'https://reddit.com/r/test/comments/seedtest', + content: 'ZZQCAPTURE_ONLY_UNIQUE body content', + connectorId: 'reddit-saved', + author: 'e2e-seed', + }) +}) + +test.afterAll(async () => { + await ctx?.cleanup() +}) + +async function typeQuery(ctx: AppContext, query: string) { + const input = ctx.window.locator('[data-testid="search-input"]') + await input.fill(query) + // Do NOT press Enter — we want the dropdown preview, not the All view. +} + +test('home dropdown surfaces a capture when only a connector item matches', async () => { + await typeQuery(ctx, 'ZZQCAPTURE_ONLY_UNIQUE') + + const suggestion = ctx.window.locator('[data-testid="home-suggestion"][data-kind="capture"]') + await expect(suggestion).toBeVisible({ timeout: 5000 }) + await expect(suggestion).toContainText('ZZQCAPTURE_ONLY_UNIQUE') + await expect(suggestion).toContainText('reddit') +}) + +test('home dropdown session suggestion shows matched snippet with highlight', async () => { + await typeQuery(ctx, 'XYLOPHONE_CANARY_42') + + const suggestion = ctx.window.locator('[data-testid="home-suggestion"][data-kind="fragment"]').first() + await expect(suggestion).toBeVisible({ timeout: 5000 }) + // The second line uses (converted from FTS ) to highlight the hit. + await expect(suggestion.locator('strong')).toContainText('XYLOPHONE_CANARY_42') +}) + +test('home dropdown session snippet window is case-insensitive', async () => { + // Query lower-case; fixture stores the term upper-case. Before the fix, + // the snippet window fell back to position 0, so for long messages the + // matched text could be cut off. We assert the matched text is present + // in the snippet (ignoring tags). + await typeQuery(ctx, 'xylophone_canary_42') + + const suggestion = ctx.window.locator('[data-testid="home-suggestion"][data-kind="fragment"]').first() + await expect(suggestion).toBeVisible({ timeout: 5000 }) + // The highlight still fires because the regex is case-insensitive. + await expect(suggestion.locator('strong').first()).toContainText(/xylophone_canary_42/i) +}) + +test('clicking a home dropdown fragment jumps to message with flash highlight', async () => { + await typeQuery(ctx, 'XYLOPHONE_CANARY_42') + + const suggestion = ctx.window.locator('[data-testid="home-suggestion"][data-kind="fragment"]').first() + await expect(suggestion).toBeVisible({ timeout: 5000 }) + await suggestion.click() + + // Session detail opens and the target message is annotated. + const target = ctx.window.locator('[data-testid="target-message"]') + await expect(target).toBeVisible({ timeout: 5000 }) + // Highlight flag is present immediately after nav. + await expect(target).toHaveAttribute('data-highlighted', '1') + // And is removed after the ~2s timer — generous bound to stay non-flaky. + await expect(target).not.toHaveAttribute('data-highlighted', '1', { timeout: 5000 }) +}) diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts index 2da1197..4be5f0c 100644 --- a/packages/app/src/main/index.ts +++ b/packages/app/src/main/index.ts @@ -6,7 +6,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { Worker } from 'node:worker_threads' import { getDB, Syncer, SpoolWatcher, - searchFragments, searchAll, searchSessionPreview, listRecentSessions, getSessionWithMessages, getStatus, + searchFragments, searchAll, searchSessionPreview, searchCaptures, listRecentSessions, getSessionWithMessages, getStatus, ConnectorRegistry, SyncScheduler, loadSyncState, saveSyncState, loadConnectors, makeFetchCapability, makeChromeCookiesCapability, makeLogCapabilityFor, makeSqliteCapability, makeExecCapability, @@ -380,6 +380,7 @@ app.whenReady().then(async () => { Menu.setApplicationMenu(appMenu) db = getDB() + installE2ETestHooks(db) acpManager = new AcpManager() syncer = new Syncer(db) watcher = new SpoolWatcher(syncer) @@ -686,9 +687,24 @@ ipcMain.handle('spool:search-preview', (_e, { query, limit = 5, source }: { quer const cached = searchCache.get(cacheKey) if (cached) return cached - const results = source === 'claude' || source === 'codex' || source === 'gemini' - ? searchSessionPreview(db, query, { limit, source }) - : searchSessionPreview(db, query, { limit }) + // Session-scoped preview stays sessions-only. + if (source === 'claude' || source === 'codex' || source === 'gemini') { + const fragments = searchSessionPreview(db, query, { limit, source }) + .map(f => ({ ...f, kind: 'fragment' as const })) + searchCache.set(cacheKey, fragments) + return fragments + } + + // Unfiltered preview: fragments first (historical behavior), captures fill + // any remaining slots. Captures now appear when a query matches only + // connector content (e.g. a Reddit post). + const fragments = searchSessionPreview(db, query, { limit }) + .map(f => ({ ...f, kind: 'fragment' as const })) + const capLimit = Math.max(0, limit - fragments.length) + const captures = capLimit > 0 + ? searchCaptures(db, query, { limit: capLimit }).map(c => ({ ...c, kind: 'capture' as const })) + : [] + const results = [...fragments, ...captures] searchCache.set(cacheKey, results) return results @@ -1090,3 +1106,46 @@ ipcMain.handle('connector:open-external', async (_e, { url }: { url: string }) = await shell.openExternal(url) return { ok: true } }) + +// ── E2E test hooks ────────────────────────────────────────────────────────── +// Only active when SPOOL_E2E_TEST=1. Exposes a small seeding surface on +// globalThis so Playwright's app.evaluate() can insert fixture rows using +// the app's already-loaded, electron-ABI better-sqlite3 (the test process +// itself can't import better-sqlite3 without ABI mismatches, and the +// system `sqlite3` CLI on macOS runners lacks FTS5, which breaks the +// captures_fts triggers). +function installE2ETestHooks(sharedDb: Database.Database): void { + if (process.env['SPOOL_E2E_TEST'] !== '1') return + const g = globalThis as unknown as Record + g['__spoolSeedCapture'] = (args: { + platform: string + platformId: string + title: string + url: string + content?: string + connectorId: string + author?: string + captureUuid: string + }): void => { + const source = sharedDb.prepare("SELECT id FROM sources WHERE name = 'connector'").get() as + | { id: number } + | undefined + if (!source) throw new Error("'connector' source row missing") + + const info = sharedDb.prepare(` + INSERT INTO captures + (source_id, capture_uuid, url, title, content_text, author, + platform, platform_id, content_type, thumbnail_url, metadata, + captured_at, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'post', NULL, '{}', + datetime('now'), NULL) + `).run( + source.id, args.captureUuid, args.url, args.title, + args.content ?? args.title, args.author ?? null, + args.platform, args.platformId, + ) + sharedDb.prepare( + 'INSERT OR IGNORE INTO capture_connectors (capture_id, connector_id) VALUES (?, ?)', + ).run(info.lastInsertRowid, args.connectorId) + } +} diff --git a/packages/app/src/preload/index.ts b/packages/app/src/preload/index.ts index f2e0576..2fc3f5a 100644 --- a/packages/app/src/preload/index.ts +++ b/packages/app/src/preload/index.ts @@ -44,7 +44,7 @@ const api = { search: (query: string, limit?: number, source?: string): Promise => ipcRenderer.invoke('spool:search', { query, limit, source }), - searchPreview: (query: string, limit?: number, source?: string): Promise => + searchPreview: (query: string, limit?: number, source?: string): Promise => ipcRenderer.invoke('spool:search-preview', { query, limit, source }), listSessions: (limit?: number): Promise => diff --git a/packages/app/src/renderer/App.tsx b/packages/app/src/renderer/App.tsx index 8fb12da..cb8ee96 100644 --- a/packages/app/src/renderer/App.tsx +++ b/packages/app/src/renderer/App.tsx @@ -33,7 +33,7 @@ interface RuntimeInfo { export default function App() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) - const [previewSuggestions, setPreviewSuggestions] = useState([]) + const [previewSuggestions, setPreviewSuggestions] = useState([]) const [selectedSession, setSelectedSession] = useState(null) const [targetMessageId, setTargetMessageId] = useState(null) const [view, setView] = useState('search') @@ -62,6 +62,7 @@ export default function App() { const [showSettings, setShowSettings] = useState(false) const [settingsTab, setSettingsTab] = useState('general') const [captureSources, setCaptureSources] = useState>([]) + const [platformColors, setPlatformColors] = useState>({}) const [defaultSearchSort, setDefaultSearchSort] = useState(DEFAULT_SEARCH_SORT_ORDER) const [resumeToastCommand, setResumeToastCommand] = useState(null) const [connectorToast, setConnectorToast] = useState(null) @@ -176,11 +177,14 @@ export default function App() { if (!window.spool?.connectors) return window.spool.connectors.list().then(async connectors => { const results: Array<{ label: string; count: number }> = [] + const colors: Record = {} for (const c of connectors) { + if (c.platform && c.color) colors[c.platform] = c.color const count = await window.spool.connectors.getCaptureCount(c.id) if (count > 0) results.push({ label: c.label, count }) } setCaptureSources(results) + setPlatformColors(colors) }).catch(console.error) }, []) refreshCaptureSourcesRef.current = refreshCaptureSources @@ -320,9 +324,10 @@ export default function App() { } }, [query, doSearch]) - const handleSelectSuggestion = useCallback((uuid: string) => { + const handleSelectSuggestion = useCallback((uuid: string, messageId?: number) => { setHomeMode(false) setSelectedSession(uuid) + setTargetMessageId(messageId ?? null) setView('session') }, []) @@ -372,6 +377,7 @@ export default function App() { codexCount={status?.codexSessions ?? null} geminiCount={status?.geminiSessions ?? null} captureSources={captureSources} + platformColors={platformColors} mode={searchMode} {...(hasAgents ? { onModeChange: handleModeChange } : {})} onConnectClick={handleConnectClick} @@ -437,6 +443,7 @@ export default function App() { onOpenSession={handleOpenSession} defaultSortOrder={defaultSearchSort} onCopySessionId={handleCopySessionId} + platformColors={platformColors} /> )} diff --git a/packages/app/src/renderer/components/FragmentResults.tsx b/packages/app/src/renderer/components/FragmentResults.tsx index 585b354..91e1545 100644 --- a/packages/app/src/renderer/components/FragmentResults.tsx +++ b/packages/app/src/renderer/components/FragmentResults.tsx @@ -10,9 +10,10 @@ type Props = { onOpenSession: (uuid: string, messageId?: number) => void defaultSortOrder: SearchSortOrder onCopySessionId: (source: FragmentResult['source']) => void + platformColors: Record } -export default function FragmentResults({ results, query, onOpenSession, defaultSortOrder, onCopySessionId }: Props) { +export default function FragmentResults({ results, query, onOpenSession, defaultSortOrder, onCopySessionId, platformColors }: Props) { const [activeFilter, setActiveFilter] = useState('all') const [sortOrder, setSortOrder] = useState(defaultSortOrder) @@ -94,7 +95,7 @@ export default function FragmentResults({ results, query, onOpenSession, default
{sortedResults.map((result, i) => result.kind === 'capture' - ? + ? : )}
@@ -155,7 +156,10 @@ function FragmentRow({ ) } -function CaptureRow({ result }: { result: CaptureResult & { kind: 'capture' } }) { +function CaptureRow({ result, platformColors }: { + result: CaptureResult & { kind: 'capture' } + platformColors: Record +}) { const snippet = result.snippet.replace(//g, '').replace(/<\/mark>/g, '') const date = formatDate(result.capturedAt) @@ -167,7 +171,7 @@ function CaptureRow({ result }: { result: CaptureResult & { kind: 'capture' } }) className="block px-4 py-3 hover:bg-warm-surface dark:hover:bg-dark-surface transition-colors" >
- + {result.author ? `You saved this · ${result.author}` : 'You saved this'} @@ -186,24 +190,11 @@ function CaptureRow({ result }: { result: CaptureResult & { kind: 'capture' } }) ) } -const PLATFORM_BADGE_COLORS: Record = { - twitter: '#3A3A3A', - github: '#555555', - youtube: '#B22222', - reddit: '#FF4500', - hackernews: '#FF6600', - bilibili: '#FB7299', - weibo: '#E6162D', - xiaohongshu: '#FE2C55', - douban: '#007722', - linkedin: '#0A66C2', -} - -function PlatformBadge({ platform }: { platform: string }) { +function PlatformBadge({ platform, color }: { platform: string; color: string }) { return ( {platform} diff --git a/packages/app/src/renderer/components/HomeView.tsx b/packages/app/src/renderer/components/HomeView.tsx index 4873f73..5cf57ff 100644 --- a/packages/app/src/renderer/components/HomeView.tsx +++ b/packages/app/src/renderer/components/HomeView.tsx @@ -1,4 +1,4 @@ -import type { FragmentResult } from '@spool/core' +import type { FragmentResult, CaptureResult, SearchResult } from '@spool/core' import SearchBar, { type SearchMode } from './SearchBar.js' import { getSessionSourceColor } from '../../shared/sessionSources.js' @@ -6,8 +6,8 @@ interface Props { query: string onChange: (q: string) => void onSubmit: () => void - onSelectSuggestion: (uuid: string) => void - suggestions: FragmentResult[] + onSelectSuggestion: (uuid: string, messageId?: number) => void + suggestions: SearchResult[] isSearching: boolean hasSettledQuery: boolean isDev: boolean @@ -18,9 +18,10 @@ interface Props { mode: SearchMode onModeChange?: ((mode: SearchMode) => void) | undefined onConnectClick: () => void + platformColors: Record } -export default function HomeView({ query, onChange, onSubmit, onSelectSuggestion, suggestions, isSearching, hasSettledQuery, isDev, claudeCount, codexCount, geminiCount, captureSources, mode, onModeChange, onConnectClick }: Props) { +export default function HomeView({ query, onChange, onSubmit, onSelectSuggestion, suggestions, isSearching, hasSettledQuery, isDev, claudeCount, codexCount, geminiCount, captureSources, mode, onModeChange, onConnectClick, platformColors }: Props) { const showPreview = query.trim().length > 0 const previewState = suggestions.length > 0 ? 'results' @@ -63,29 +64,11 @@ export default function HomeView({ query, onChange, onSubmit, onSelectSuggestion ].join(' ')} >
- {previewState === 'results' && suggestions.slice(0, 3).map(s => ( - - ))} + {previewState === 'results' && suggestions.slice(0, 3).map(s => + s.kind === 'capture' + ? + : + )} {previewState === 'loading' && (
@@ -114,6 +97,76 @@ export default function HomeView({ query, onChange, onSubmit, onSelectSuggestion ) } +function SuggestionDot({ color }: { color: string }) { + // h-5 matches text-sm line-height (20px) so the dot aligns to the + // first line's vertical center instead of the 2-line block center. + return ( + + + + ) +} + +function FragmentSuggestionRow({ result, onSelect }: { + result: FragmentResult & { kind: 'fragment' } + onSelect: (uuid: string, messageId?: number) => void +}) { + const snippet = result.snippet.replace(//g, '').replace(/<\/mark>/g, '') + return ( + + ) +} + +function CaptureSuggestionRow({ result, platformColors }: { + result: CaptureResult & { kind: 'capture' } + platformColors: Record +}) { + const origin = result.author + ? `${result.platform} · You saved this · ${result.author}` + : `${result.platform} · You saved this` + return ( + + + + + {result.title || result.url} + + + {origin} + + + + ) +} + interface SourceChipsProps { claudeCount: number | null codexCount: number | null diff --git a/packages/app/src/renderer/components/SessionDetail.tsx b/packages/app/src/renderer/components/SessionDetail.tsx index b96bf8f..a8a38ad 100644 --- a/packages/app/src/renderer/components/SessionDetail.tsx +++ b/packages/app/src/renderer/components/SessionDetail.tsx @@ -14,6 +14,7 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [showFindBar, setShowFindBar] = useState(false) + const [showTargetHighlight, setShowTargetHighlight] = useState(false) const [findFocusNonce, setFindFocusNonce] = useState(0) const [findResultNonce, setFindResultNonce] = useState(0) const [findQuery, setFindQuery] = useState('') @@ -85,7 +86,11 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess useEffect(() => { if (!loading && targetMessageId && targetRef.current) { targetRef.current.scrollIntoView({ behavior: 'instant', block: 'center' }) + setShowTargetHighlight(true) + const timer = setTimeout(() => setShowTargetHighlight(false), 2000) + return () => clearTimeout(timer) } + return undefined }, [loading, targetMessageId]) useEffect(() => { @@ -240,6 +245,11 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
[row.sessionId, row])) } -function buildLikeSnippet(text: string, terms: string[]): string { +export function buildLikeSnippet(text: string, terms: string[]): string { const normalizedText = text.trim() if (!normalizedText) return '' + // Case-insensitive hit search: the upstream SQL uses LIKE which is ASCII + // case-insensitive, so the matched content may differ in case from the + // query terms (e.g. user typed "dark fantasy", text has "Dark Fantasy"). + const lowerText = normalizedText.toLowerCase() const firstHit = terms - .map(term => normalizedText.indexOf(term)) + .map(term => lowerText.indexOf(term.toLowerCase())) .filter(index => index >= 0) .sort((a, b) => a - b)[0] ?? 0 @@ -819,14 +823,21 @@ function buildLikeSnippet(text: string, terms: string[]): string { if (start > 0) snippet = `…${snippet}` if (end < normalizedText.length) snippet = `${snippet}…` + // Highlight preserving original casing via case-insensitive regex. const uniqueTerms = Array.from(new Set(terms)).sort((a, b) => b.length - a.length) for (const term of uniqueTerms) { - snippet = snippet.split(term).join(`${term}`) + if (!term) continue + const pattern = new RegExp(escapeRegex(term), 'gi') + snippet = snippet.replace(pattern, m => `${m}`) } return snippet } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + function toLikePattern(term: string): string { return `%${escapeLike(term)}%` } diff --git a/packages/core/src/db/search-query.test.ts b/packages/core/src/db/search-query.test.ts index b4f9cc1..e8957ef 100644 --- a/packages/core/src/db/search-query.test.ts +++ b/packages/core/src/db/search-query.test.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3' import { afterEach, describe, expect, it } from 'vitest' import { buildFtsQuery, buildSearchPlan, selectFtsTableKind, shouldUseSessionFallback } from './search-query.js' -import { searchFragments } from './queries.js' +import { buildLikeSnippet, searchFragments } from './queries.js' const dbs: Database.Database[] = [] @@ -43,6 +43,38 @@ describe('buildFtsQuery', () => { }) }) +describe('buildLikeSnippet', () => { + it('centers the window around the first hit (case-insensitive)', () => { + const longPrefix = 'x'.repeat(200) + const text = `${longPrefix} Dark Fantasy Realms tail` + const snippet = buildLikeSnippet(text, ['dark', 'fantasy']) + // Must contain the matched segment (original casing, ignoring ). + const stripped = snippet.replace(/<\/?mark>/g, '') + expect(stripped).toContain('Dark Fantasy Realms') + // Leading ellipsis proves the window slid off the start rather than + // falling back to position 0 (the pre-fix behavior). + expect(snippet.startsWith('…')).toBe(true) + }) + + it('wraps matches in preserving original casing', () => { + const snippet = buildLikeSnippet('A quick Dark Fantasy adventure', ['dark', 'fantasy']) + expect(snippet).toContain('Dark') + expect(snippet).toContain('Fantasy') + }) + + it('returns empty string for empty input', () => { + expect(buildLikeSnippet(' ', ['anything'])).toBe('') + expect(buildLikeSnippet('', [])).toBe('') + }) + + it('escapes regex metacharacters in terms', () => { + // A term containing regex special chars must not blow up and must still + // match literally. + const snippet = buildLikeSnippet('Look at v1.2.3 release', ['1.2.3']) + expect(snippet).toContain('1.2.3') + }) +}) + describe('searchFragments', () => { it('finds messages that contain separated keywords from one natural-language query', () => { const db = createSearchTestDb()