test(omo-codex): batch 19 (3 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:04 +09:00
parent 6fcce3c279
commit 53932dc94c
3 changed files with 347 additions and 0 deletions
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { parsePlanChecklist } from "../src/boulder-reader.js";
describe("start-work plan checklist parser", () => {
it("#given top-level completed and incomplete checkboxes #when parsed #then counts remaining and total", () => {
// given
const markdown = ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [X] Also done", "- [ ] Second"].join(
"\n",
);
// when
const checklist = parsePlanChecklist(markdown);
// then
expect(checklist).toEqual({ remaining: 2, total: 4, nextTaskLabel: "First" });
});
it("#given nested checkboxes #when parsed #then ignores non-column-zero items", () => {
// given
const markdown = ["## TODOs", "- [ ] Top-level", " - [ ] Nested", "\t- [ ] Tab nested", "- [x] Complete"].join(
"\n",
);
// when
const checklist = parsePlanChecklist(markdown);
// then
expect(checklist).toEqual({ remaining: 1, total: 2, nextTaskLabel: "Top-level" });
});
it("#given checkboxes outside counted sections #when parsed #then ignores unrelated top-level tasks", () => {
// given
const markdown = [
"# Plan",
"- [ ] Preamble task",
"## TODOs",
"- [ ] Build hook",
"## Acceptance Criteria",
"- [ ] Acceptance item",
"## Final Verification Wave",
"- [x] Run tests",
"- [ ] Run smoke",
].join("\n");
// when
const checklist = parsePlanChecklist(markdown);
// then
expect(checklist).toEqual({ remaining: 2, total: 3, nextTaskLabel: "Build hook" });
});
it("#given all top-level tasks complete #when parsed #then next task is null", () => {
// given
const markdown = ["## TODOs", "- [x] First", "- [X] Second"].join("\n");
// when
const checklist = parsePlanChecklist(markdown);
// then
expect(checklist).toEqual({ remaining: 0, total: 2, nextTaskLabel: null });
});
});
@@ -0,0 +1,124 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execPath } from "node:process";
import { afterEach, describe, expect, it } from "vitest";
const cleanupRoots: string[] = [];
afterEach(() => {
for (const root of cleanupRoots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe("start-work continuation CLI", () => {
it("#given valid Stop stdin #when CLI runs #then stdout contains block JSON", () => {
// given
const cwd = createWorkspace(["codex:s1"]);
const payload = JSON.stringify(makePayload(cwd, false, "Stop"));
// when
const result = runCli("stop", payload);
// then
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(0);
expect(result.stdout).toContain('"decision":"block"');
});
it("#given valid SubagentStop stdin #when CLI runs #then stdout contains block JSON", () => {
// given
const cwd = createWorkspace(["codex:s1"]);
const payload = JSON.stringify(makePayload(cwd, false, "SubagentStop"));
// when
const result = runCli("subagent-stop", payload);
// then
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(0);
expect(result.stdout).toContain('"decision":"block"');
});
it("#given active stop hook stdin #when CLI runs #then stdout is empty and exit is zero", () => {
// given
const cwd = createWorkspace(["codex:s1"]);
const payload = JSON.stringify(makePayload(cwd, true, "Stop"));
// when
const result = runCli("stop", payload);
// then
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(0);
expect(result.stdout).toBe("");
});
it("#given unrelated session stdin #when CLI runs #then stdout is empty and exit is zero", () => {
// given
const cwd = createWorkspace(["codex:other"]);
const payload = JSON.stringify(makePayload(cwd, false, "Stop"));
// when
const result = runCli("stop", payload);
// then
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(0);
expect(result.stdout).toBe("");
});
it("#given malformed stdin #when CLI runs #then stdout is empty and exit is zero", () => {
// given
const payload = "{not-json";
// when
const result = runCli("stop", payload);
// then
if (result.error !== undefined) throw result.error;
expect(result.status).toBe(0);
expect(result.stdout).toBe("");
});
});
function runCli(subcommand: "stop" | "subagent-stop", input: string) {
return spawnSync(execPath, [join(process.cwd(), "dist", "cli.js"), "hook", subcommand], { input, encoding: "utf8" });
}
function createWorkspace(sessionIds: readonly string[]): string {
const root = mkdtempSync(join(tmpdir(), "codex-continuation-cli-"));
cleanupRoots.push(root);
mkdirSync(join(root, ".omo", "plans"), { recursive: true });
writeFileSync(join(root, ".omo", "plans", "plan.md"), "## TODOs\n\n- [ ] Task one\n");
const work = {
work_id: "w1",
active_plan: ".omo/plans/plan.md",
plan_name: "cli plan",
session_ids: sessionIds,
status: "active",
};
writeFileSync(
join(root, ".omo", "boulder.json"),
`${JSON.stringify({ schema_version: 2, active_work_id: "w1", works: { w1: work } })}\n`,
);
return root;
}
function makePayload(
cwd: string,
stopHookActive: boolean,
eventName: "Stop" | "SubagentStop",
): Record<string, string | boolean> {
return {
session_id: "s1",
turn_id: "t1",
transcript_path: "",
cwd,
hook_event_name: eventName,
model: "gpt-5.5",
permission_mode: "default",
stop_hook_active: stopHookActive,
last_assistant_message: "done",
};
}
@@ -0,0 +1,160 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runStopHook } from "../src/codex-hook.js";
import type { ReadonlyFileSystem, StopInput } from "../src/types.js";
const WORKSPACE = "/repo";
const BOULDER_PATH = join(WORKSPACE, ".omo", "boulder.json");
const PLAN_PATH = join(WORKSPACE, ".omo", "plans", "plan.md");
const LEDGER_PATH = join(WORKSPACE, ".omo", "start-work", "ledger.jsonl");
describe("start-work Stop hook", () => {
it("#given stop hook is already active #when hook runs #then returns empty output", () => {
// given
const fs = createMemoryFs();
const input = { ...createStopInput(), stop_hook_active: true };
// when
const output = runStopHook(input, fs);
// then
expect(output).toBe("");
});
it("#given active codex work with remaining top-level tasks #when hook runs #then returns block JSON", () => {
// given
const fs = createMemoryFs({
[BOULDER_PATH]: createBoulderJson({
sessionIds: ["codex:sess_abc"],
status: "active",
worktreePath: "/tmp/worktree",
}),
[PLAN_PATH]: ["# Plan", "", "## TODOs", "- [ ] First", "- [x] Done", "- [ ] Second"].join("\n"),
});
// when
const output = runStopHook(createStopInput(), fs);
// then
const parsed = parseBlockOutput(output);
expect(parsed.decision).toBe("block");
expect(parsed.reason).toContain("- Plan: `launch-plan`");
expect(parsed.reason).toContain(`- Plan file: \`${PLAN_PATH}\``);
expect(parsed.reason).toContain(`- Boulder state: \`${BOULDER_PATH}\``);
expect(parsed.reason).toContain("- Remaining top-level checkboxes: `2` of `3`");
expect(parsed.reason).toContain("- Next incomplete task: `First`");
expect(parsed.reason).toContain("- Worktree: `/tmp/worktree`");
expect(parsed.reason).toContain(`- Ledger: \`${LEDGER_PATH}\``);
expect(parsed.reason).toContain("- Your session id in boulder.json: `codex:sess_abc`");
});
it("#given active work belongs to another harness #when hook runs #then returns empty output", () => {
// given
const fs = createMemoryFs({
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["opencode:sess_abc"], status: "active" }),
[PLAN_PATH]: "- [ ] First",
});
// when
const output = runStopHook(createStopInput(), fs);
// then
expect(output).toBe("");
});
it("#given bare legacy session id #when hook runs #then returns empty output", () => {
// given
const fs = createMemoryFs({
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["sess_abc"], status: "active" }),
[PLAN_PATH]: "- [ ] First",
});
// when
const output = runStopHook(createStopInput(), fs);
// then
expect(output).toBe("");
});
it("#given completed boulder work #when hook runs #then returns empty output", () => {
// given
const fs = createMemoryFs({
[BOULDER_PATH]: createBoulderJson({ sessionIds: ["codex:sess_abc"], status: "completed" }),
[PLAN_PATH]: "- [ ] First",
});
// when
const output = runStopHook(createStopInput(), fs);
// then
expect(output).toBe("");
});
it("#given malformed input #when hook runs #then returns empty output", () => {
// given
const fs = createMemoryFs();
// when
const output = runStopHook({ hook_event_name: "Stop", session_id: 123 }, fs);
// then
expect(output).toBe("");
});
});
type BoulderInput = {
readonly sessionIds: readonly string[];
readonly status: "active" | "completed" | "paused" | "abandoned";
readonly worktreePath?: string;
};
function createStopInput(): StopInput {
return {
hook_event_name: "Stop",
session_id: "sess_abc",
turn_id: "turn_1",
transcript_path: "",
cwd: WORKSPACE,
model: "gpt-5.5",
permission_mode: "default",
stop_hook_active: false,
last_assistant_message: "done",
};
}
function createBoulderJson(input: BoulderInput): string {
const work = {
work_id: "work_1",
active_plan: ".omo/plans/plan.md",
plan_name: "launch-plan",
status: input.status,
session_ids: input.sessionIds,
...(input.worktreePath === undefined ? {} : { worktree_path: input.worktreePath }),
};
return JSON.stringify({ schema_version: 2, active_work_id: "work_1", works: { work_1: work } });
}
function createMemoryFs(files: Record<string, string> = {}): ReadonlyFileSystem {
return {
readFileSync(path, encoding) {
expect(encoding).toBe("utf8");
const value = files[path];
if (value === undefined) throw new Error(`Missing fixture: ${path}`);
return value;
},
};
}
function parseBlockOutput(output: string): { readonly decision: "block"; readonly reason: string } {
const parsed: unknown = JSON.parse(output);
if (!isRecord(parsed)) throw new Error("Expected object output");
if (parsed["decision"] !== "block") throw new Error("Expected block decision");
const reason = parsed["reason"];
if (typeof reason !== "string") throw new Error("Expected string reason");
return { decision: "block", reason };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}