import { describe, it, expect } from "vitest"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; import { findNextTask, findFirstUncheckedBullet, buildTaskSpec, resolveClaudeAuth, } from "./dispatch.mjs"; function makeWorkTree({ epics }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-")); for (const [epicId, epicData] of Object.entries(epics)) { const epicDir = path.join(root, epicId); fs.mkdirSync(epicDir, { recursive: true }); fs.writeFileSync( path.join(epicDir, "_epic.md"), `---\nid: ${epicId}\ntitle: ${epicId}\nstatus: ${epicData.status ?? "in-progress"}\n---\n`, ); for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) { const storyDir = path.join(epicDir, storyId); fs.mkdirSync(storyDir, { recursive: true }); const tasks = (storyData.tasks ?? []) .map((t) => `- [${t.done ? "x" : " "}] ${t.label}`) .join("\n"); const deps = storyData.depends_on ? `depends-on: [${storyData.depends_on.join(", ")}]\n` : ""; fs.writeFileSync( path.join(storyDir, "_story.md"), `---\nid: ${storyId}\ntitle: ${storyId}\nstatus: ${storyData.status ?? "todo"}\n${deps}---\n\n## Tasks\n${tasks}\n`, ); } } return root; } describe("findFirstUncheckedBullet", () => { it("returns the first - [ ] line under ## Tasks", () => { const content = `## Tasks - [x] done - [ ] open - [ ] another `; const { bulletLine } = findFirstUncheckedBullet(content); expect(bulletLine).toBe("- [ ] open"); }); it("returns null when no unchecked bullets remain", () => { const content = `## Tasks - [x] done - [x] also done `; expect(findFirstUncheckedBullet(content).bulletLine).toBeNull(); }); it("only checks inside the Tasks section", () => { const content = `## Goal - [ ] not a task ## Tasks - [x] done `; expect(findFirstUncheckedBullet(content).bulletLine).toBeNull(); }); }); describe("findNextTask", () => { it("returns the next bullet from the first ready story", () => { const root = makeWorkTree({ epics: { e: { stories: { s: { status: "todo", tasks: [ { label: "a", done: true }, { label: "b", done: false }, ], }, }, }, }, }); const next = findNextTask(root); expect(next.epic).toBe("e"); expect(next.story).toBe("s"); expect(next.bulletLine.trim()).toBe("- [ ] b"); }); it("returns null when no stories are ready", () => { const root = makeWorkTree({ epics: { e: { stories: { s: { status: "done", tasks: [{ label: "a", done: true }], }, }, }, }, }); expect(findNextTask(root)).toBeNull(); }); it("returns null when ready story has no unchecked bullets", () => { const root = makeWorkTree({ epics: { e: { stories: { s: { status: "todo", tasks: [{ label: "a", done: true }], }, }, }, }, }); expect(findNextTask(root)).toBeNull(); }); }); describe("buildTaskSpec", () => { it("includes epic, story, bullet, and full story content", () => { const next = { epic: "e", story: "s", title: "Story", storyContent: "## Goal\n\nSomething.\n## Tasks\n- [ ] do thing", bulletLine: "- [ ] do thing", }; const spec = buildTaskSpec(next); expect(spec).toContain("e"); expect(spec).toContain("s — Story"); expect(spec).toContain("- [ ] do thing"); expect(spec).toContain("## Goal"); }); }); describe("resolveClaudeAuth", () => { it("returns subscription mode when ~/.claude exists on host", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-")); fs.mkdirSync(path.join(tmpHome, ".claude")); const result = resolveClaudeAuth({ env: {}, home: tmpHome }); expect(result.mode).toBe("subscription"); expect(result.hostPath).toBe(path.join(tmpHome, ".claude")); expect(result.sandboxPath).toBe("~/.claude"); }); it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => { const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-")); const overrideDir = path.join(tmpRoot, "custom-claude"); fs.mkdirSync(overrideDir); const result = resolveClaudeAuth({ env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir }, home: "/nonexistent", }); expect(result.mode).toBe("subscription"); expect(result.hostPath).toBe(overrideDir); }); it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-")); // No .claude directory created const result = resolveClaudeAuth({ env: { ANTHROPIC_API_KEY: "sk-test" }, home: tmpHome, }); expect(result.mode).toBe("api-key"); expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" }); }); it("falls back to OPENAI_API_KEY when only that is set", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-")); const result = resolveClaudeAuth({ env: { OPENAI_API_KEY: "sk-openai" }, home: tmpHome, }); expect(result.mode).toBe("api-key"); expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" }); }); it("returns missing when neither subscription nor API key available", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-")); const result = resolveClaudeAuth({ env: {}, home: tmpHome }); expect(result.mode).toBe("missing"); }); it("prefers subscription over API key when both available", () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-")); fs.mkdirSync(path.join(tmpHome, ".claude")); const result = resolveClaudeAuth({ env: { ANTHROPIC_API_KEY: "sk-test" }, home: tmpHome, }); expect(result.mode).toBe("subscription"); }); });