test(mosaic): RI-N3 contract specs for prdy and mission --plan adapters (#1275)
- import paths: created/draft, typed refusal creates nothing, conflict refusal leaves bytes untouched, --accept-successor persists v+1 - export writes the labeled generated view; authority unchanged - mission --plan persists linkage readable by a fresh service instance; mission path and prdy path share the docs/prdy store with stable ids/versions
This commit is contained in:
@@ -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<typeof vi.spyOn>;
|
||||||
|
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
|
||||||
|
|
||||||
|
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<Record<string, unknown>> };
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user