import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { packHandoff, unpackHandoff } from './handoffArchive' import { installHandoffBundle, stageHandoffBundle } from './handoffBundle' import { HANDOFF_MANIFEST_FILE, parseHandoffManifest } from './handoffManifest' const tempDirs: string[] = [] afterEach(() => { while (tempDirs.length > 0) { const d = tempDirs.pop() if (d) rmSync(d, { recursive: true, force: true }) } }) function tempDir(prefix: string): string { const d = mkdtempSync(path.join(os.tmpdir(), prefix)) tempDirs.push(d) return d } const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001' const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002' const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' function senderRoots(): { projectsRoot: string, codexRoot: string } { const projectsRoot = tempDir('sender-projects-') const codexRoot = tempDir('sender-codex-') const proj = path.join(projectsRoot, '-Users-chw-proj') const wt = path.join(projectsRoot, '-Users-chw-wt-x') mkdirSync(proj, { recursive: true }) mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true }) writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n') writeFileSync(path.join(wt, `${IMPL}.jsonl`), 'impl\n') writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n') const day = path.join(codexRoot, '2026', '08', '26') mkdirSync(day, { recursive: true }) writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n') return { projectsRoot, codexRoot } } describe('stageHandoffBundle', () => { it('收集存在的会话,缺失的不进列表', async () => { const roots = senderRoots() const staging = tempDir('staging-') const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', branch: 'feature/x', sessions: { sessionId: BRAIN, implementSessionId: IMPL, testSessionId: 'missing-0000-4000-8000-000000000000', reviewSessionId: REVIEW }, profiles: { profilePath: '/Users/chw/p.json' }, }, staging, roots, new Date('2026-08-26T00:00:00Z')) expect(manifest.claude).toEqual([{ id: BRAIN, kind: 'brainstorm' }, { id: IMPL, kind: 'implement' }]) expect(manifest.codex).toEqual([{ id: REVIEW, relPath: path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`) }]) expect(manifest.sessions.testSessionId).toBe('missing-0000-4000-8000-000000000000') expect(manifest.createdAt).toBe('2026-08-26T00:00:00.000Z') expect(existsSync(path.join(staging, 'claude', `${IMPL}.jsonl`))).toBe(true) expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) expect(existsSync(path.join(staging, 'codex', manifest.codex[0].relPath))).toBe(true) expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest) }) }) describe('pack → unpack → install', () => { it('接管方装到对应 project 目录与 codex 目录', async () => { const roots = senderRoots() const staging = tempDir('staging-') const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', branch: 'feature/x', sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW }, profiles: {}, }, staging, roots) const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz') await packHandoff(staging, archive) expect(existsSync(archive)).toBe(true) const extracted = tempDir('extracted-') await unpackHandoff(archive, extracted) const parsed = parseHandoffManifest(readFileSync(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), 7) expect(parsed).toEqual(manifest) const projectsRoot = tempDir('receiver-projects-') const codexRoot = tempDir('receiver-codex-') const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj') const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x') // 本机残留的同 sid 副本必须被清掉 mkdirSync(path.join(projectsRoot, '-stale'), { recursive: true }) writeFileSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`), 'stale\n') await installHandoffBundle(extracted, parsed, { projectsRoot, codexRoot, workspaceProjectsDir, worktreeProjectsDir }) expect(readFileSync(path.join(workspaceProjectsDir, `${BRAIN}.jsonl`), 'utf8')).toBe('brain\n') expect(readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')).toBe('impl\n') expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true) expect(existsSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false) expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n') }) it('无 worktreeProjectsDir 时实施会话也装到工作区目录', async () => { const roots = senderRoots() const staging = tempDir('staging-') const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', sessions: { implementSessionId: IMPL }, profiles: {} }, staging, roots) const projectsRoot = tempDir('receiver-projects-') const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj') await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir }) expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true) }) it('带 worktreePath + pathRewrite:装机文件里的发送方路径全部改写成接收方路径', async () => { const WORKTREE_FROM = '/Users/chw/wt/x' const WORKTREE_TO = '/home/me/wt/x' const roots = senderRoots() // sender 会话文件里嵌入发送方的 worktree 路径(plain / file:// 两种形式) const wt = path.join(roots.projectsRoot, '-Users-chw-wt-x') writeFileSync(path.join(wt, `${IMPL}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), `{"cwd":"${WORKTREE_FROM}"}\n`) const day = path.join(roots.codexRoot, '2026', '08', '26') writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`) const staging = tempDir('staging-') const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', worktreePath: WORKTREE_FROM, sessions: { implementSessionId: IMPL, reviewSessionId: REVIEW }, profiles: {}, }, staging, roots) expect(manifest.worktreePath).toBe(WORKTREE_FROM) const projectsRoot = tempDir('receiver-projects-') const codexRoot = tempDir('receiver-codex-') const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x') await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot, workspaceProjectsDir: path.join(projectsRoot, '-home-me-proj'), worktreeProjectsDir, pathRewrite: { from: WORKTREE_FROM, to: WORKTREE_TO }, }) const installedJsonl = readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8') const installedSubagent = readFileSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'), 'utf8') const installedRollout = readFileSync(path.join(codexRoot, manifest.codex[0].relPath), 'utf8') for (const content of [installedJsonl, installedSubagent, installedRollout]) { expect(content).toContain(WORKTREE_TO) expect(content).not.toContain(WORKTREE_FROM) } }) })