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