test(HAI-017): complete Step 4 — add vitest config and tests for lock, parsing, atomic writes

This commit is contained in:
Dustin Byrne
2026-03-25 21:27:49 -04:00
parent 65ed0d919e
commit a8f31782a8
4 changed files with 175 additions and 9 deletions

View File

@@ -7,10 +7,12 @@
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.1.0"
}
}

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { TaskStore } from "./store.js";
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import type { Task } from "./types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "hai-store-test-"));
}
describe("TaskStore", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
await store.init();
});
afterEach(async () => {
store.stopWatching();
await rm(rootDir, { recursive: true, force: true });
});
async function createTestTask(): Promise<Task> {
return store.createTask({ description: "Test task" });
}
async function createTaskWithSteps(): Promise<Task> {
const task = await store.createTask({ description: "Task with steps" });
// Write a PROMPT.md with steps so updateStep works
const dir = join(rootDir, ".hai", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with steps
## Steps
### Step 0: Preflight
- [ ] Check things
### Step 1: Implementation
- [ ] Do stuff
### Step 2: Testing
- [ ] Test stuff
`,
);
return task;
}
// ── Lock serialization test ──────────────────────────────────────
describe("write lock serialization", () => {
it("serializes concurrent logEntry and updateStep calls without corruption", async () => {
const task = await createTaskWithSteps();
const id = task.id;
// Fire 20 concurrent operations: 10 logEntry + 10 updateStep (alternating steps)
const promises: Promise<Task>[] = [];
for (let i = 0; i < 20; i++) {
if (i % 2 === 0) {
promises.push(store.logEntry(id, `Log entry ${i}`));
} else {
// Toggle step 0 between in-progress and done
const status = i % 4 === 1 ? "in-progress" : "done";
promises.push(store.updateStep(id, 0, status));
}
}
await Promise.all(promises);
// Read back and verify valid JSON
const taskJsonPath = join(rootDir, ".hai", "tasks", id, "task.json");
const raw = await readFile(taskJsonPath, "utf-8");
const result = JSON.parse(raw) as Task;
// Check all 10 log entries are present (plus initial "Task created" + step update logs)
const customLogs = result.log.filter((l) => l.action.startsWith("Log entry"));
expect(customLogs).toHaveLength(10);
});
});
// ── Defensive parsing test ───────────────────────────────────────
describe("defensive JSON parsing", () => {
it("recovers from corrupted task.json with trailing duplicate content", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
// Corrupt the file: append duplicate trailing content (like HAI-015)
const validJson = await readFile(taskJsonPath, "utf-8");
const corrupted = validJson + validJson.slice(validJson.length / 2);
await writeFile(taskJsonPath, corrupted);
// getTask should recover
const recovered = await store.getTask(task.id);
expect(recovered.id).toBe(task.id);
expect(recovered.description).toBe("Test task");
});
it("throws a clear error when JSON is completely unrecoverable", async () => {
const task = await createTestTask();
const taskJsonPath = join(rootDir, ".hai", "tasks", task.id, "task.json");
// Write completely invalid content
await writeFile(taskJsonPath, "not json at all {{{");
await expect(store.getTask(task.id)).rejects.toThrow("Failed to parse task.json");
});
});
// ── Atomic write test ────────────────────────────────────────────
describe("atomic writes", () => {
it("produces valid JSON after write with no .tmp files left behind", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".hai", "tasks", task.id);
// Perform a write
await store.logEntry(task.id, "atomic test");
// Verify valid JSON
const raw = await readFile(join(dir, "task.json"), "utf-8");
const parsed = JSON.parse(raw) as Task;
expect(parsed.log.some((l) => l.action === "atomic test")).toBe(true);
// Verify no .tmp files
const files = await readdir(dir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
});
// ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => {
it("handles 10 parallel logEntry calls preserving all entries", async () => {
const task = await createTestTask();
const initialLogCount = task.log.length; // 1 ("Task created")
const promises = Array.from({ length: 10 }, (_, i) =>
store.logEntry(task.id, `Stress log ${i}`),
);
await Promise.all(promises);
const result = await store.getTask(task.id);
const stressLogs = result.log.filter((l) => l.action.startsWith("Stress log"));
expect(stressLogs).toHaveLength(10);
expect(result.log).toHaveLength(initialLogCount + 10);
});
});
});

View File

@@ -81,18 +81,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
// Attempt recovery: find last '}' and truncate
const lastBrace = raw.lastIndexOf("}");
if (lastBrace > 0) {
const truncated = raw.slice(0, lastBrace + 1);
// Attempt recovery: try truncating at each '}' from the end until valid
let pos = raw.length;
while ((pos = raw.lastIndexOf("}", pos - 1)) > 0) {
try {
const task = JSON.parse(truncated) as Task;
const task = JSON.parse(raw.slice(0, pos + 1)) as Task;
console.warn(
`[hai] Warning: repaired corrupted task.json at ${filePath} (truncated ${raw.length - lastBrace - 1} trailing bytes)`,
`[hai] Warning: repaired corrupted task.json at ${filePath} (truncated ${raw.length - pos - 1} trailing bytes)`,
);
return task;
} catch {
// Recovery also failed — fall through
// Try next position
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});