Files
stack/packages/project/tests/onboarding.test.mjs

173 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { 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';
import {
approveNorthStarDraft, INTERVIEW_TEMPLATE_URL, interviewMessage, isTemplateNorthStar, loadInterviewTemplate,
MESSAGE_MAX, northStarTemplateText, OnboardingError, SPEC_PATH, validateInterviewTemplate,
} from '../src/onboarding.mjs';
// F2 (REQ-SETUP-2): the interview template and the north-star proposal-plus-approval path.
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();
const shipped = () => JSON.parse(readFileSync(INTERVIEW_TEMPLATE_URL, 'utf8'));
const A = 'a'.repeat(40), B = 'b'.repeat(40), C = 'c'.repeat(40);
const DRAFTS = { northStar: 'docs/north-star-draft.md', prd: 'docs/prd-1.0.md', requirementPrefix: 'REQ-' };
test('the shipped template is valid, asks the four questions in order and renders a first message', () => {
const template = loadInterviewTemplate();
assert.deepEqual(validateInterviewTemplate(template), []);
assert.deepEqual(template.questions.map(q => q.key), ['outcome', 'audience', 'limits', 'success']);
assert.deepEqual(template.drafts, DRAFTS);
const message = interviewMessage(template, { project: 'alpha' });
assert.match(message, /^Let's start project alpha with an onboarding interview\./);
for (const [i, q] of template.questions.entries()) assert.ok(message.includes(`${i + 1}. ${q.text}`));
assert.ok(message.includes('write_record at docs/north-star-draft.md'));
assert.ok(message.includes('`mosaic project north-star alpha`'));
assert.ok(message.includes('requirements/REQ-<n>.json'));
assert.ok(message.includes("can't write spec/north-star.md"));
assert.doesNotMatch(message, /\{[a-zA-Z]+\}/);
assert.ok(message.length <= MESSAGE_MAX);
assert.throws(() => interviewMessage(template, { project: '../x' }), { code: 'invalid-request' });
});
test('a replacement template is checked key by key and every problem is named', () => {
const cases = [
[t => { t.extra = 1; }, 'template keys must be exactly'],
[t => { t.templateVersion = 2; }, 'templateVersion must be 1'],
[t => { t.id = '../x'; }, 'id must be a record id'],
[t => { t.threadName = 'two\nlines'; }, 'threadName must be one line'],
[t => { t.threadName = 'x'.repeat(121); }, 'threadName must be one line'],
[t => { t.drafts.northStar = 'spec/north-star.md'; }, 'drafts.northStar must be a Markdown doc directly under docs/'],
[t => { t.drafts.northStar = 'docs/sub/x.md'; }, 'drafts.northStar must be a Markdown doc directly under docs/'],
[t => { t.drafts.prd = 'docs/prd.md'; }, 'drafts.prd must be docs/prd-<version>.md'],
[t => { t.drafts.northStar = t.drafts.prd; }, 'drafts.northStar and drafts.prd must differ'],
[t => { t.drafts.requirementPrefix = '-x'; }, 'drafts.requirementPrefix must start a record id'],
[t => { delete t.drafts.prd; }, 'drafts keys must be exactly'],
[t => { t.questions = []; }, 'questions must list 1-12 questions'],
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats an earlier question key'],
[t => { t.questions[0].key = 'Bad Key'; }, 'questions[0].key must be lower-case'],
[t => { t.questions[0].text = ''; }, 'questions[0].text must be one line'],
[t => { t.questions[0].extra = 1; }, 'questions[0] keys must be exactly key, text'],
[t => { t.prompt = ['line\nbreak']; }, 'prompt must be 1-200 lines'],
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder; expected one of'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{questions}', 'the questions')); }, 'prompt must use {questions}'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{northStarDraft}', 'a doc')); }, 'prompt must use {northStarDraft}'],
];
for (const [edit, problem] of cases) {
const t = shipped(); edit(t);
const problems = validateInterviewTemplate(t);
assert.ok(problems.some(p => p.startsWith(problem)), `${problem}: got ${JSON.stringify(problems)}`);
assert.throws(() => interviewMessage(t, { project: 'alpha' }), { code: 'onboarding-template-invalid' });
}
assert.deepEqual(validateInterviewTemplate(null), ['template must be a JSON object']);
const long = shipped(); long.prompt = Array(20).fill(`${'x'.repeat(1960)} {questions} {northStarDraft}`);
assert.deepEqual(validateInterviewTemplate(long), []);
assert.throws(() => interviewMessage(long, { project: 'alpha' }), { code: 'onboarding-template-invalid', message: /longer than 32768/ });
});
test('loadInterviewTemplate reads the file a replacement names and refuses one that is unreadable or invalid', t => {
const root = mkdtempSync(join(tmpdir(), 'onboarding-template-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const good = shipped(); good.threadName = 'Kickoff'; good.questions = good.questions.slice(0, 2);
writeFileSync(join(root, 'good.json'), JSON.stringify(good));
assert.equal(loadInterviewTemplate(join(root, 'good.json')).threadName, 'Kickoff');
writeFileSync(join(root, 'broken.json'), '{');
assert.throws(() => loadInterviewTemplate(join(root, 'broken.json')), { code: 'onboarding-template-invalid', message: /unreadable/ });
writeFileSync(join(root, 'bad.json'), JSON.stringify({ ...good, templateVersion: 9 }));
assert.throws(() => loadInterviewTemplate(join(root, 'bad.json')), { code: 'onboarding-template-invalid', message: /templateVersion must be 1/ });
assert.throws(() => loadInterviewTemplate(join(root, 'missing.json')), { code: 'onboarding-template-invalid' });
});
test('the template check ignores line endings and outer blank lines only', () => {
const text = northStarTemplateText();
assert.equal(isTemplateNorthStar(text), true);
assert.equal(isTemplateNorthStar(`\n${text.replace(/\n/g, '\r\n')}\n\n`), true);
assert.equal(isTemplateNorthStar(`${text}\nOne more line.\n`), false);
assert.equal(isTemplateNorthStar(undefined), false);
});
test('approval refuses before any read on bad input and never saves a draft it did not read as reviewed', async () => {
const calls = [];
const read = async args => { calls.push(['read', args]); return { content: '# Draft\n', commit: A }; };
const save = async args => { calls.push(['save', args]); return { path: SPEC_PATH, commit: C }; };
const ok = { read, save, project: 'stack', draft: A, base: B, drafts: DRAFTS };
for (const [over, code] of [
[{ read: undefined }, 'not-configured'], [{ save: null }, 'not-configured'],
[{ project: '../x' }, 'invalid-request'], [{ draft: 'abc' }, 'invalid-request'], [{ base: A.toUpperCase() }, 'invalid-request'],
[{ drafts: { northStar: 'spec/north-star.md' } }, 'onboarding-template-invalid'], [{ drafts: undefined }, 'onboarding-template-invalid'],
]) await assert.rejects(approveNorthStarDraft({ ...ok, ...over }), e => e instanceof OnboardingError && e.code === code && e.stage === 'check');
assert.deepEqual(calls, []);
// The draft moved on since the human read it: refused at the read, nothing saved.
await assert.rejects(approveNorthStarDraft({ ...ok, draft: B }), { code: 'draft-stale', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => ({ content: ' \n', commit: A }) }), { code: 'draft-empty', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => ({ content: '# x' }) }), { code: 'invalid-response', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => { throw Object.assign(Error('gone'), { code: 'project-read-missing' }); } }),
e => e.code === 'project-read-missing' && e.stage === 'read' && e.cause.message === 'gone');
assert.deepEqual(calls.filter(([kind]) => kind === 'save'), []);
// A save refusal keeps its code; a reply without the new commit is an unknown outcome.
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => { throw Object.assign(Error('stale'), { code: 'stale-base' }); } }), { code: 'stale-base', stage: 'save' });
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => ({ path: SPEC_PATH }) }), { code: 'outcome-unknown', stage: 'save' });
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => { throw new Error('socket closed'); } }), { code: 'outcome-unknown', stage: 'save' });
calls.length = 0;
assert.deepEqual(await approveNorthStarDraft(ok), { path: SPEC_PATH, commit: C, draft: { path: DRAFTS.northStar, commit: A } });
assert.deepEqual(calls, [
['read', { project: 'stack', path: DRAFTS.northStar }],
['save', { project: 'stack', path: SPEC_PATH, base: B, content: '# Draft\n' }],
]);
});
async function repoFixture(t) {
const root = mkdtempSync(join(tmpdir(), 'onboarding-repo-'));
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 assistant = { definition: 'assistant', instance: 'assistant' };
const human = {
read: args => api.readProjectFile({ ...context, ...args }),
save: args => api.writeHumanFile({ ...context, ...args }),
};
return { context, repo, assistant, human };
}
test('the assistant proposes a draft doc, cannot write the spec, and only the human approval writes it', async t => {
const { context, repo, assistant, human } = await repoFixture(t);
const before = await api.readSpec(context);
assert.equal(isTemplateNorthStar(before.content), true);
// The agent writer refuses spec/ outright: no approval, no write.
await assert.rejects(api.writeRecord({ ...context, path: SPEC_PATH, record: '# Mine\n', author: assistant }), { code: 'project-spec-human-only' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), before.commit);
const text = '# North star\n\nShip the onboarding interview.\n\n## Who it is for\n\nNew organizations.\n';
const draft = await api.writeRecord({ ...context, path: DRAFTS.northStar, record: text, author: assistant });
assert.equal(git(repo, 'show', '-s', '--format=%an', draft.commit), 'assistant.assistant');
// Without the approval the spec is still the template.
const unchanged = await api.readSpec(context);
assert.equal(unchanged.commit, before.commit); assert.equal(isTemplateNorthStar(unchanged.content), true);
// A base older than the spec the human saw refuses in the save and writes nothing.
const head = git(repo, 'rev-parse', 'HEAD');
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: draft.commit, drafts: DRAFTS }), { code: 'stale-base', stage: 'save' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), head);
const spec = await api.readProjectFile({ ...context, path: SPEC_PATH });
const done = await approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: spec.commit, drafts: DRAFTS });
assert.equal(done.path, SPEC_PATH); assert.deepEqual(done.draft, { path: DRAFTS.northStar, commit: draft.commit });
assert.equal(git(repo, 'rev-parse', 'HEAD'), done.commit);
assert.equal(git(repo, 'show', '-s', '--format=%an|%s', done.commit), 'jason|Edit spec/north-star.md');
assert.equal(git(repo, 'show', `${done.commit}:${SPEC_PATH}`), text.trimEnd());
const after = await api.readSpec(context);
assert.equal(after.content, text); assert.equal(after.confirmed, true);
assert.equal(git(repo, 'status', '--porcelain'), '');
// The draft the approval used stays as it was; a second approval against the old spec commit is stale.
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: spec.commit, drafts: DRAFTS }), { code: 'stale-base' });
});
test('a draft rewritten after the human read it refuses draft-stale and the spec is untouched', async t => {
const { context, repo, assistant, human } = await repoFixture(t);
const first = await api.writeRecord({ ...context, path: DRAFTS.northStar, record: '# First\n', author: assistant });
await api.writeRecord({ ...context, path: DRAFTS.northStar, record: '# Second\n', author: assistant });
const spec = await api.readProjectFile({ ...context, path: SPEC_PATH }), head = git(repo, 'rev-parse', 'HEAD');
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: first.commit, base: spec.commit, drafts: DRAFTS }), { code: 'draft-stale' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), head);
assert.equal(isTemplateNorthStar((await api.readSpec(context)).content), true);
});