- lib/util/markdown-write.mjs: serializeFrontmatter (subset matches frontmatter.mjs parser), atomicWriteMarkdown (single tmp+rename, body bytes verbatim), readAndUpdate (read+mutate+write). - lib/util/revision-guard.mjs: revisionGuard(path, mutator, validator) — backup -> mutate -> validate -> restore-on-fail. Extracted from /trekrevise prompt so rollback can be unit-tested. - 12 tests for markdown-write, including 6-key source_annotations round-trip + walk-all-fixtures regression. - 6 tests for revision-guard: applied/rolled-back/mutator-failed/sha256 stability/pre-existing-bak abort. - Forward-compat policy comments in 3 validators (brief/plan/review) — non-functional pin against future strict-key refactors. Pass: 508/510 (was 490; +18 net from v4.2 Step 1, 2 skipped Docker)
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
// tests/lib/revision-guard.test.mjs
|
|
// Unit tests for lib/util/revision-guard.mjs (v4.2)
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { revisionGuard } from '../../lib/util/revision-guard.mjs';
|
|
import { atomicWriteMarkdown } from '../../lib/util/markdown-write.mjs';
|
|
|
|
function sha256(path) {
|
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
}
|
|
|
|
const ALWAYS_VALID = () => ({ valid: true, errors: [], warnings: [] });
|
|
const ALWAYS_INVALID = () => ({ valid: false, errors: [{ code: 'TEST', message: 'forced fail' }], warnings: [] });
|
|
|
|
test('revisionGuard — validator-PASS commits revision and deletes bak', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
|
|
const r = revisionGuard(
|
|
path,
|
|
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
|
|
ALWAYS_VALID,
|
|
);
|
|
assert.equal(r.outcome, 'applied');
|
|
assert.ok(!existsSync(path + '.local.bak'), 'bak should be deleted on success');
|
|
const text = readFileSync(path, 'utf-8');
|
|
assert.match(text, /revision: 1/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('revisionGuard — validator-FAIL rolls back to byte-identical pre-revision', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
|
|
const before = sha256(path);
|
|
const r = revisionGuard(
|
|
path,
|
|
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
|
|
ALWAYS_INVALID,
|
|
);
|
|
assert.equal(r.outcome, 'rolled-back');
|
|
const after = sha256(path);
|
|
assert.equal(after, before, 'rollback must restore byte-identical content');
|
|
assert.ok(!existsSync(path + '.local.bak'), 'bak should be cleaned up after rollback');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('revisionGuard — pre-existing .local.bak aborts with operator guidance', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
|
|
const bak = path + '.local.bak';
|
|
writeFileSync(bak, 'stale backup from prior run');
|
|
const r = revisionGuard(path, ({ frontmatter, body }) => ({ frontmatter, body }), ALWAYS_VALID);
|
|
assert.equal(r.outcome, 'mutator-failed');
|
|
assert.match(r.error, /pre-existing backup/);
|
|
// Original file untouched, stale bak preserved for operator inspection
|
|
assert.equal(readFileSync(bak, 'utf-8'), 'stale backup from prior run');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('revisionGuard — mutator that throws restores original via bak', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
|
|
const before = sha256(path);
|
|
const r = revisionGuard(
|
|
path,
|
|
() => { throw new Error('boom'); },
|
|
ALWAYS_VALID,
|
|
);
|
|
assert.equal(r.outcome, 'mutator-failed');
|
|
assert.match(r.error, /boom/);
|
|
const after = sha256(path);
|
|
assert.equal(after, before, 'mutator-throw must preserve original');
|
|
assert.ok(!existsSync(path + '.local.bak'), 'bak cleaned up after mutator-throw');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('revisionGuard — mutator returns invalid object rejected before validator runs', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n');
|
|
const before = sha256(path);
|
|
let validatorCalled = false;
|
|
const r = revisionGuard(
|
|
path,
|
|
() => null, // not an object
|
|
() => { validatorCalled = true; return { valid: true, errors: [], warnings: [] }; },
|
|
);
|
|
assert.equal(r.outcome, 'mutator-failed');
|
|
assert.equal(validatorCalled, false, 'validator must not run if mutator returned invalid result');
|
|
const after = sha256(path);
|
|
assert.equal(after, before, 'invalid mutator must preserve original');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('revisionGuard — sha256 fields populated and stable', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'rg-test-'));
|
|
try {
|
|
const path = join(dir, 'plan.md');
|
|
atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n');
|
|
const before = sha256(path);
|
|
const r = revisionGuard(
|
|
path,
|
|
({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }),
|
|
ALWAYS_VALID,
|
|
);
|
|
assert.equal(r.sha256_before, before);
|
|
assert.equal(typeof r.sha256_after, 'string');
|
|
assert.notEqual(r.sha256_after, r.sha256_before, 'sha256 must change after applied revision');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|