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; let logSpy: ReturnType; let exitStub: ReturnType; function buildTestProgram(): Command { const program = new Command('mosaic').exitOverride(); registerPrdyCommand(program); return program; } function runPrdy(args: string[]): Promise { return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' }); } function importableDocument(overrides: Record = {}): Record { 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 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); }); });