Files
2026-07-13 12:26:02 +08:00

216 lines
8.3 KiB
TypeScript

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',
}
}