cc-haha/src/server/__tests__/searchService.sessions.test.ts
程序员阿江(Relakkes) 1a050242b7 feat(search): global session full-text search
Add a Codex-style global search dialog (Cmd+K / sidebar button) that
full-text searches across all session transcripts, replacing the old
title-only sidebar filter.

Backend: rewrite searchService.searchSessions as a two-phase engine —
ripgrep finds candidate files + matched lines, then those lines are
parsed to keep only user/assistant text, re-confirmed against the
cleaned text to drop JSON/UUID/base64 false positives, and windowed into
highlighted snippets. Results carry real session titles (new
sessionService.getSessionTitleAndMeta reusing the list title
precedence), project path, mtime, role and match counts; falls back to a
JS scan when ripgrep is unavailable.

Frontend: new GlobalSearchModal (debounced, stale-response-safe, keyboard
nav, role badges, highlighting, recent-chats empty state); Cmd+K now
opens it; the sidebar input is replaced by a search trigger button.

Tests: 15 backend cases (searchService.sessions) + 12 frontend cases
(GlobalSearchModal); existing Sidebar/pages tests updated for the new
trigger.
2026-06-15 19:06:00 +08:00

257 lines
9.5 KiB
TypeScript

/**
* Unit tests for SearchService.searchSessions — global session full-text search.
*
* Builds throwaway ~/.claude/projects/<dir>/<uuid>.jsonl fixtures under a temp
* CLAUDE_CONFIG_DIR and exercises the two-phase (ripgrep → parse/clean) engine.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { SearchService } from '../services/searchService.js'
let tmpDir: string
let service: SearchService
async function setupTmpConfigDir(): Promise<void> {
tmpDir = path.join(
os.tmpdir(),
`cc-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
process.env.CLAUDE_CONFIG_DIR = tmpDir
}
async function cleanupTmpDir(): Promise<void> {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
}
delete process.env.CLAUDE_CONFIG_DIR
}
/** Write a JSONL session file. Entries may be objects (serialized) or raw strings (for malformed-line tests). */
async function writeSessionFile(
projectDir: string,
sessionId: string,
entries: Array<Record<string, unknown> | string>,
): Promise<string> {
const dir = path.join(tmpDir, 'projects', projectDir)
await fs.mkdir(dir, { recursive: true })
const filePath = path.join(dir, `${sessionId}.jsonl`)
const content =
entries.map((e) => (typeof e === 'string' ? e : JSON.stringify(e))).join('\n') + '\n'
await fs.writeFile(filePath, content, 'utf-8')
return filePath
}
beforeEach(async () => {
await setupTmpConfigDir()
service = new SearchService()
})
afterEach(cleanupTmpDir)
describe('SearchService.searchSessions', () => {
it('matches user message text and returns role=user with correct highlights', async () => {
await writeSessionFile('proj-a', 'session-1', [
{
type: 'user',
uuid: 'u1',
timestamp: '2026-06-01T00:00:00.000Z',
message: { role: 'user', content: 'please implement global search feature' },
},
])
const { results } = await service.searchSessions('global search')
expect(results).toHaveLength(1)
expect(results[0].sessionId).toBe('session-1')
expect(results[0].projectPath).toBe('proj-a')
const m = results[0].matches[0]
expect(m.role).toBe('user')
expect(m.messageId).toBe('u1')
expect(m.lineNumber).toBe(1)
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end).toLowerCase()).toBe(
'global search',
)
})
it('matches assistant text blocks and returns role=assistant', async () => {
await writeSessionFile('proj-a', 'session-2', [
{
type: 'assistant',
uuid: 'a1',
message: { role: 'assistant', content: [{ type: 'text', text: 'I recommend ripgrep for this' }] },
},
])
const { results } = await service.searchSessions('ripgrep')
expect(results).toHaveLength(1)
expect(results[0].matches[0].role).toBe('assistant')
})
it('matches Chinese content with correct highlight slicing', async () => {
await writeSessionFile('proj-a', 'session-3', [
{ type: 'user', message: { role: 'user', content: '帮我做一个全文搜索功能' } },
])
const { results } = await service.searchSessions('全文搜索')
expect(results).toHaveLength(1)
const m = results[0].matches[0]
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('全文搜索')
})
it('searches only user/assistant text, ignoring tool_use and tool_result blocks', async () => {
await writeSessionFile('proj-a', 'session-4', [
{
type: 'assistant',
uuid: 'a2',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'running the command now' },
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'rg zzytoolmarker --json' } },
],
},
},
{
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'zzytoolmarker found in 3 files' }],
},
},
])
// 'zzytoolmarker' only lives in tool_use input + tool_result content → must NOT match.
const tool = await service.searchSessions('zzytoolmarker')
expect(tool.results).toHaveLength(0)
// The assistant's natural-language text is searchable.
const text = await service.searchSessions('running the command')
expect(text.results).toHaveLength(1)
expect(text.results[0].matches[0].role).toBe('assistant')
})
it('drops ripgrep false positives that only hit JSON structure (keys/uuids)', async () => {
await writeSessionFile('proj-a', 'session-5', [
{ type: 'user', uuid: 'assistant-like-uuid', message: { role: 'user', content: 'hello world' } },
])
// 'content' is a JSON key, never part of the readable text 'hello world'.
const noise = await service.searchSessions('content')
expect(noise.results).toHaveLength(0)
const real = await service.searchSessions('hello world')
expect(real.results).toHaveLength(1)
})
it('skips internal command breadcrumb entries', async () => {
await writeSessionFile('proj-a', 'session-6', [
{ type: 'user', message: { role: 'user', content: '<command-name>deploy</command-name> magicword' } },
])
const { results } = await service.searchSessions('magicword')
expect(results).toHaveLength(0)
})
it('resolves the real session title (custom-title wins) instead of the UUID', async () => {
await writeSessionFile('proj-a', 'titled-session', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } },
{ type: 'ai-title', aiTitle: 'AI Generated Title', sessionId: 'titled-session' },
{ type: 'custom-title', customTitle: 'My Custom Title', sessionId: 'titled-session' },
])
const { results } = await service.searchSessions('searchword')
expect(results[0].title).toBe('My Custom Title')
expect(results[0].title).not.toBe('titled-session')
})
it('falls back to the AI title when there is no custom title', async () => {
await writeSessionFile('proj-a', 'ai-titled', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'another searchword here' } },
{ type: 'ai-title', aiTitle: 'Smart Title', sessionId: 'ai-titled' },
])
const { results } = await service.searchSessions('another searchword')
expect(results[0].title).toBe('Smart Title')
})
it('windows snippets for very long lines', async () => {
const filler = 'x'.repeat(50_000)
await writeSessionFile('proj-a', 'session-8', [
{ type: 'user', message: { role: 'user', content: `${filler} NEEDLEWORD ${filler}` } },
])
const { results } = await service.searchSessions('NEEDLEWORD')
expect(results).toHaveLength(1)
const m = results[0].matches[0]
expect(m.snippet.length).toBeLessThan(600)
expect(m.snippet).toContain('…')
expect(m.snippet.slice(m.highlights[0].start, m.highlights[0].end)).toBe('NEEDLEWORD')
})
it('caps matches per session but reports the full matchCount', async () => {
const entries = Array.from({ length: 8 }, (_, i) => ({
type: 'user',
uuid: `u${i}`,
message: { role: 'user', content: `repeatword occurrence number ${i}` },
}))
await writeSessionFile('proj-a', 'session-9', entries)
const { results } = await service.searchSessions('repeatword', { matchesPerSession: 3 })
expect(results[0].matchCount).toBe(8)
expect(results[0].matches).toHaveLength(3)
})
it('orders sessions by most-recently modified first', async () => {
const older = await writeSessionFile('proj-a', 'older', [
{ type: 'user', message: { role: 'user', content: 'sortword in older' } },
])
await writeSessionFile('proj-b', 'newer', [
{ type: 'user', message: { role: 'user', content: 'sortword in newer' } },
])
const past = new Date(Date.now() - 60_000)
await fs.utimes(older, past, past)
const { results } = await service.searchSessions('sortword')
expect(results.map((r) => r.sessionId)).toEqual(['newer', 'older'])
})
it('skips malformed/half-written lines without crashing', async () => {
await writeSessionFile('proj-a', 'session-11', [
{ type: 'user', message: { role: 'user', content: 'valid brokenmarker line' } },
'{ this is not valid json brokenmarker',
])
const { results } = await service.searchSessions('brokenmarker')
expect(results).toHaveLength(1)
expect(results[0].matchCount).toBe(1)
})
it('throws on empty or whitespace-only query', async () => {
await expect(service.searchSessions('')).rejects.toThrow()
await expect(service.searchSessions(' ')).rejects.toThrow()
})
it('falls back to a JS scan when ripgrep is unavailable', async () => {
await writeSessionFile('proj-a', 'session-13', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'fallbackword works fine' } },
])
;(service as unknown as { commandExists: () => Promise<boolean> }).commandExists = async () => false
const { results } = await service.searchSessions('fallbackword')
expect(results).toHaveLength(1)
expect(results[0].matches[0].role).toBe('user')
expect(results[0].matches[0].snippet).toContain('fallbackword')
})
it('returns empty when the projects dir does not exist', async () => {
await fs.rm(path.join(tmpDir, 'projects'), { recursive: true, force: true })
const { results, truncated } = await service.searchSessions('anything')
expect(results).toEqual([])
expect(truncated).toBe(false)
})
})