Files
stack/packages/mosaic/src/commands/prdy.spec.ts
T
fargoandjarvis d92de53399
ci/woodpecker/push/publish Pipeline was canceled
feat(prd): one transitional PRD authority — RI-4-001 (#1275) (#1294)
Co-authored-by: fargo <[email protected]>
2026-08-18 05:56:51 +00:00

205 lines
7.6 KiB
TypeScript

import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerPrdyCommand } from './prdy.js';
import { PrdService } from '@mosaicstack/prdy';
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
// outside the tmp project dir) ──────────────────────────────────────────────
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
class ProcessExitError extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
}
}
function stubProcessExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new ProcessExitError(code ?? 0);
}) as never);
}
const originalCwd = process.cwd();
let projectDir: string;
let errorSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let exitStub: ReturnType<typeof stubProcessExit>;
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerPrdyCommand(program);
return program;
}
function runPrdy(args: string[]): Promise<unknown> {
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
}
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'cmd-import-prd',
title: 'Command Import PRD',
status: 'approved', // must be forced to draft: validity is not approval
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
process.chdir(projectDir);
exitStub = stubProcessExit();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore only the per-test spies: module factory mocks must keep their
// implementations for the next test.
exitStub.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic prdy (thin adapter over PrdService)', () => {
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
await runPrdy(['--init', 'Adapter Created']);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.title).toBe('Adapter Created');
expect(docs[0]?.version).toBe(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
});
it('--import <file> creates a valid import through the service', async () => {
const filePath = path.join(projectDir, 'incoming.yaml');
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
await runPrdy(['--import', filePath]);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.id).toBe('cmd-import-prd');
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
});
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
const filePath = path.join(projectDir, 'broken.yaml');
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
// Typed refusal surfaced to the user, nothing created.
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Conflict Target' });
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
const beforeBytes = await readFile(storeFile, 'utf8');
const filePath = path.join(projectDir, 'divergent.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Divergent Command Import',
}),
),
'utf8',
);
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
// Original authority document is byte-identical on disk.
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
});
it('--import --accept-successor persists the successor version explicitly', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Successor Target' });
const filePath = path.join(projectDir, 'divergent2.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Accepted Via CLI',
}),
),
'utf8',
);
await runPrdy(['--import', filePath, '--accept-successor']);
const doc = await service.get(existing.id);
expect(doc.version).toBe(2);
expect(doc.title).toBe('Accepted Via CLI');
expect(doc.status).toBe('draft');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
});
it('--export writes a labeled generated view and never touches authority', async () => {
const service = new PrdService({ projectPath: projectDir });
const created = await service.create({ name: 'Export Via CLI' });
const before = await service.get(created.id);
await runPrdy(['--export', created.id]);
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
const md = await readFile(mdPath, 'utf8');
expect(md).toContain('generated view — do not edit');
expect(md).toContain(`prd-id: ${created.id}`);
expect(md).toContain('prd-version: 1');
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining(`Generated view written: ${mdPath}`),
);
// Authority unchanged by the export.
expect(await service.get(created.id)).toEqual(before);
});
});