import test from 'node:test'; import assert from 'node:assert/strict'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import * as api from '../src/index.mjs'; // Row 148 (#1642): the human's file list and save. The agent writer's own tests stay in project.test.mjs. const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim(); async function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'project-human-file-')); t.after(() => rmSync(root, { recursive: true, force: true })); const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') }, } }, project: 'stack', dataRoot: root }; const { repo } = await api.initProject(context); const commitAs = (path, bytes, author) => { writeFileSync(join(repo, path), bytes); git(repo, 'add', '--', path); git(repo, '-c', `user.name=${author}`, '-c', 'user.email=fixture@mosaic.invalid', 'commit', '-m', 'Fixture ' + path); return git(repo, 'rev-parse', 'HEAD'); }; const opened = async path => (await api.readProjectFile({ ...context, path })).commit; const save = async (path, content, base) => api.writeHumanFile({ ...context, path, content, base: base ?? await opened(path) }); return { context, repo, root, commitAs, opened, save }; } const state = repo => ({ head: git(repo, 'rev-parse', 'HEAD'), status: git(repo, 'status', '--porcelain') }); test('the file list names the spec and Markdown directly under docs, as committed at HEAD', async t => { const { context, repo, commitAs } = await fixture(t); commitAs('docs/b-notes.md', '# B\n', 'jason'); mkdirSync(join(repo, 'docs/sub')); commitAs('docs/sub/deep.md', '# Deep\n', 'jason'); commitAs('docs/plain.txt', 'not markdown\n', 'jason'); writeFileSync(join(repo, 'docs/uncommitted.md'), '# Not committed\n'); const listed = await api.listProjectFiles(context); assert.deepEqual(listed.files, [{ path: 'docs/README.md' }, { path: 'docs/b-notes.md' }, { path: 'spec/north-star.md' }]); assert.equal(listed.head, git(repo, 'rev-parse', 'HEAD')); assert.deepEqual(Object.keys(listed).sort(), ['files', 'head']); await assert.rejects(api.listProjectFiles({ ...context, project: 'other' }), { code: 'project-not-declared' }); await assert.rejects(api.listProjectFiles({ ...context, path: 'docs/b-notes.md' }), { code: 'project-read-path' }); }); test('a save writes the typed text as one commit by the business human and touches nothing else', async t => { const { context, repo, opened, save } = await fixture(t); const text = '# North star\n\nLine one\r\nLine two with html and é.\n\n\n'; const before = state(repo).head, base = await opened('spec/north-star.md'); const saved = await save('spec/north-star.md', text, base); assert.equal(saved.path, 'spec/north-star.md'); assert.equal(git(repo, 'rev-parse', 'HEAD'), saved.commit); assert.equal(git(repo, 'rev-list', '--count', `${before}..HEAD`), '1'); assert.equal(git(repo, 'show', '-s', '--format=%an|%ae|%s', saved.commit), 'jason|jason@mosaic.invalid|Edit spec/north-star.md'); assert.deepEqual(git(repo, 'show', '--format=', '--name-only', saved.commit).split('\n'), ['spec/north-star.md']); assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), text); assert.equal(git(repo, 'status', '--porcelain'), ''); const read = await api.readProjectFile({ ...context, path: 'spec/north-star.md' }); assert.equal(read.content, text); assert.equal(read.commit, saved.commit); assert.equal((await api.readSpec(context)).confirmed, true); const doc = await save('docs/README.md', '# Docs, edited\n'); assert.equal(git(repo, 'show', '-s', '--format=%an|%s', doc.commit), 'jason|Edit docs/README.md'); assert.equal(git(repo, 'rev-list', '--count', `${saved.commit}..HEAD`), '1'); }); test('a stale base refuses and writes nothing, whoever made the newer commit', async t => { const { repo, commitAs, opened, save } = await fixture(t); const base = await opened('spec/north-star.md'); commitAs('spec/north-star.md', '# Agent edit\n', 'coder.builder'); const after = state(repo); await assert.rejects(save('spec/north-star.md', '# Mine\n', base), { code: 'stale-base' }); assert.deepEqual(state(repo), after); assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Agent edit\n'); // A commit that only touched another file is not newer for this file. const docBase = await opened('docs/README.md'); commitAs('docs/other.md', '# Other\n', 'jason'); assert.equal((await save('docs/README.md', '# Still mine\n', docBase)).path, 'docs/README.md'); // The head commit is not the file's last commit: a base taken from HEAD refuses. const head = git(repo, 'rev-parse', 'HEAD'); await assert.rejects(save('spec/north-star.md', '# Wrong base\n', head), { code: 'stale-base' }); // The current last commit is accepted. assert.equal((await save('spec/north-star.md', '# Reloaded\n')).path, 'spec/north-star.md'); }); test('only the spec and existing docs files are writable', async t => { const { context, repo, opened, commitAs } = await fixture(t); const base = await opened('spec/north-star.md'); mkdirSync(join(repo, 'docs/sub')); commitAs('docs/sub/deep.md', '# Deep\n', 'jason'); const after = state(repo); for (const path of ['project.json', 'requirements/.gitkeep', 'decisions/x.json', 'templates/document.md', 'spec/other.md', 'docs/plain.txt', 'docs/sub/deep.md', '../outside.md', 'docs/../project.json', '/etc/passwd', 'docs/.hidden.md', 'docs/a\\b.md', '', null, 7]) await assert.rejects(api.writeHumanFile({ ...context, path, content: '# X\n', base }), { code: 'project-record-path' }, String(path)); await assert.rejects(api.writeHumanFile({ ...context, path: 'docs/new.md', content: '# New\n', base }), { code: 'project-read-missing' }); assert.deepEqual(state(repo), after); }); test('content and base are checked before anything is written', async t => { const { context, repo, opened } = await fixture(t); const base = await opened('spec/north-star.md'), after = state(repo); const write = (content, b) => api.writeHumanFile({ ...context, path: 'spec/north-star.md', content, base: b }); for (const content of ['', 'a\0b', 'lone \ud800 surrogate', 'x'.repeat(1024 * 1024 + 1), 'é'.repeat(600 * 1024), null, 5, { a: 1 }]) await assert.rejects(write(content, base), { code: 'project-record-invalid' }); for (const b of [undefined, '', 'HEAD', base.slice(1), base.toUpperCase(), `${base}0`, 5]) await assert.rejects(write('# Fine\n', b), { code: 'project-record-invalid' }); assert.deepEqual(state(repo), after); const limit = await write('x'.repeat(1024 * 1024), base); assert.match(limit.commit, /^[0-9a-f]{40}$/); }); test('an unchanged save, a dirty tree and a busy writer refuse without a commit', async t => { const { repo, opened, save } = await fixture(t); const text = readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), base = await opened('spec/north-star.md'); const after = state(repo); await assert.rejects(save('spec/north-star.md', text, base), { code: 'project-record-unchanged' }); writeFileSync(join(repo, 'docs/README.md'), '# Pending human edit\n'); await assert.rejects(save('spec/north-star.md', '# New\n', base), { code: 'project-dirty' }); git(repo, 'checkout', '--', 'docs/README.md'); writeFileSync(join(repo, '.git/mosaic-record.lock'), 'another writer'); await assert.rejects(save('spec/north-star.md', '# New\n', base), { code: 'project-busy' }); rmSync(join(repo, '.git/mosaic-record.lock')); assert.deepEqual(state(repo), after); }); test('a failed commit restores the exact prior bytes, including a byte-order mark, and leaves history alone', async t => { const { context, repo, root, commitAs, opened } = await fixture(t); const original = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('# With BOM\r\n')]); commitAs('docs/bom.md', original, 'jason'); const base = await opened('docs/bom.md'), after = state(repo); const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); const bin = join(root, 'bin'); mkdirSync(bin); writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do [ "$arg" = commit ] && exit 71; done\nexec '${realGit}' "$@"\n`); chmodSync(join(bin, 'git'), 0o755); const saved = process.env.PATH; try { process.env.PATH = bin + ':' + saved; await assert.rejects(api.writeHumanFile({ ...context, path: 'docs/bom.md', content: '# Lost\n', base }), { code: 'project-git' }); } finally { process.env.PATH = saved; } assert.deepEqual(state(repo), after); assert.ok(readFileSync(join(repo, 'docs/bom.md')).equals(original)); });