ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fargo <[email protected]>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import yaml from 'js-yaml';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
PRD_GENERATED_VIEW_LABEL,
|
|
PrdImportConflictError,
|
|
PrdImportInvalidError,
|
|
PrdNotFoundError,
|
|
PrdService,
|
|
PrdUpdateError,
|
|
} from './index.js';
|
|
import type { PrdDocument } from './index.js';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
let projectDir: string;
|
|
|
|
async function makeProject(): Promise<string> {
|
|
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
|
|
}
|
|
|
|
function service(): PrdService {
|
|
return new PrdService({ projectPath: projectDir });
|
|
}
|
|
|
|
function storeDir(): string {
|
|
return path.join(projectDir, 'docs', 'prdy');
|
|
}
|
|
|
|
/** Handcraft a full, schema-valid PRD document for import scenarios. */
|
|
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
|
|
return {
|
|
id: 'imported-prd-20260101-000000',
|
|
title: 'Imported PRD',
|
|
status: 'draft',
|
|
projectPath: '/tmp/elsewhere',
|
|
template: 'software',
|
|
version: 1,
|
|
sections: [
|
|
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
|
|
{
|
|
id: 'scope-non-goals',
|
|
title: 'Scope / Non-Goals',
|
|
fields: { inScope: '', outOfScope: '' },
|
|
},
|
|
],
|
|
missions: [],
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function writeImportFile(doc: PrdDocument): Promise<string> {
|
|
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
|
|
await writeFile(filePath, yaml.dump(doc), 'utf8');
|
|
return filePath;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
projectDir = await makeProject();
|
|
});
|
|
|
|
// ── Single authority store (AC: prdy path and mission path resolve to the
|
|
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
|
|
|
|
describe('PrdService single authority store', () => {
|
|
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
|
|
const direct = await service().create({ name: 'Direct PRD' });
|
|
const viaMission = await service().planForMission({
|
|
name: 'Mission PRD',
|
|
missionId: 'mission-1',
|
|
missionVersion: '2026-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
const files = await readdir(storeDir());
|
|
expect(files).toContain(`${direct.id}.yaml`);
|
|
expect(files).toContain(`${viaMission.id}.yaml`);
|
|
|
|
// A fresh service instance (new process equivalent) resolves both.
|
|
const all = await service().list();
|
|
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
|
|
|
|
// Stable versions: creation is v1; linkage writes do not bump content version.
|
|
expect((await service().get(direct.id)).version).toBe(1);
|
|
expect((await service().get(viaMission.id)).version).toBe(1);
|
|
});
|
|
|
|
it('round-trips documents through the store with identity intact', async () => {
|
|
const created = await service().create({ name: 'Round Trip', template: 'feature' });
|
|
const fresh = await service().get(created.id);
|
|
|
|
expect(fresh).toEqual(created);
|
|
expect(fresh.id).toBe(created.id);
|
|
expect(fresh.template).toBe('feature');
|
|
expect(fresh.status).toBe('draft');
|
|
});
|
|
|
|
it('throws a typed error for unknown ids and empty stores', async () => {
|
|
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
});
|
|
});
|
|
|
|
// ── Mission linkage persistence (AC: linkage survives restart via fresh
|
|
// service instances) ────────────────────────────────────────────────────────
|
|
|
|
describe('PrdService mission linkage', () => {
|
|
it('persists linkage and reads it back from a fresh service instance', async () => {
|
|
const created = await service().planForMission({
|
|
name: 'Linked PRD',
|
|
missionId: 'mission-42',
|
|
missionVersion: '2026-02-03T04:05:06.000Z',
|
|
requirementIds: ['FR-1', 'FR-2'],
|
|
});
|
|
|
|
// Fresh instance — nothing in memory from the creating call.
|
|
const links = await service().listMissionLinks(created.id);
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]).toMatchObject({
|
|
missionId: 'mission-42',
|
|
missionVersion: '2026-02-03T04:05:06.000Z',
|
|
prdVersion: 1,
|
|
requirementIds: ['FR-1', 'FR-2'],
|
|
});
|
|
|
|
// Linkage is carried in the YAML authority file itself.
|
|
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
const persisted = yaml.load(raw) as PrdDocument;
|
|
expect(persisted.missions[0]?.missionId).toBe('mission-42');
|
|
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
|
|
});
|
|
|
|
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
|
|
const created = await service().planForMission({
|
|
name: 'Relink PRD',
|
|
missionId: 'mission-7',
|
|
missionVersion: 'v1',
|
|
});
|
|
|
|
await service().update({
|
|
id: created.id,
|
|
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
|
|
});
|
|
|
|
const relinked = await service().linkMission({
|
|
prdId: created.id,
|
|
missionId: 'mission-7',
|
|
missionVersion: 'v2',
|
|
requirementIds: ['NFR-1'],
|
|
});
|
|
|
|
expect(relinked.missions).toHaveLength(1);
|
|
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
|
|
});
|
|
|
|
it('does not bump the content version when writing linkage', async () => {
|
|
const created = await service().create({ name: 'Stable Version' });
|
|
const linked = await service().linkMission({
|
|
prdId: created.id,
|
|
missionId: 'm',
|
|
missionVersion: 'v1',
|
|
});
|
|
expect(linked.version).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── Update semantics ──────────────────────────────────────────────────────────
|
|
|
|
describe('PrdService update', () => {
|
|
it('applies section patches and bumps the content version', async () => {
|
|
const created = await service().create({ name: 'Updatable' });
|
|
const updated = await service().update({
|
|
id: created.id,
|
|
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
|
|
});
|
|
|
|
expect(updated.version).toBe(2);
|
|
expect(updated.sections[0]?.fields).toMatchObject({
|
|
context: 'Some context',
|
|
objective: 'Goal',
|
|
});
|
|
expect((await service().get(created.id)).version).toBe(2);
|
|
});
|
|
|
|
it('refuses unknown section ids with a typed error', async () => {
|
|
const created = await service().create({ name: 'Strict' });
|
|
await expect(
|
|
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
|
|
).rejects.toBeInstanceOf(PrdUpdateError);
|
|
});
|
|
});
|
|
|
|
// ── Markdown export is a labeled generated view, never authority ──────────────
|
|
|
|
describe('PrdService exportMarkdown', () => {
|
|
it('writes a generated view carrying the label and source identity', async () => {
|
|
const created = await service().create({ name: 'Exported PRD' });
|
|
const result = await service().exportMarkdown({ id: created.id });
|
|
|
|
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
|
|
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
|
|
expect(result.content).toContain(`prd-id: ${created.id}`);
|
|
expect(result.content).toContain('prd-version: 1');
|
|
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
|
|
});
|
|
|
|
it('reflects the current version after updates', async () => {
|
|
const created = await service().create({ name: 'Versioned Export' });
|
|
await service().update({
|
|
id: created.id,
|
|
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
|
|
});
|
|
const result = await service().exportMarkdown({ id: created.id });
|
|
expect(result.content).toContain('prd-version: 2');
|
|
});
|
|
|
|
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
|
|
const created = await service().create({ name: 'Guarded PRD' });
|
|
const before = structuredClone(await service().get(created.id));
|
|
|
|
const result = await service().exportMarkdown({ id: created.id });
|
|
await writeFile(
|
|
result.filePath,
|
|
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const after = await service().get(created.id);
|
|
expect(after).toEqual(before);
|
|
expect(after.version).toBe(1);
|
|
expect(after.title).toBe(before.title);
|
|
});
|
|
|
|
it('never parses Markdown files that sit in the store directory', async () => {
|
|
const created = await service().create({ name: 'Decoy Guard' });
|
|
|
|
// A decoy .md file with invalid YAML must be invisible to the store.
|
|
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
|
|
// And a decoy .yaml-named Markdown body must not silently validate either.
|
|
await service().exportMarkdown({ id: created.id });
|
|
|
|
const listed = await service().list();
|
|
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
|
|
await expect(service().get(created.id)).resolves.toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ── Import: validated, conflict-aware, never silently merging ─────────────────
|
|
|
|
describe('PrdService importDocument', () => {
|
|
it('creates a valid import through the service, as draft — validity is not approval', async () => {
|
|
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
|
|
|
|
const result = await service().importDocument({ filePath });
|
|
|
|
expect(result.kind).toBe('created');
|
|
expect(result.document.id).toBe('imported-prd-20260101-000000');
|
|
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
|
|
expect(result.document.version).toBe(1);
|
|
|
|
const persisted = await service().get('imported-prd-20260101-000000');
|
|
expect(persisted.status).toBe('draft');
|
|
|
|
const files = await readdir(storeDir());
|
|
expect(files).toContain('imported-prd-20260101-000000.yaml');
|
|
});
|
|
|
|
it('reports identical content as a no-op without writing', async () => {
|
|
const created = await service().create({ name: 'Existing PRD' });
|
|
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
|
|
const filePath = await writeImportFile(importFixture({ ...created }));
|
|
const result = await service().importDocument({ filePath });
|
|
|
|
expect(result.kind).toBe('identical');
|
|
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
|
|
const existing = await service().create({ name: 'Authority PRD' });
|
|
await service().linkMission({
|
|
prdId: existing.id,
|
|
missionId: 'mission-keep',
|
|
missionVersion: 'v1',
|
|
requirementIds: ['FR-0'],
|
|
});
|
|
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
|
|
|
const divergent = importFixture({
|
|
...existing,
|
|
title: 'Divergent Title',
|
|
sections: [
|
|
{
|
|
id: 'introduction',
|
|
title: 'Introduction',
|
|
fields: { context: 'changed', objective: '' },
|
|
},
|
|
],
|
|
});
|
|
const filePath = await writeImportFile(divergent);
|
|
|
|
const attempt = service().importDocument({ filePath });
|
|
let caught: unknown;
|
|
try {
|
|
await attempt;
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(PrdImportConflictError);
|
|
|
|
const error = caught as PrdImportConflictError;
|
|
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
|
|
expect(error.existing.id).toBe(existing.id);
|
|
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
|
|
expect(error.proposal.status).toBe('draft');
|
|
|
|
// Original authority content untouched on disk.
|
|
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
|
expect(afterBytes).toBe(beforeBytes);
|
|
});
|
|
|
|
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
|
|
const existing = await service().create({ name: 'Successor Base' });
|
|
await service().linkMission({
|
|
prdId: existing.id,
|
|
missionId: 'mission-keep',
|
|
missionVersion: 'v1',
|
|
});
|
|
|
|
const divergent = importFixture({
|
|
...existing,
|
|
title: 'Accepted Successor Title',
|
|
});
|
|
const filePath = await writeImportFile(divergent);
|
|
|
|
const successor = await service().acceptSuccessor({ filePath });
|
|
expect(successor.id).toBe(existing.id);
|
|
expect(successor.version).toBe(existing.version + 1);
|
|
expect(successor.title).toBe('Accepted Successor Title');
|
|
expect(successor.status).toBe('draft');
|
|
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
|
|
|
|
// Persisted for a fresh reader.
|
|
const fresh = await service().get(existing.id);
|
|
expect(fresh.version).toBe(2);
|
|
expect(fresh.title).toBe('Accepted Successor Title');
|
|
});
|
|
|
|
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
|
|
const cases: Array<{ name: string; body: string }> = [
|
|
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
|
|
{
|
|
name: 'bad-status.yaml',
|
|
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
|
|
},
|
|
{
|
|
name: 'bad-version.yaml',
|
|
body: yaml.dump(importFixture({ version: 0 })),
|
|
},
|
|
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
|
|
];
|
|
|
|
for (const fixture of cases) {
|
|
const filePath = path.join(projectDir, fixture.name);
|
|
await writeFile(filePath, fixture.body, 'utf8');
|
|
|
|
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
|
|
PrdImportInvalidError,
|
|
);
|
|
}
|
|
|
|
// Nothing was created: the authority store does not even exist yet.
|
|
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
|
|
const filePath = await writeImportFile(importFixture());
|
|
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
|
|
});
|
|
});
|
|
|
|
// ── No second writer: no code path reads exported Markdown back into authority ─
|
|
|
|
describe('no-second-writer invariant (source-level)', () => {
|
|
// Resolve the package source dir whether vitest runs from the package root
|
|
// (turbo/pnpm test) or from the worktree root.
|
|
function resolveSrcDir(): string {
|
|
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
|
|
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
|
|
}
|
|
|
|
const srcDir = resolveSrcDir();
|
|
const sourceFiles = [
|
|
'cli.ts',
|
|
'index.ts',
|
|
'prd.ts',
|
|
'service.ts',
|
|
'templates.ts',
|
|
'types.ts',
|
|
'wizard.ts',
|
|
];
|
|
|
|
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
|
|
for (const file of sourceFiles) {
|
|
const text = await readFile(path.join(srcDir, file), 'utf8');
|
|
const readLines = text
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
|
|
|
|
for (const line of readLines) {
|
|
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('the mosaic prdy/mission adapters never read a .md file', async () => {
|
|
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
|
|
for (const file of ['prdy.ts', 'mission.ts']) {
|
|
const text = await readFile(path.join(adapterDir, file), 'utf8');
|
|
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
|
|
false,
|
|
);
|
|
}
|
|
});
|
|
});
|