Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/app/e2e/fast-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/app/e2e/helpers/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const APP_DIR = join(__dirname, '..', '..')
export interface AppContext {
app: ElectronApplication
window: Page
dbPath: string
cleanup: () => Promise<void>
}

Expand All @@ -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) {
Expand All @@ -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 })
Expand Down
39 changes: 39 additions & 0 deletions packages/app/e2e/helpers/seed.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
79 changes: 79 additions & 0 deletions packages/app/e2e/home-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <strong> (converted from FTS <mark>) 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 <strong> 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 })
})
67 changes: 63 additions & 4 deletions packages/app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>
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)
}
}
2 changes: 1 addition & 1 deletion packages/app/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const api = {
search: (query: string, limit?: number, source?: string): Promise<SearchResult[]> =>
ipcRenderer.invoke('spool:search', { query, limit, source }),

searchPreview: (query: string, limit?: number, source?: string): Promise<FragmentResult[]> =>
searchPreview: (query: string, limit?: number, source?: string): Promise<SearchResult[]> =>
ipcRenderer.invoke('spool:search-preview', { query, limit, source }),

listSessions: (limit?: number): Promise<Session[]> =>
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface RuntimeInfo {
export default function App() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [previewSuggestions, setPreviewSuggestions] = useState<FragmentResult[]>([])
const [previewSuggestions, setPreviewSuggestions] = useState<SearchResult[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [targetMessageId, setTargetMessageId] = useState<number | null>(null)
const [view, setView] = useState<View>('search')
Expand Down Expand Up @@ -62,6 +62,7 @@ export default function App() {
const [showSettings, setShowSettings] = useState(false)
const [settingsTab, setSettingsTab] = useState<SettingsTab>('general')
const [captureSources, setCaptureSources] = useState<Array<{ label: string; count: number }>>([])
const [platformColors, setPlatformColors] = useState<Record<string, string>>({})
const [defaultSearchSort, setDefaultSearchSort] = useState<SearchSortOrder>(DEFAULT_SEARCH_SORT_ORDER)
const [resumeToastCommand, setResumeToastCommand] = useState<string | null>(null)
const [connectorToast, setConnectorToast] = useState<string | null>(null)
Expand Down Expand Up @@ -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<string, string> = {}
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
Expand Down Expand Up @@ -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')
}, [])

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -437,6 +443,7 @@ export default function App() {
onOpenSession={handleOpenSession}
defaultSortOrder={defaultSearchSort}
onCopySessionId={handleCopySessionId}
platformColors={platformColors}
/>
</div>
)}
Expand Down
Loading