import { afterAll, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { runAction } from '../src/act/apply.js' import { journalPath, readRecords } from '../src/act/journal.js' import { sha256 } from '../src/act/backup.js' import type { ActionRecord } from '../src/act/types.js' const roots: string[] = [] async function makeRoot(): Promise<{ actionsDir: string; files: string }> { const root = await mkdtemp(join(tmpdir(), 'codeburn-act-journal-')) roots.push(root) const files = join(root, 'files') await mkdir(files, { recursive: true }) return { actionsDir: join(root, 'actions'), files } } afterAll(async () => { for (const root of roots) await rm(root, { recursive: true, force: true }) }) describe('runAction journaling', () => { it('journals backups and afterHash for edit, create, and move ops', async () => { const { actionsDir, files } = await makeRoot() const editPath = join(files, 'edit.txt') const createPath = join(files, 'new.txt') const movePath = join(files, 'move.txt') const moveDest = join(files, 'archive', 'move.txt') await writeFile(editPath, 'old-edit') await writeFile(movePath, 'move-body') const rec = await runAction({ kind: 'claude-md-rule', description: 'test action', changes: [ { op: 'edit', path: editPath, content: 'new-edit' }, { op: 'create', path: createPath, content: 'created' }, { op: 'move', path: movePath, movedTo: moveDest }, ], }, actionsDir) expect(rec.status).toBe('applied') expect(rec.findingId).toBeNull() expect(rec.changes).toHaveLength(3) const [edit, create, move] = rec.changes expect(edit!.backup).not.toBeNull() expect(await readFile(join(actionsDir, edit!.backup!), 'utf-8')).toBe('old-edit') expect(edit!.afterHash).toBe(sha256(Buffer.from('new-edit'))) expect(await readFile(editPath, 'utf-8')).toBe('new-edit') expect(create!.backup).toBeNull() expect(create!.afterHash).toBe(sha256(Buffer.from('created'))) expect(await readFile(createPath, 'utf-8')).toBe('created') expect(move!.backup).not.toBeNull() expect(await readFile(join(actionsDir, move!.backup!), 'utf-8')).toBe('move-body') expect(move!.movedTo).toBe(moveDest) expect(move!.afterHash).toBe(sha256(Buffer.from('move-body'))) expect(await readFile(moveDest, 'utf-8')).toBe('move-body') expect(existsSync(movePath)).toBe(false) const records = await readRecords(actionsDir) expect(records).toHaveLength(1) expect(records[0]!.id).toBe(rec.id) }) it('rolls back completed mutations and writes no record when a mutation fails', async () => { const { actionsDir, files } = await makeRoot() const editPath = join(files, 'edit.txt') await writeFile(editPath, 'original') const missingSrc = join(files, 'does-not-exist.txt') await expect(runAction({ kind: 'shell-config', description: 'failing action', changes: [ { op: 'edit', path: editPath, content: 'changed' }, { op: 'move', path: missingSrc, movedTo: join(files, 'dest.txt') }, ], }, actionsDir)).rejects.toThrow() expect(await readFile(editPath, 'utf-8')).toBe('original') expect(existsSync(journalPath(actionsDir))).toBe(false) }) it('skips corrupt journal lines and still loads valid records', async () => { const { actionsDir } = await makeRoot() await mkdir(actionsDir, { recursive: true }) const recA = sampleRecord('11111111-1111-1111-1111-111111111111', 'first') const recB = sampleRecord('22222222-2222-2222-2222-222222222222', 'second') await writeFile( journalPath(actionsDir), JSON.stringify(recA) + '\n' + '{ this is not json\n' + JSON.stringify(recB) + '\n', ) const records = await readRecords(actionsDir) expect(records.map(r => r.id)).toEqual([recA.id, recB.id]) expect(records.map(r => r.description)).toEqual(['first', 'second']) }) it('round-trips a record through JSON (act list --json shape)', async () => { const { actionsDir, files } = await makeRoot() const p = join(files, 'f.txt') await writeFile(p, 'before') const rec = await runAction({ kind: 'model-default', description: 'json shape', changes: [{ op: 'edit', path: p, content: 'after' }], }, actionsDir) const records = await readRecords(actionsDir) const roundTripped = JSON.parse(JSON.stringify(records)) as ActionRecord[] expect(roundTripped).toEqual(records) const only = roundTripped[0]! expect(only).toMatchObject({ id: rec.id, kind: 'model-default', description: 'json shape', status: 'applied' }) expect(typeof only.at).toBe('string') expect(only.findingId).toBeNull() expect(only.changes[0]).toMatchObject({ path: p, op: 'edit' }) expect(only.changes[0]!.backup).not.toBeNull() expect(typeof only.changes[0]!.afterHash).toBe('string') }) }) describe('action lock', () => { it('refuses to run while a fresh foreign lock is held', async () => { const { actionsDir, files } = await makeRoot() await mkdir(actionsDir, { recursive: true }) await writeFile(join(actionsDir, '.lock'), '') const target = join(files, 'blocked.txt') await expect(runAction({ kind: 'shell-config', description: 'blocked by lock', changes: [{ op: 'create', path: target, content: 'x' }], }, actionsDir)).rejects.toThrow(/in progress/) expect(existsSync(target)).toBe(false) }) it('takes over a lock whose mtime is older than 60s', async () => { const { actionsDir, files } = await makeRoot() await mkdir(actionsDir, { recursive: true }) const lock = join(actionsDir, '.lock') await writeFile(lock, JSON.stringify({ pid: 1, at: 0 })) const past = new Date(Date.now() - 61_000) await utimes(lock, past, past) const target = join(files, 'takeover.txt') const rec = await runAction({ kind: 'shell-config', description: 'stale takeover', changes: [{ op: 'create', path: target, content: 'x' }], }, actionsDir) expect(rec.status).toBe('applied') expect(await readFile(target, 'utf-8')).toBe('x') expect(existsSync(lock)).toBe(false) }) }) describe('act list --json (CLI)', () => { it('prints full records as JSON, newest first', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-act-cli-')) roots.push(home) const actionsDir = join(home, '.config', 'codeburn', 'actions') const files = join(home, 'work') await mkdir(files, { recursive: true }) const p1 = join(files, 'a.txt') const p2 = join(files, 'b.txt') await writeFile(p1, 'a') await writeFile(p2, 'b') const recOlder = await runAction({ kind: 'mcp-remove', description: 'older', changes: [{ op: 'edit', path: p1, content: 'a2' }], }, actionsDir) const recNewer = await runAction({ kind: 'mcp-remove', description: 'newer', changes: [{ op: 'edit', path: p2, content: 'b2' }], }, actionsDir) // Anchor the spawn to this checkout so running vitest from another cwd // cannot execute a different checkout's cli.ts. const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') const res = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', 'act', 'list', '--json'], { cwd: repoRoot, env: { ...process.env, HOME: home, USERPROFILE: home, HOMEPATH: home, HOMEDRIVE: '' }, encoding: 'utf-8', }) expect(res.status).toBe(0) const parsed = JSON.parse(res.stdout) as ActionRecord[] expect(parsed.map(r => r.id)).toEqual([recNewer.id, recOlder.id]) expect(parsed[0]).toMatchObject({ kind: 'mcp-remove', description: 'newer', status: 'applied', findingId: null }) expect(parsed[0]!.changes[0]).toMatchObject({ path: p2, op: 'edit' }) expect(typeof parsed[0]!.changes[0]!.afterHash).toBe('string') expect(parsed[0]!.changes[0]!.backup).not.toBeNull() }, 20_000) }) function sampleRecord(id: string, description: string): ActionRecord { return { id, at: new Date().toISOString(), kind: 'mcp-remove', findingId: null, description, changes: [], status: 'applied', } }