160 lines
6.5 KiB
TypeScript
160 lines
6.5 KiB
TypeScript
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-*-<uuid>.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 目录下的 <sid>.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('不下钻 <sid>/ 子目录,忽略非 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])
|
|
})
|
|
})
|