diff --git a/packages/mosaic/src/commands/mission-prd.spec.ts b/packages/mosaic/src/commands/mission-prd.spec.ts new file mode 100644 index 00000000..a5dcabe1 --- /dev/null +++ b/packages/mosaic/src/commands/mission-prd.spec.ts @@ -0,0 +1,149 @@ +import { mkdtemp, readFile, readdir } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { parse as parseYaml } from 'yaml'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerMissionCommand } from './mission.js'; +import { PrdService } from '@mosaicstack/prdy'; +import type { MissionInfo } from '../tui/gateway-api.js'; + +// ── Mocks: the gateway is not available in adapter tests ────────────────────── + +// vi.hoisted: the mock factory is hoisted above imports, so the fixture must +// be initialized there too. +const MISSION = vi.hoisted( + (): MissionInfo => ({ + id: 'mission-plan-1', + name: 'Plan Mission Alpha', + description: null, + status: 'planning', + projectId: null, + userId: null, + phase: null, + milestones: null, + config: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-03-04T05:06:07.000Z', + }), +); + +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', () => ({ + fetchMissions: vi.fn().mockResolvedValue([MISSION]), + fetchMission: vi.fn(), + createMission: vi.fn(), + updateMission: vi.fn(), + fetchMissionTasks: vi.fn().mockResolvedValue([]), + createMissionTask: vi.fn(), + updateMissionTask: vi.fn(), + fetchProjects: vi.fn().mockResolvedValue([]), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const originalCwd = process.cwd(); +let projectDir: string; +let logSpy: ReturnType; +let consoleStub: ReturnType[] = []; + +function buildTestProgram(): Command { + const program = new Command('mosaic').exitOverride(); + registerMissionCommand(program); + return program; +} + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-')); + process.chdir(projectDir); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleStub.push(logSpy); +}); + +afterEach(() => { + // Restore only the per-test spies; module factory mocks keep their + // implementations across tests. + for (const stub of consoleStub) stub.mockRestore(); + consoleStub = []; + process.chdir(originalCwd); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('mosaic mission --plan (thin adapter over PrdService)', () => { + it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => { + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + // PRD landed in the same store `mosaic prdy` uses. + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.yaml$/); + + // Fresh service instance (new-process equivalent) reads the linkage back. + const service = new PrdService({ projectPath: projectDir }); + const docs = await service.list(); + expect(docs).toHaveLength(1); + + const prd = docs[0]!; + expect(prd.title).toBe('Plan Mission Alpha'); + expect(prd.version).toBe(1); + + const links = await service.listMissionLinks(prd.id); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + missionId: MISSION.id, + missionVersion: MISSION.updatedAt, // mission version marker + prdVersion: 1, + }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked')); + }); + + it('linkage is persisted in the YAML authority document itself (survives restart)', async () => { + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8'); + const persisted = parseYaml(raw) as { missions: Array> }; + + expect(persisted.missions).toHaveLength(1); + expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' }); + }); + + it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => { + // Mission path. + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + // prdy path (service, non-interactive entry). + const service = new PrdService({ projectPath: projectDir }); + const direct = await service.create({ name: 'Directly Created' }); + + const all = await service.list(); + expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort()); + expect(all).toHaveLength(2); + + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + expect(files).toContain(`${direct.id}.yaml`); + + // Both are v1 in the same store with distinct stable ids. + for (const doc of all) { + expect(doc.version).toBe(1); + expect(files).toContain(`${doc.id}.yaml`); + } + }); +}); diff --git a/packages/mosaic/src/commands/prdy.spec.ts b/packages/mosaic/src/commands/prdy.spec.ts new file mode 100644 index 00000000..7bbfb5e4 --- /dev/null +++ b/packages/mosaic/src/commands/prdy.spec.ts @@ -0,0 +1,204 @@ +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); + }); +});