import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; import { CIAgentFiles, ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "../core/ciagent-files.js"; function createTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-files-test-")); } function cleanup(dir: string): void { fs.rmSync(dir, { recursive: true, force: true }); } describe("CIAgentFiles", () => { let dir: string; beforeEach(() => { dir = createTempDir(); }); afterEach(() => { cleanup(dir); }); describe("ensureCIAgentDir", () => { it("creates .ciagent directory", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); expect(fs.existsSync(path.join(dir, ".ciagent"))).toBe(true); }); }); describe("isInitialized", () => { it("returns false when no config.json exists", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.isInitialized()).toBe(false); }); it("returns true when config.json exists", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), "{}"); expect(ciFiles.isInitialized()).toBe(true); }); }); describe("projectSlug", () => { it("defaults to empty string", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.getProjectSlug()).toBe(""); }); it("uses provided project slug", () => { const ciFiles = new CIAgentFiles(dir, "task-api"); expect(ciFiles.getProjectSlug()).toBe("task-api"); }); it("setProjectSlug updates slug", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.setProjectSlug("auth-svc"); expect(ciFiles.getProjectSlug()).toBe("auth-svc"); }); }); describe("multi-project support", () => { it("isMultiProject returns false when not initialized", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.isMultiProject()).toBe(false); }); it("isMultiProject returns false for single-project config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "default", name: "Default" }], active_project: "default", })); expect(ciFiles.isMultiProject()).toBe(true); }); it("isMultiProject returns false for config without projects array", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({})); expect(ciFiles.isMultiProject()).toBe(false); }); it("addProject adds a project to config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [], active_project: "", })); ciFiles.addProject("task-api", "Task API", true); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8")); expect(config.projects).toHaveLength(1); expect(config.projects[0].slug).toBe("task-api"); expect(config.active_project).toBe("task-api"); }); it("addProject does not duplicate existing project", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "task-api", name: "Task API" }], active_project: "task-api", })); ciFiles.addProject("task-api", "Task API V2"); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8")); expect(config.projects).toHaveLength(1); }); it("addProject creates project subdirectory", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [], active_project: "", })); ciFiles.addProject("task-api", "Task API", true); expect(fs.existsSync(path.join(dir, ".ciagent", "task-api"))).toBe(true); }); it("getActiveProject returns from config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "task-api", name: "Task API", default: true }], active_project: "task-api", })); expect(ciFiles.getActiveProject()).toBe("task-api"); }); it("setActiveProject updates config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [ { slug: "task-api", name: "Task API" }, { slug: "auth-svc", name: "Auth Service" }, ], active_project: "task-api", })); ciFiles.setActiveProject("auth-svc"); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8")); expect(config.active_project).toBe("auth-svc"); }); it("listProjects returns projects from config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [ { slug: "task-api", name: "Task API", default: true }, { slug: "auth-svc", name: "Auth Service" }, ], active_project: "task-api", })); const projects = ciFiles.listProjects(); expect(projects).toHaveLength(2); expect(projects[0].slug).toBe("task-api"); expect(projects[1].slug).toBe("auth-svc"); }); }); describe("needsMigration", () => { it("returns false when not initialized", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.needsMigration()).toBe(false); }); it("returns false when already multi-project", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "default", name: "Default" }], })); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test"); expect(ciFiles.needsMigration()).toBe(false); }); it("returns true when flat files exist without subdirs or multi-project config", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test"); expect(ciFiles.needsMigration()).toBe(true); }); it("returns false when flat files exist but subdirs also exist", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test"); fs.mkdirSync(path.join(dir, ".ciagent", "task-api")); fs.writeFileSync(path.join(dir, ".ciagent", "task-api", "PROJECT.md"), "# Task API"); expect(ciFiles.needsMigration()).toBe(false); }); }); describe("migrateFlatToProject", () => { it("moves flat files to project subdirectory", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test Project"); fs.writeFileSync(path.join(dir, ".ciagent", "ARCHITECTURE.md"), "# Architecture"); fs.writeFileSync(path.join(dir, ".ciagent", "ROADMAP.md"), "# Roadmap"); fs.writeFileSync(path.join(dir, ".ciagent", "REQUIREMENTS.md"), "# Requirements"); ciFiles.migrateFlatToProject("my-app"); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "PROJECT.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "ARCHITECTURE.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "ROADMAP.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "REQUIREMENTS.md"))).toBe(true); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8")); expect(config.projects).toHaveLength(1); expect(config.active_project).toBe("my-app"); }); it("does not migrate when not needed", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "existing", name: "Existing" }], })); ciFiles.migrateFlatToProject("new-proj"); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8")); expect(config.projects).toHaveLength(1); expect(config.projects[0].slug).toBe("existing"); }); }); describe("isNfrMilestone", () => { it("returns true when no roadmap exists", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.isNfrMilestone()).toBe(true); }); it("returns true when phases are all NFR types", () => { const ciFiles = new CIAgentFiles(dir, "nfr-proj"); ciFiles.ensureProjectDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }], active_project: "nfr-proj", })); const roadmap: RoadmapMd = { overview: "NFR-only", phases: [ { number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, { number: 2, name: "perf-tune", description: "Tune perf", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); expect(ciFiles.isNfrMilestone()).toBe(true); }); it("returns false when phases include feature work", () => { const ciFiles = new CIAgentFiles(dir, "feat-proj"); ciFiles.ensureProjectDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "feat-proj", name: "Feature Project", default: true }], active_project: "feat-proj", })); const roadmap: RoadmapMd = { overview: "mixed", phases: [ { number: 1, name: "authentication", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, { number: 2, name: "test-coverage", description: "Add tests", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); expect(ciFiles.isNfrMilestone()).toBe(false); }); }); describe("getMilestoneType", () => { it("returns nfr when no roadmap exists", () => { const ciFiles = new CIAgentFiles(dir); expect(ciFiles.getMilestoneType()).toBe("nfr"); }); it("returns nfr when phases are all NFR types", () => { const ciFiles = new CIAgentFiles(dir, "nfr-proj2"); ciFiles.ensureProjectDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }], active_project: "nfr-proj2", })); const roadmap: RoadmapMd = { overview: "NFR-only", phases: [ { number: 1, name: "fix-bug", description: "Fix bug", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); expect(ciFiles.getMilestoneType()).toBe("nfr"); }); it("returns feature when phases include feat work", () => { const ciFiles = new CIAgentFiles(dir, "feat-proj2"); ciFiles.ensureProjectDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }], active_project: "feat-proj2", })); const roadmap: RoadmapMd = { overview: "feature", phases: [ { number: 1, name: "auth-flow", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); expect(ciFiles.getMilestoneType()).toBe("feature"); }); it("returns major when phases include refactor/rewrite/migrate", () => { const ciFiles = new CIAgentFiles(dir, "schema-proj"); ciFiles.ensureProjectDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "schema-proj", name: "Schema Project", default: true }], active_project: "schema-proj", })); const roadmap: RoadmapMd = { overview: "major", phases: [ { number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, ], }; ciFiles.writeRoadmapMd(roadmap); expect(ciFiles.getMilestoneType()).toBe("major"); }); }); describe("multi-project file paths", () => { it("writes PROJECT.md to project subdirectory when slug is set", () => { const ciFiles = new CIAgentFiles(dir, "my-app"); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({ projects: [{ slug: "my-app", name: "My App", default: true }], active_project: "my-app", })); const project: ProjectMd = { name: "My App", coreValue: "Build something cool", requirements: { validated: [], active: [], outOfScope: [] }, constraints: [], context: "Test context", keyDecisions: [], }; ciFiles.writeProjectMd(project, "initial"); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "PROJECT.md"))).toBe(true); }); it("writes PROJECT.md to .ci root when no slug is set", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.ensureCIDir(); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({})); const project: ProjectMd = { name: "Default App", coreValue: "Build something", requirements: { validated: [], active: [], outOfScope: [] }, constraints: [], context: "Test context", keyDecisions: [], }; ciFiles.writeProjectMd(project, "initial"); expect(fs.existsSync(path.join(dir, ".ciagent", "PROJECT.md"))).toBe(true); }); }); describe("PROJECT.md", () => { const project: ProjectMd = { name: "Task API", coreValue: "Build a REST API for task management", requirements: { validated: ["User auth works"], active: ["Real-time notifications", "CRUD operations"], outOfScope: ["Admin dashboard"], }, constraints: ["Must use Node.js", "Production-ready"], context: "This is a task management API", keyDecisions: [ { decision: "Use PostgreSQL", rationale: "ACID compliance", outcome: "✓ Good" }, ], }; it("writes and reads PROJECT.md", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeProjectMd(project, "initial creation"); const read = ciFiles.readProjectMd(); expect(read).not.toBeNull(); expect(read!.name).toBe("Task API"); expect(read!.requirements.active).toContain("Real-time notifications"); expect(read!.constraints).toContain("Must use Node.js"); }); it("overwrites PROJECT.md on update", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeProjectMd(project, "initial"); const updated = { ...project, coreValue: "Updated description" }; ciFiles.writeProjectMd(updated, "phase 1 complete"); const read = ciFiles.readProjectMd(); expect(read!.coreValue).toBe("Updated description"); }); }); describe("ROADMAP.md", () => { const roadmap: RoadmapMd = { overview: "4-phase delivery", phases: [ { number: 1, name: "auth", description: "Auth system", status: "in_progress", dependsOn: [], requirements: ["AUTH-01"], successCriteria: ["Users can sign up"], }, { number: 2, name: "tasks", description: "Task CRUD", status: "not_started", dependsOn: [1], requirements: ["TASK-01"], successCriteria: ["Users can create tasks"], }, ], }; it("writes and reads ROADMAP.md", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeRoadmapMd(roadmap); const read = ciFiles.readRoadmapMd(); expect(read).not.toBeNull(); expect(read!.overview).toBe("4-phase delivery"); }); }); describe("REQUIREMENTS.md", () => { const requirements: RequirementsMd = { v1: [ { category: "Auth", items: [ { id: "AUTH-01", description: "User can sign up" }, { id: "AUTH-02", description: "User can log in" }, ], }, ], v2: [ { category: "Notifications", items: [{ id: "NOTIF-01", description: "Push notifications" }], }, ], outOfScope: [{ feature: "Admin dashboard", reason: "Not core value" }], traceability: [ { requirement: "AUTH-01", phase: 1, status: "pending" }, { requirement: "AUTH-02", phase: 1, status: "pending" }, ], }; it("writes and reads REQUIREMENTS.md", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeRequirementsMd(requirements); const read = ciFiles.readRequirementsMd(); expect(read).not.toBeNull(); }); it("updates requirement status", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeRequirementsMd(requirements); ciFiles.updateRequirementStatus("AUTH-01", "complete"); const read = ciFiles.readRequirementsMd(); expect(read).not.toBeNull(); }); }); describe("ARCHITECTURE.md", () => { const arch: ArchitectureMd = { overview: "Monolith with modules", components: [ { name: "API", description: "REST API server", boundaries: "HTTP layer only", dependsOn: ["Auth", "Tasks"], }, ], dataFlow: "Client -> API -> DB", buildOrder: ["Auth", "Tasks", "API"], }; it("writes and reads ARCHITECTURE.md", () => { const ciFiles = new CIAgentFiles(dir); ciFiles.writeArchitectureMd(arch); const read = ciFiles.readArchitectureMd(); expect(read).not.toBeNull(); expect(read!.overview).toBe("Monolith with modules"); }); }); describe("updatePhaseStatus", () => { it("updates phase status in roadmap", () => { const ciFiles = new CIAgentFiles(dir); const roadmap: RoadmapMd = { overview: "test", phases: [ { number: 1, name: "auth", description: "Auth", status: "not_started", dependsOn: [], requirements: [], successCriteria: [], }, ], }; ciFiles.writeRoadmapMd(roadmap); ciFiles.updatePhaseStatus(1, "complete"); const read = ciFiles.readRoadmapMd(); expect(read).not.toBeNull(); }); }); });