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

308 lines
12 KiB
TypeScript

import { afterAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runAction } from '../src/act/apply.js'
import { appendRecord, readRecords } from '../src/act/journal.js'
import { DriftError, undoAction } from '../src/act/undo.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-undo-'))
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('undoAction', () => {
it('restores byte-identical content for edit, create, and move, then refuses a second undo', async () => {
const { actionsDir, files } = await makeRoot()
const editPath = join(files, 'edit.bin')
const createPath = join(files, 'created.bin')
const movePath = join(files, 'move.bin')
const moveDest = join(files, 'sub', 'move.bin')
const editOriginal = Buffer.from([0, 1, 2, 3, 255, 254])
const moveOriginal = Buffer.from([10, 20, 30, 40])
await writeFile(editPath, editOriginal)
await writeFile(movePath, moveOriginal)
const rec = await runAction({
kind: 'archive-skill',
description: 'undo test',
changes: [
{ op: 'edit', path: editPath, content: Buffer.from([9, 9, 9]) },
{ op: 'create', path: createPath, content: Buffer.from([7, 7]) },
{ op: 'move', path: movePath, movedTo: moveDest },
],
}, actionsDir)
// 8-char prefix is accepted
await undoAction({ id: rec.id.slice(0, 8) }, { actionsDir })
expect(Buffer.compare(await readFile(editPath), editOriginal)).toBe(0)
expect(existsSync(createPath)).toBe(false)
expect(existsSync(moveDest)).toBe(false)
expect(Buffer.compare(await readFile(movePath), moveOriginal)).toBe(0)
const records = await readRecords(actionsDir)
expect(records).toHaveLength(1)
expect(records[0]!.status).toBe('undone')
expect(records[0]!.undoneAt).toBeTruthy()
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toThrow(/already undone/)
})
it('undoes the most recent action with --last and leaves earlier actions applied', async () => {
const { actionsDir, files } = await makeRoot()
const first = join(files, 'first.txt')
const second = join(files, 'second.txt')
await writeFile(first, 'first-old')
await writeFile(second, 'second-old')
const recFirst = await runAction({
kind: 'claude-md-rule', description: 'first', changes: [{ op: 'edit', path: first, content: 'first-new' }],
}, actionsDir)
await runAction({
kind: 'claude-md-rule', description: 'second', changes: [{ op: 'edit', path: second, content: 'second-new' }],
}, actionsDir)
const undone = await undoAction({ last: true }, { actionsDir })
expect(undone.description).toBe('second')
expect(await readFile(second, 'utf-8')).toBe('second-old')
expect(await readFile(first, 'utf-8')).toBe('first-new')
const records = await readRecords(actionsDir)
expect(records.find(r => r.id === recFirst.id)!.status).toBe('applied')
})
it('refuses to undo a drifted file, but --force proceeds', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'drift.txt')
await writeFile(p, 'original')
const rec = await runAction({
kind: 'claude-md-rule', description: 'drift', changes: [{ op: 'edit', path: p, content: 'applied' }],
}, actionsDir)
await writeFile(p, 'user-modified')
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
expect((await readRecords(actionsDir))[0]!.status).toBe('applied')
expect(await readFile(p, 'utf-8')).toBe('user-modified')
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(p, 'utf-8')).toBe('original')
expect((await readRecords(actionsDir))[0]!.status).toBe('undone')
})
it('reverts changes newest-first so overlapping changes restore the original state', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'orig')
const rec = await runAction({
kind: 'shell-config',
description: 'move then edit',
changes: [
{ op: 'move', path: src, movedTo: dest },
{ op: 'edit', path: dest, content: 'edited' },
],
}, actionsDir)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('orig')
expect(existsSync(dest)).toBe(false)
})
it('refuses to undo a move when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'moved-bytes')
const rec = await runAction({
kind: 'archive-skill', description: 'occupied', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await writeFile(src, 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(src, 'utf-8')).toBe('moved-bytes')
expect(existsSync(dest)).toBe(false)
})
it('snapshots an existing move destination and restores both files on undo', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'src-bytes')
await writeFile(dest, 'dest-bytes')
const rec = await runAction({
kind: 'archive-agent', description: 'move onto dest', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(dest, 'utf-8')).toBe('src-bytes')
expect(await readFile(join(actionsDir, rec.changes[0]!.destBackup!), 'utf-8')).toBe('dest-bytes')
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(src, 'utf-8')).toBe('src-bytes')
expect(await readFile(dest, 'utf-8')).toBe('dest-bytes')
})
it('force-undo of a move whose moved file is gone restores from backup and flips status', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'a.txt')
const dest = join(files, 'b.txt')
await writeFile(src, 'precious')
const rec = await runAction({
kind: 'archive-command', description: 'gone', changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await rm(dest)
await expect(undoAction({ id: rec.id }, { actionsDir })).rejects.toBeInstanceOf(DriftError)
const undone = await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(undone.status).toBe('undone')
expect(await readFile(src, 'utf-8')).toBe('precious')
})
it('undoing a create that overwrote an existing file restores the prior bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'exists.txt')
await writeFile(p, 'prior')
const rec = await runAction({
kind: 'guard-install', description: 'create over existing', changes: [{ op: 'create', path: p, content: 'new' }],
}, actionsDir)
expect(rec.changes[0]!.backup).not.toBeNull()
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('prior')
})
it('undoes a plan that touches the same path twice back to the original bytes', async () => {
const { actionsDir, files } = await makeRoot()
const p = join(files, 'twice.txt')
await writeFile(p, 'v0')
const rec = await runAction({
kind: 'claude-md-rule',
description: 'same path twice',
changes: [
{ op: 'edit', path: p, content: 'v1' },
{ op: 'edit', path: p, content: 'v2' },
],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe(rec.changes[1]!.afterHash)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(p, 'utf-8')).toBe('v0')
})
it('rejects an ambiguous id prefix with the match count', async () => {
const { actionsDir } = await makeRoot()
await appendRecord(actionsDir, bareRecord('aaaaaaaa-1111-4111-8111-111111111111', 'one'))
await appendRecord(actionsDir, bareRecord('aaaaaaaa-2222-4222-8222-222222222222', 'two'))
await expect(undoAction({ id: 'aaaaaaaa' }, { actionsDir })).rejects.toThrow(/matches 2 actions/)
})
it('archives a directory and undo restores the tree with nested content byte-identical', async () => {
const { actionsDir, files } = await makeRoot()
const dir = join(files, 'skill')
const dest = join(files, '.archived', 'skill')
const nestedBytes = Buffer.from([1, 2, 3, 250, 0])
await mkdir(join(dir, 'nested'), { recursive: true })
await writeFile(join(dir, 'SKILL.md'), 'skill body')
await writeFile(join(dir, 'nested', 'data.bin'), nestedBytes)
const rec = await runAction({
kind: 'archive-skill',
description: 'archive dir',
changes: [{ op: 'move', path: dir, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.afterHash).toBe('')
expect(rec.changes[0]!.backup).not.toBeNull()
expect(existsSync(dir)).toBe(false)
expect(await readFile(join(dest, 'SKILL.md'), 'utf-8')).toBe('skill body')
await undoAction({ id: rec.id }, { actionsDir })
expect(existsSync(dest)).toBe(false)
expect(await readFile(join(dir, 'SKILL.md'), 'utf-8')).toBe('skill body')
expect(Buffer.compare(await readFile(join(dir, 'nested', 'data.bin')), nestedBytes)).toBe(0)
})
it('moves a directory onto an existing destination directory and undo restores both trees', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'agent')
const dest = join(files, 'agent-archived')
await mkdir(src, { recursive: true })
await mkdir(dest, { recursive: true })
await writeFile(join(src, 'agent.md'), 'src tree')
await writeFile(join(dest, 'old.md'), 'dest tree')
const rec = await runAction({
kind: 'archive-agent',
description: 'dir onto dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
expect(rec.changes[0]!.destBackup).not.toBeNull()
expect(await readFile(join(dest, 'agent.md'), 'utf-8')).toBe('src tree')
expect(existsSync(join(dest, 'old.md'))).toBe(false)
await undoAction({ id: rec.id }, { actionsDir })
expect(await readFile(join(src, 'agent.md'), 'utf-8')).toBe('src tree')
expect(await readFile(join(dest, 'old.md'), 'utf-8')).toBe('dest tree')
expect(existsSync(join(dest, 'agent.md'))).toBe(false)
})
it('refuses dir-move undo when the original path is occupied, then --force overwrites', async () => {
const { actionsDir, files } = await makeRoot()
const src = join(files, 'cmd')
const dest = join(files, 'cmd-archived')
await mkdir(src, { recursive: true })
await writeFile(join(src, 'cmd.md'), 'body')
const rec = await runAction({
kind: 'archive-command',
description: 'occupied dir',
changes: [{ op: 'move', path: src, movedTo: dest }],
}, actionsDir)
await mkdir(src, { recursive: true })
await writeFile(join(src, 'squatter.md'), 'squatter')
const err = await undoAction({ id: rec.id }, { actionsDir }).catch(e => e)
expect(err).toBeInstanceOf(DriftError)
expect((err as DriftError).drifted.some(d => d.includes(src))).toBe(true)
await undoAction({ id: rec.id }, { actionsDir, force: true })
expect(await readFile(join(src, 'cmd.md'), 'utf-8')).toBe('body')
expect(existsSync(join(src, 'squatter.md'))).toBe(false)
expect(existsSync(dest)).toBe(false)
})
})
function bareRecord(id: string, description: string): ActionRecord {
return {
id,
at: new Date().toISOString(),
kind: 'mcp-remove',
findingId: null,
description,
changes: [],
status: 'applied',
}
}