Files
stack/packages/project/tests/human-file.test.mjs
T
jason.woltjeandClaude Opus 5.5 d4ea7e5a69 webui, bus, project: a Console editor for the project's spec and docs (row 148, #1642)
Row 148 round 3, Researcher's candidate (agents/researcher/work/queue-148/,
row148.patch 1b63164b, manifest 95ae4736, 27 paths, base 18aee8fa1).
Dewey approved round 3 (comment 28832) and Filbert approved it with no
findings (28835). Mos gave the decision 123 go on public diff 8360122b
(mos-0636): no vendored code, no innerHTML or other HTML sinks.

The Docs page #/docs lists each project's spec/north-star.md and the
Markdown files directly under docs/. Save writes the typed text as one
local commit authored by business.human, through a new human-only bus
verb project.file.save that carries the opened commit as base and
refuses stale-base if the file moved. The preview is a textarea plus a
renderer built with createElement and textContent only; raw HTML shows
as source text and images never load. CSP is unchanged.

Round 3 replaced the backreference HR regex, which overflowed the
regexp stack, with an isRule scan. Round 4 added a textContent fallback
when paintDoc throws, and the submit handler resets saving in a finally.
Mos's rule (ceiling 1 s at 1 MiB, growth x3 per doubling over 1 to
4 MiB): 130/130 and 138/138 cells pass, worst 513 ms at 1 MiB, worst
ratio x2.50, control x2.01; the 756-cell grid has 0 timeouts. Mutants
43/44, v21 equivalent.

Follow-ups: Vikunja P19 tasks 318 (Save stays off after a non-render
paint throw), 319 (dead NUL check in bus.js) and 320 (the 1 MiB + 64 KiB
buffer before the human proof, Gitea #1650).

Sage's landing gate ran on a4779d2a5 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), 22:19:24Z to 22:24:09Z,
network sage-r148g_default removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:24:39 -05:00

138 lines
8.8 KiB
JavaScript

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', 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', '[email protected]', '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 <b>html</b> 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|[email protected]|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));
});