import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { munge, parseLines, scanProjects } from '../src/core/jsonl-discovery.js'; function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object[]): void { const dir = join(projectsDir, munge(cwd)); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `${sid}.jsonl`), `${lines.map((l) => JSON.stringify(l)).join('\n')}\n`); } describe('munge', () => { it('reproduit le nom de dossier ~/.claude/projects', () => { expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-'); expect(munge('/home/user/projects/demo')).toBe('-home-user-projects-demo'); }); }); describe('parseLines (tolérance)', () => { it('ignore les lignes vides, coupées ou malformées sans throw', () => { const text = '{"a":1}\n\n{not json}\n{"b":2}\n{"c":'; expect(parseLines(text)).toEqual([{ a: 1 }, { b: 2 }]); }); it('shift/pop les lignes partielles en bord de chunk', () => { const text = 'partial}\n{"ok":1}\n{also partial'; expect(parseLines(text, true, true)).toEqual([{ ok: 1 }]); }); }); describe('scanProjects', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'arb-jsonl-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); it('dossier absent → tableau vide', async () => { expect(await scanProjects(join(dir, 'nope'))).toEqual([]); }); it('extrait claudeSessionId + cwd + branche + version', async () => { writeJsonl(dir, '/home/u/proj', 'sid-1', [ { type: 'user', sessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170', message: { content: 'hello world' } }, ]); const res = await scanProjects(dir); expect(res).toHaveLength(1); expect(res[0]).toMatchObject({ claudeSessionId: 'sid-1', cwd: '/home/u/proj', gitBranch: 'main', version: '2.1.170' }); }); it('écarte un fichier sans cwd (queue-operation only)', async () => { writeJsonl(dir, '/x', 'sid-q', [{ type: 'queue-operation', sessionId: 'sid-q' }]); expect(await scanProjects(dir)).toEqual([]); }); it('survit à une ligne malformée et reste exploitable', async () => { const d = join(dir, munge('/home/u/p2')); mkdirSync(d, { recursive: true }); writeFileSync( join(d, 'sid-2.jsonl'), `{bad json\n${JSON.stringify({ type: 'user', sessionId: 'sid-2', cwd: '/home/u/p2', message: { content: 'ok' } })}\n`, ); const res = await scanProjects(dir); expect(res).toHaveLength(1); expect(res[0].claudeSessionId).toBe('sid-2'); }); describe('priorité de titre (ai-title > summary > last-prompt > 1er user)', () => { it('ai-title gagne sur tout', async () => { writeJsonl(dir, '/t/1', 's', [ { type: 'user', sessionId: 's', cwd: '/t/1', message: { content: 'premier prompt' } }, { type: 'summary', sessionId: 's', summary: 'un résumé' }, { type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' }, { type: 'ai-title', sessionId: 's', aiTitle: 'Titre IA' }, ]); expect((await scanProjects(dir))[0].title).toBe('Titre IA'); }); it('summary gagne sur last-prompt et user', async () => { writeJsonl(dir, '/t/2', 's', [ { type: 'user', sessionId: 's', cwd: '/t/2', message: { content: 'premier prompt' } }, { type: 'last-prompt', sessionId: 's', lastPrompt: 'dernier prompt' }, { type: 'summary', sessionId: 's', summary: 'un résumé' }, ]); expect((await scanProjects(dir))[0].title).toBe('un résumé'); }); it('repli ultime sur le premier prompt user', async () => { writeJsonl(dir, '/t/3', 's', [{ type: 'user', sessionId: 's', cwd: '/t/3', message: { content: 'premier prompt' } }]); expect((await scanProjects(dir))[0].title).toBe('premier prompt'); }); }); });