Initial commit
This commit is contained in:
200
scripts/work/dispatch.test.mjs
Normal file
200
scripts/work/dispatch.test.mjs
Normal file
@@ -0,0 +1,200 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user