/** * Tests for projects.ts — worker state, migration, and accessors. * Run with: npx tsx --test lib/projects.test.ts */ import { describe, it } from "node:test"; import assert from "node:assert"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { readProjects, getWorker, emptyWorkerState, writeProjects, type ProjectsData } from "./projects.js"; describe("readProjects migration", () => { it("should migrate old format (dev/qa/architect fields) to workers map", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); // Old format: hardcoded dev/qa/architect fields const oldFormat = { projects: { "group-1": { name: "test-project", repo: "~/git/test", groupName: "Test", deployUrl: "", baseBranch: "main", deployBranch: "main", dev: { active: true, issueId: "42", startTime: null, level: "mid", sessions: { mid: "key-1" } }, qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, architect: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, }, }, }; await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; // Should have workers map with migrated role keys assert.ok(project.workers, "should have workers map"); assert.ok(project.workers.developer, "should have developer worker (migrated from dev)"); assert.ok(project.workers.tester, "should have tester worker (migrated from qa)"); assert.ok(project.workers.architect, "should have architect worker"); // Developer worker should be active with migrated level assert.strictEqual(project.workers.developer.active, true); assert.strictEqual(project.workers.developer.issueId, "42"); assert.strictEqual(project.workers.developer.level, "medior"); // Old fields should not exist on the object assert.strictEqual((project as any).dev, undefined); assert.strictEqual((project as any).qa, undefined); assert.strictEqual((project as any).architect, undefined); await fs.rm(tmpDir, { recursive: true }); }); it("should migrate old level names in old format", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); const oldFormat = { projects: { "group-1": { name: "legacy", repo: "~/git/legacy", groupName: "Legacy", deployUrl: "", baseBranch: "main", deployBranch: "main", dev: { active: false, issueId: null, startTime: null, level: "medior", sessions: { medior: "key-1" } }, qa: { active: false, issueId: null, startTime: null, level: "reviewer", sessions: { reviewer: "key-2" } }, architect: { active: false, issueId: null, startTime: null, level: "opus", sessions: { opus: "key-3" } }, }, }, }; await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(oldFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; // Level names should be migrated (dev→developer, qa→tester, medior→medior, reviewer→medior) assert.strictEqual(project.workers.developer.level, "medior"); assert.strictEqual(project.workers.tester.level, "medior"); assert.strictEqual(project.workers.architect.level, "senior"); // Session keys should be migrated assert.strictEqual(project.workers.developer.sessions.medior, "key-1"); assert.strictEqual(project.workers.tester.sessions.medior, "key-2"); assert.strictEqual(project.workers.architect.sessions.senior, "key-3"); await fs.rm(tmpDir, { recursive: true }); }); it("should read new format (workers map) correctly", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); const newFormat = { projects: { "group-1": { name: "modern", repo: "~/git/modern", groupName: "Modern", deployUrl: "", baseBranch: "main", deployBranch: "main", workers: { developer: { active: true, issueId: "10", startTime: null, level: "senior", sessions: { senior: "key-s" } }, tester: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, }, }, }, }; await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(newFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; assert.ok(project.workers.developer); assert.strictEqual(project.workers.developer.active, true); assert.strictEqual(project.workers.developer.level, "senior"); assert.ok(project.workers.tester); await fs.rm(tmpDir, { recursive: true }); }); it("should migrate old worker keys in new format", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); // Workers map but with old role keys const mixedFormat = { projects: { "group-1": { name: "mixed", repo: "~/git/mixed", groupName: "Mixed", deployUrl: "", baseBranch: "main", deployBranch: "main", workers: { dev: { active: true, issueId: "10", startTime: null, level: "mid", sessions: { mid: "key-m" } }, qa: { active: false, issueId: null, startTime: null, level: null, sessions: {} }, }, }, }, }; await fs.writeFile(path.join(projDir, "projects.json"), JSON.stringify(mixedFormat), "utf-8"); const data = await readProjects(tmpDir); const project = data.projects["group-1"]; // Old keys should be migrated assert.ok(project.workers.developer, "dev should be migrated to developer"); assert.ok(project.workers.tester, "qa should be migrated to tester"); assert.strictEqual(project.workers.developer.level, "medior"); assert.strictEqual(project.workers.developer.sessions.medior, "key-m"); await fs.rm(tmpDir, { recursive: true }); }); }); describe("getWorker", () => { it("should return worker from workers map", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); const data: ProjectsData = { projects: { "g1": { name: "test", repo: "~/git/test", groupName: "Test", deployUrl: "", baseBranch: "main", deployBranch: "main", workers: { developer: { active: true, issueId: "5", startTime: null, level: "medior", sessions: {} }, }, }, }, }; const worker = getWorker(data.projects["g1"], "developer"); assert.strictEqual(worker.active, true); assert.strictEqual(worker.issueId, "5"); await fs.rm(tmpDir, { recursive: true }); }); it("should return empty worker for unknown role", async () => { const data: ProjectsData = { projects: { "g1": { name: "test", repo: "~/git/test", groupName: "Test", deployUrl: "", baseBranch: "main", deployBranch: "main", workers: {}, }, }, }; const worker = getWorker(data.projects["g1"], "nonexistent"); assert.strictEqual(worker.active, false); assert.strictEqual(worker.issueId, null); }); }); describe("writeProjects round-trip", () => { it("should preserve workers map through write/read cycle", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); const projDir = path.join(tmpDir, "projects"); await fs.mkdir(projDir, { recursive: true }); const data: ProjectsData = { projects: { "g1": { name: "roundtrip", repo: "~/git/rt", groupName: "RT", deployUrl: "", baseBranch: "main", deployBranch: "main", workers: { developer: emptyWorkerState(["junior", "medior", "senior"]), tester: emptyWorkerState(["junior", "medior", "senior"]), architect: emptyWorkerState(["junior", "senior"]), }, }, }, }; await writeProjects(tmpDir, data); const loaded = await readProjects(tmpDir); const project = loaded.projects["g1"]; assert.ok(project.workers.developer); assert.ok(project.workers.tester); assert.ok(project.workers.architect); assert.strictEqual(project.workers.developer.sessions.junior, null); assert.strictEqual(project.workers.developer.sessions.medior, null); assert.strictEqual(project.workers.developer.sessions.senior, null); await fs.rm(tmpDir, { recursive: true }); }); });