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-.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-.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); });