From e4f6d61b421426f6e7cd08a3de237d19bf8fea6e Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:41:52 +0800 Subject: [PATCH 1/3] fix: quick-search surfaces connector captures + restore target flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home dropdown ignored connector captures entirely, so searching a term that only matched a Reddit/HN/etc. post returned "No quick matches yet" even though pressing Enter would find it in the full view. The snippet generator was also case-sensitive on its window-placement heuristic — so typing "dark fantasy" never found the "Dark Fantasy Realms" hit and just sliced from position 0, hiding the match. Clicking a dropdown suggestion had dropped the message-id plumbing, and the 2-second amber flash on the target message (PR #30) had been removed incidentally in PR #48. Changes: - spool:search-preview IPC merges searchSessionPreview + searchCaptures; sessions still get priority, captures fill remaining slots so search behaviour for session-only users is unchanged - Renderer types propagate SearchResult[] end-to-end; home dropdown renders kind-specific rows (dot + title + snippet/source line), capture rows link out to the URL - Platform colors now come from the connector manifest via connectors.list() instead of a hardcoded map that silently fell through to accent for unknown plugins - buildLikeSnippet lowercases both sides for indexOf and uses a case- insensitive regex for wrapping, preserving original casing - handleSelectSuggestion accepts messageId so home-dropdown clicks land on the exact matched message - Restore showTargetHighlight state + 2s timer removed in PR #48; expose data-highlighted attribute for stable e2e assertions Tests: - 4 unit tests for buildLikeSnippet (case-insensitive window, case- preserving highlight, empty input, regex-metachar safety) - 4 e2e tests covering capture-only query, session snippet highlight, case-insensitive search, and click-to-flash — flash assertion keyed on data-highlighted attribute (not Tailwind class) to stay non-flaky - Captures seeded via sqlite3 CLI to avoid node/electron ABI mismatch --- packages/app/e2e/helpers/launch.ts | 2 + packages/app/e2e/helpers/seed.ts | 50 ++++++++ packages/app/e2e/home-preview.spec.ts | 79 +++++++++++++ packages/app/src/main/index.ts | 23 +++- packages/app/src/preload/index.ts | 2 +- packages/app/src/renderer/App.tsx | 11 +- .../renderer/components/FragmentResults.tsx | 29 ++--- .../app/src/renderer/components/HomeView.tsx | 107 +++++++++++++----- .../src/renderer/components/SessionDetail.tsx | 10 ++ packages/core/src/db/queries.ts | 17 ++- packages/core/src/db/search-query.test.ts | 34 +++++- 11 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 packages/app/e2e/helpers/seed.ts create mode 100644 packages/app/e2e/home-preview.spec.ts diff --git a/packages/app/e2e/helpers/launch.ts b/packages/app/e2e/helpers/launch.ts index 7f68a51..1f5f33a 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 } @@ -55,6 +56,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..487999f --- /dev/null +++ b/packages/app/e2e/helpers/seed.ts @@ -0,0 +1,50 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' + +export interface SeedCapture { + platform: string + platformId: string + title: string + url: string + content?: string + connectorId: string + author?: string +} + +/** + * Insert a capture + its M:N attribution directly into the DB. Uses the + * sqlite3 CLI (preinstalled on macOS and ubuntu-latest) instead of better- + * sqlite3, so the test process doesn't need a node-ABI native binding when + * the app is built against the electron ABI. + * + * Safe to call after `waitForSync` — the app opens WAL-mode, which allows + * a second writer without locking issues for a handful of rows. + */ +export function seedCapture(dbPath: string, capture: SeedCapture): void { + const captureUuid = randomUUID() + const sql = ` + 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 ( + (SELECT id FROM sources WHERE name = 'connector'), + '${captureUuid}', + '${sqlEscape(capture.url)}', + '${sqlEscape(capture.title)}', + '${sqlEscape(capture.content ?? capture.title)}', + ${capture.author ? `'${sqlEscape(capture.author)}'` : 'NULL'}, + '${sqlEscape(capture.platform)}', + '${sqlEscape(capture.platformId)}', + 'post', NULL, '{}', + datetime('now'), NULL + ); + INSERT OR IGNORE INTO capture_connectors (capture_id, connector_id) + VALUES (last_insert_rowid(), '${sqlEscape(capture.connectorId)}'); + ` + execFileSync('sqlite3', [dbPath, sql], { stdio: 'pipe' }) +} + +function sqlEscape(value: string): string { + return value.replace(/'/g, "''") +} diff --git a/packages/app/e2e/home-preview.spec.ts b/packages/app/e2e/home-preview.spec.ts new file mode 100644 index 0000000..6ff4de0 --- /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. + seedCapture(ctx.dbPath, { + 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..c95955d 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, @@ -686,9 +686,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 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() From f2a1862f10e559d5b36661396d42838f96e675df Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:51:35 +0800 Subject: [PATCH 2/3] test: add e2e coverage for All-view and source-filtered click-to-flash PR #86 only covered the home dropdown click path. Add matching coverage for the two other entry points that flow through handleOpenSession: - Clicking a row directly from the All view - Clicking a row after applying a source filter (Claude Code tab) Both paths share the same handler, but the UI surface differs; catching a regression in either one previously required manual testing. --- packages/app/e2e/fast-search.spec.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From c55b351f1dc3a278216e4d454aafa313f20acbb5 Mon Sep 17 00:00:00 2001 From: Chen <99816898+donteatfriedrice@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:00:11 +0800 Subject: [PATCH 3/3] fix(e2e): seed via main-process hook instead of sqlite3 CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS GitHub runner ships sqlite3 without FTS5, so the captures_fts INSERT trigger failed when the CLI tried to write a seeded row. Install a globalThis.__spoolSeedCapture hook in main/index.ts when SPOOL_E2E_TEST=1 is set (launchApp always sets it). The hook reuses the app's already-loaded better-sqlite3 (which bundles its own SQLite with FTS5 regardless of the OS) and the live db handle, so there's no ABI mismatch and no second connection. Dynamic import and process.mainModule.require were both tried first but neither is available in Playwright's evaluate sandbox — a named globalThis hook is the only approach that works with stock Electron. --- packages/app/e2e/helpers/launch.ts | 1 + packages/app/e2e/helpers/seed.ts | 55 +++++++++++---------------- packages/app/e2e/home-preview.spec.ts | 2 +- packages/app/src/main/index.ts | 44 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/helpers/launch.ts b/packages/app/e2e/helpers/launch.ts index 1f5f33a..0974a78 100644 --- a/packages/app/e2e/helpers/launch.ts +++ b/packages/app/e2e/helpers/launch.ts @@ -34,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) { diff --git a/packages/app/e2e/helpers/seed.ts b/packages/app/e2e/helpers/seed.ts index 487999f..9c6fb6e 100644 --- a/packages/app/e2e/helpers/seed.ts +++ b/packages/app/e2e/helpers/seed.ts @@ -1,5 +1,5 @@ -import { execFileSync } from 'node:child_process' import { randomUUID } from 'node:crypto' +import type { ElectronApplication } from '@playwright/test' export interface SeedCapture { platform: string @@ -12,39 +12,28 @@ export interface SeedCapture { } /** - * Insert a capture + its M:N attribution directly into the DB. Uses the - * sqlite3 CLI (preinstalled on macOS and ubuntu-latest) instead of better- - * sqlite3, so the test process doesn't need a node-ABI native binding when - * the app is built against the electron ABI. + * 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: * - * Safe to call after `waitForSync` — the app opens WAL-mode, which allows - * a second writer without locking issues for a handful of rows. + * - 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 function seedCapture(dbPath: string, capture: SeedCapture): void { +export async function seedCapture( + app: ElectronApplication, + capture: SeedCapture, +): Promise { const captureUuid = randomUUID() - const sql = ` - 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 ( - (SELECT id FROM sources WHERE name = 'connector'), - '${captureUuid}', - '${sqlEscape(capture.url)}', - '${sqlEscape(capture.title)}', - '${sqlEscape(capture.content ?? capture.title)}', - ${capture.author ? `'${sqlEscape(capture.author)}'` : 'NULL'}, - '${sqlEscape(capture.platform)}', - '${sqlEscape(capture.platformId)}', - 'post', NULL, '{}', - datetime('now'), NULL - ); - INSERT OR IGNORE INTO capture_connectors (capture_id, connector_id) - VALUES (last_insert_rowid(), '${sqlEscape(capture.connectorId)}'); - ` - execFileSync('sqlite3', [dbPath, sql], { stdio: 'pipe' }) -} - -function sqlEscape(value: string): string { - return value.replace(/'/g, "''") + 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 index 6ff4de0..7edce03 100644 --- a/packages/app/e2e/home-preview.spec.ts +++ b/packages/app/e2e/home-preview.spec.ts @@ -10,7 +10,7 @@ test.beforeAll(async () => { // 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. - seedCapture(ctx.dbPath, { + await seedCapture(ctx.app, { platform: 'reddit', platformId: 't3_seedtest', title: 'ZZQCAPTURE_ONLY_UNIQUE post title', diff --git a/packages/app/src/main/index.ts b/packages/app/src/main/index.ts index c95955d..4be5f0c 100644 --- a/packages/app/src/main/index.ts +++ b/packages/app/src/main/index.ts @@ -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) @@ -1105,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) + } +}