import type { Issue } from '../gitea/types' import { promises as fsp, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { annotateMissingSessionIds, claudeSessionFileExists, codexSessionFileExists, listClaudeSessionIds, listCodexSessionIds, } from './sessionFiles' const tempDirs: string[] = [] afterEach(() => { vi.restoreAllMocks() while (tempDirs.length > 0) { const dir = tempDirs.pop() if (dir) rmSync(dir, { recursive: true, force: true }) } }) function tempDir(prefix: string): string { const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) tempDirs.push(dir) return dir } const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' const UUID_B = '11111111-2222-4333-8444-555555555555' const SID_1 = 'aaaaaaaa-1111-4111-8111-111111111111' const SID_2 = 'bbbbbbbb-2222-4222-8222-222222222222' const SID_3 = 'cccccccc-3333-4333-8333-333333333333' function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void { const dayDir = path.join(baseDir, ...ymd) mkdirSync(dayDir, { recursive: true }) writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n') } function writeTranscript(projectsRoot: string, project: string, sid: string): void { const dir = path.join(projectsRoot, project) mkdirSync(dir, { recursive: true }) writeFileSync(path.join(dir, `${sid}.jsonl`), '{}\n') } describe('listCodexSessionIds', () => { it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => { const base = tempDir('codex-sessions-') writeRollout(base, ['2026', '04', '27'], UUID_A) writeRollout(base, ['2026', '05', '01'], UUID_B) const ids = await listCodexSessionIds(base) expect(ids).toEqual(new Set([UUID_A, UUID_B])) }) it('忽略不符合 rollout-*-.jsonl 的文件', async () => { const base = tempDir('codex-sessions-') const dayDir = path.join(base, '2026', '04', '27') mkdirSync(dayDir, { recursive: true }) writeFileSync(path.join(dayDir, 'notes.txt'), 'x') writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n') writeRollout(base, ['2026', '04', '27'], UUID_A) const ids = await listCodexSessionIds(base) expect(ids).toEqual(new Set([UUID_A])) }) it('目录不存在返回空 Set', async () => { const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope')) expect(ids.size).toBe(0) }) }) describe('listClaudeSessionIds', () => { it('收集各 project 目录下的 .jsonl,统一小写', async () => { const root = tempDir('claude-projects-') writeTranscript(root, '-Users-a-proj', SID_1.toUpperCase()) writeTranscript(root, '-Users-b-wt', SID_2) const ids = await listClaudeSessionIds(root) expect(ids).toEqual(new Set([SID_1, SID_2])) }) it('不下钻 / 子目录,忽略非 jsonl 文件和根目录下的普通文件', async () => { const root = tempDir('claude-projects-') writeTranscript(root, '-Users-a-proj', SID_1) const subagents = path.join(root, '-Users-a-proj', SID_1, 'subagents') mkdirSync(subagents, { recursive: true }) writeFileSync(path.join(subagents, `${SID_3}.jsonl`), '{}\n') writeFileSync(path.join(root, '-Users-a-proj', 'notes.txt'), 'x') writeFileSync(path.join(root, '.DS_Store'), 'x') const ids = await listClaudeSessionIds(root) expect(ids).toEqual(new Set([SID_1])) }) it('根目录不存在返回空 Set', async () => { const ids = await listClaudeSessionIds(path.join(tempDir('claude-projects-'), 'nope')) expect(ids.size).toBe(0) }) }) describe('单个会话文件判定', () => { it('claudeSessionFileExists:任一 project 目录下有 transcript 即为 true', async () => { const root = tempDir('claude-projects-') writeTranscript(root, '-Users-b-wt', SID_1) expect(await claudeSessionFileExists(SID_1, root)).toBe(true) expect(await claudeSessionFileExists(SID_2, root)).toBe(false) }) it('codexSessionFileExists:按 rollout 文件名尾部 uuid 判定', async () => { const base = tempDir('codex-sessions-') writeRollout(base, ['2026', '04', '27'], UUID_A) expect(await codexSessionFileExists(UUID_A.toUpperCase(), base)).toBe(true) expect(await codexSessionFileExists(UUID_B, base)).toBe(false) }) }) describe('annotateMissingSessionIds', () => { const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue it('claude + codex 混合:只列出本机没有会话文件的 id,全在本机的不加字段', async () => { const claudeRoot = tempDir('claude-projects-') const codexRoot = tempDir('codex-sessions-') writeTranscript(claudeRoot, '-Users-a-proj', SID_1) writeTranscript(claudeRoot, '-Users-b-wt', SID_2) writeRollout(codexRoot, ['2026', '04', '27'], UUID_A) const issues: Issue[] = [ { ...baseIssue, number: 1, sessionId: SID_1.toUpperCase(), implementSessionId: SID_3, reviewSessionId: UUID_A }, { ...baseIssue, number: 2, testSessionId: SID_3, prototypeSessionId: SID_2, reviewSessionId: UUID_B }, { ...baseIssue, number: 3, sessionId: SID_2, reviewSessionId: UUID_A.toUpperCase() }, { ...baseIssue, number: 4 }, ] const out = await annotateMissingSessionIds(issues, { claudeRoot, codexRoot }) expect(out[0].missingSessionIds).toEqual([SID_3]) expect(out[1].missingSessionIds).toEqual([SID_3, UUID_B]) expect('missingSessionIds' in out[2]).toBe(false) expect('missingSessionIds' in out[3]).toBe(false) }) it('没有任何工单带会话 id 时一个目录都不扫', async () => { const readdir = vi.spyOn(fsp, 'readdir') const out = await annotateMissingSessionIds([{ ...baseIssue }], { claudeRoot: tempDir('claude-projects-'), codexRoot: tempDir('codex-sessions-'), }) expect(readdir).not.toHaveBeenCalled() expect('missingSessionIds' in out[0]).toBe(false) }) it('只带 claude 会话 id 时不扫 codex 目录', async () => { const claudeRoot = tempDir('claude-projects-') const codexRoot = tempDir('codex-sessions-') const readdir = vi.spyOn(fsp, 'readdir') const out = await annotateMissingSessionIds([{ ...baseIssue, sessionId: SID_1 }], { claudeRoot, codexRoot }) expect(readdir.mock.calls.some(([p]) => p === codexRoot)).toBe(false) expect(readdir.mock.calls.some(([p]) => p === claudeRoot)).toBe(true) expect(out[0].missingSessionIds).toEqual([SID_1]) }) })