test(HAI-017): complete Step 4 — add vitest config and tests for lock, parsing, atomic writes
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
158
packages/core/src/store.test.ts
Normal file
158
packages/core/src/store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
packages/core/vitest.config.ts
Normal file
7
packages/core/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user