// tests/lib/markdown-write.test.mjs // Unit tests for lib/util/markdown-write.mjs (v4.2) import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { serializeFrontmatter, atomicWriteMarkdown, readAndUpdate, } from '../../lib/util/markdown-write.mjs'; import { parseFrontmatter, parseDocument } from '../../lib/util/frontmatter.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES_ROOT = resolve(__dirname, '..', 'fixtures'); test('serializeFrontmatter — empty object returns empty string', () => { assert.equal(serializeFrontmatter({}), ''); }); test('serializeFrontmatter — round-trip fidelity for scalars + arrays + list-of-dicts', () => { const obj = { name: 'voyage-test', revision: 0, enabled: true, notes: null, tags: ['alpha', 'beta', 'gamma'], findings: [ { id: 'a', severity: 'major' }, { id: 'b', severity: 'minor' }, ], }; const yaml = serializeFrontmatter(obj); const reparsed = parseFrontmatter(yaml).parsed; assert.deepEqual(reparsed, obj); }); test('serializeFrontmatter — block-style YAML for arrays (no flow style)', () => { const yaml = serializeFrontmatter({ tags: ['a', 'b'] }); assert.ok(!yaml.includes('[a, b]'), 'flow-style array forbidden'); assert.ok(yaml.includes('tags:\n - a\n - b'), 'block-style required'); }); test('serializeFrontmatter — strings with colons are quoted', () => { const yaml = serializeFrontmatter({ task: 'Re-architect: phase 2' }); assert.match(yaml, /task: ".*Re-architect.*phase 2.*"/); const reparsed = parseFrontmatter(yaml).parsed; assert.equal(reparsed.task, 'Re-architect: phase 2'); }); test('serializeFrontmatter — integer revision: 0 emitted unquoted', () => { const yaml = serializeFrontmatter({ revision: 0 }); assert.equal(yaml, 'revision: 0'); }); test('serializeFrontmatter — round-trips 6-key source_annotations dict (v4.2 schema)', () => { const obj = { revision: 1, source_annotations: [ { id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'step-3', intent: 'change', comment: 'Reorder before step 4', timestamp: '2026-05-09T10:00:00Z', }, { id: 'ANN-0002', target_artifact: 'plan.md', target_anchor: 'step-7', intent: 'fix', comment: 'typo in heading', timestamp: '2026-05-09T10:05:00Z', }, ], annotation_digest: 'abc123def4567890', }; const yaml = serializeFrontmatter(obj); const reparsed = parseFrontmatter(yaml).parsed; assert.deepEqual(reparsed, obj, '6-key list-of-dict must round-trip'); }); test('atomicWriteMarkdown — writes file with frontmatter + body', () => { const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Title\n\nBody.\n'); const text = readFileSync(path, 'utf-8'); assert.match(text, /^---\nplan_version: "?1\.7"?\nrevision: 0\n---\n# Title\n\nBody\.\n$/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('atomicWriteMarkdown — leaves no .tmp orphan after success', () => { const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { ok: true }, 'body'); assert.ok(existsSync(path)); assert.ok(!existsSync(path + '.tmp')); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('atomicWriteMarkdown — overwrites existing file atomically', () => { const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); try { const path = join(dir, 'plan.md'); writeFileSync(path, 'old content'); atomicWriteMarkdown(path, { new: true }, 'new body\n'); const text = readFileSync(path, 'utf-8'); assert.match(text, /new: true/); assert.match(text, /new body/); assert.ok(!text.includes('old content')); assert.ok(!existsSync(path + '.tmp')); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('atomicWriteMarkdown — preserves body bytes verbatim', () => { const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); try { const path = join(dir, 'plan.md'); const body = '# Title\n\n- item with `code`\n\n```yaml\nmanifest:\n expected_paths:\n - foo\n```\n\nTrailing text.'; atomicWriteMarkdown(path, { v: 1 }, body); const text = readFileSync(path, 'utf-8'); const split = text.split('---\n'); const recoveredBody = split.slice(2).join('---\n'); assert.equal(recoveredBody, body); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('readAndUpdate — round-trips frontmatter + body via mutator', () => { const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Original\nBody.\n'); const result = readAndUpdate(path, ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body, })); assert.equal(result.valid, true); const re = parseDocument(readFileSync(path, 'utf-8')); assert.equal(re.parsed.frontmatter.revision, 1); assert.match(re.parsed.body, /# Original/); } finally { rmSync(dir, { recursive: true, force: true }); } }); // Round-trip ALL existing fixture frontmatters (per risk-assessor C3). // Walk tests/fixtures/**, parse + serialize + parse, assert deep-equal. function walkMd(root, out = []) { if (!existsSync(root)) return out; for (const entry of readdirSync(root)) { const p = join(root, entry); const st = statSync(p); if (st.isDirectory()) walkMd(p, out); else if (entry.endsWith('.md')) out.push(p); } return out; } test('serializeFrontmatter — round-trips ALL existing fixture frontmatters', () => { const fixtures = walkMd(FIXTURES_ROOT); let checked = 0; for (const path of fixtures) { const text = readFileSync(path, 'utf-8'); const parsed = parseDocument(text); if (!parsed.valid) continue; // some fixtures are intentionally malformed const fm = parsed.parsed.frontmatter; if (!fm || Object.keys(fm).length === 0) continue; const yaml = serializeFrontmatter(fm); const reparsed = parseFrontmatter(yaml); if (!reparsed.valid) continue; // skip malformed-on-purpose fixtures assert.deepEqual(reparsed.parsed, fm, `round-trip failed for fixture: ${path}`); checked++; } assert.ok(checked > 0, 'expected to round-trip at least one fixture'); });