import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs" import { join } from "path" import { z } from "zod" import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage" import type { OhMyOpenCodeConfig } from "../../config/schema" const TEST_DIR = ".test-claude-tasks" const TEST_DIR_ABS = join(process.cwd(), TEST_DIR) describe("getTaskDir", () => { test("returns correct path for default config", () => { //#given const config: Partial = {} //#when const result = getTaskDir(config) //#then expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) }) test("returns correct path with custom storage_path", () => { //#given const config: Partial = { sisyphus: { tasks: { storage_path: ".custom/tasks", claude_code_compat: false, }, }, } //#when const result = getTaskDir(config) //#then expect(result).toBe(join(process.cwd(), ".custom/tasks")) }) test("returns correct path with default config parameter", () => { //#when const result = getTaskDir() //#then expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) }) }) describe("generateTaskId", () => { test("generates task ID with T- prefix and UUID", () => { //#when const taskId = generateTaskId() //#then expect(taskId).toMatch(/^T-[a-f0-9-]{36}$/) }) test("generates unique task IDs", () => { //#when const id1 = generateTaskId() const id2 = generateTaskId() //#then expect(id1).not.toBe(id2) }) }) describe("listTaskFiles", () => { beforeEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) afterEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) test("returns empty array for non-existent directory", () => { //#given const config: Partial = { new_task_system_enabled: false, sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } } //#when const result = listTaskFiles(config) //#then expect(result).toEqual([]) }) test("returns empty array for directory with no task files", () => { //#given const config: Partial = { new_task_system_enabled: false, sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } } mkdirSync(TEST_DIR_ABS, { recursive: true }) writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8") //#when const result = listTaskFiles(config) //#then expect(result).toEqual([]) }) test("lists task files with T- prefix and .json extension", () => { //#given const config: Partial = { new_task_system_enabled: false, sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } } mkdirSync(TEST_DIR_ABS, { recursive: true }) writeFileSync(join(TEST_DIR_ABS, "T-abc123.json"), "{}", "utf-8") writeFileSync(join(TEST_DIR_ABS, "T-def456.json"), "{}", "utf-8") writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8") writeFileSync(join(TEST_DIR_ABS, "notes.md"), "# notes", "utf-8") //#when const result = listTaskFiles(config) //#then expect(result).toHaveLength(2) expect(result).toContain("T-abc123") expect(result).toContain("T-def456") }) test("returns task IDs without .json extension", () => { //#given const config: Partial = { new_task_system_enabled: false, sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } } mkdirSync(TEST_DIR_ABS, { recursive: true }) writeFileSync(join(TEST_DIR_ABS, "T-test-id.json"), "{}", "utf-8") //#when const result = listTaskFiles(config) //#then expect(result[0]).toBe("T-test-id") expect(result[0]).not.toContain(".json") }) }) describe("readJsonSafe", () => { const testSchema = z.object({ id: z.string(), value: z.number(), }) beforeEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } mkdirSync(TEST_DIR_ABS, { recursive: true }) }) afterEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) test("returns null for non-existent file", () => { //#given const filePath = join(TEST_DIR_ABS, "nonexistent.json") //#when const result = readJsonSafe(filePath, testSchema) //#then expect(result).toBeNull() }) test("returns parsed data for valid file", () => { //#given const filePath = join(TEST_DIR_ABS, "valid.json") const data = { id: "test", value: 42 } writeFileSync(filePath, JSON.stringify(data), "utf-8") //#when const result = readJsonSafe(filePath, testSchema) //#then expect(result).toEqual(data) }) test("returns null for invalid JSON", () => { //#given const filePath = join(TEST_DIR_ABS, "invalid.json") writeFileSync(filePath, "{ invalid json", "utf-8") //#when const result = readJsonSafe(filePath, testSchema) //#then expect(result).toBeNull() }) test("returns null for data that fails schema validation", () => { //#given const filePath = join(TEST_DIR_ABS, "invalid-schema.json") const data = { id: "test", value: "not-a-number" } writeFileSync(filePath, JSON.stringify(data), "utf-8") //#when const result = readJsonSafe(filePath, testSchema) //#then expect(result).toBeNull() }) }) describe("writeJsonAtomic", () => { beforeEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) afterEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) test("creates directory if it does not exist", () => { //#given const filePath = join(TEST_DIR_ABS, "nested", "dir", "file.json") const data = { test: "data" } //#when writeJsonAtomic(filePath, data) //#then expect(existsSync(filePath)).toBe(true) }) test("writes data atomically", async () => { //#given const filePath = join(TEST_DIR_ABS, "atomic.json") const data = { id: "test", value: 123 } //#when writeJsonAtomic(filePath, data) //#then expect(existsSync(filePath)).toBe(true) const content = await Bun.file(filePath).text() expect(JSON.parse(content)).toEqual(data) }) test("overwrites existing file", async () => { //#given const filePath = join(TEST_DIR_ABS, "overwrite.json") mkdirSync(TEST_DIR_ABS, { recursive: true }) writeFileSync(filePath, JSON.stringify({ old: "data" }), "utf-8") //#when const newData = { new: "data" } writeJsonAtomic(filePath, newData) //#then const content = await Bun.file(filePath).text() expect(JSON.parse(content)).toEqual(newData) }) }) describe("acquireLock", () => { beforeEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } mkdirSync(TEST_DIR_ABS, { recursive: true }) }) afterEach(() => { if (existsSync(TEST_DIR_ABS)) { rmSync(TEST_DIR_ABS, { recursive: true, force: true }) } }) test("acquires lock when no lock exists", () => { //#given const dirPath = TEST_DIR_ABS //#when const lock = acquireLock(dirPath) //#then expect(lock.acquired).toBe(true) expect(existsSync(join(dirPath, ".lock"))).toBe(true) //#cleanup lock.release() }) test("fails to acquire lock when fresh lock exists", () => { //#given const dirPath = TEST_DIR const firstLock = acquireLock(dirPath) //#when const secondLock = acquireLock(dirPath) //#then expect(secondLock.acquired).toBe(false) //#cleanup firstLock.release() }) test("acquires lock when stale lock exists (>30s)", () => { //#given const dirPath = TEST_DIR const lockPath = join(dirPath, ".lock") const staleTimestamp = Date.now() - 31000 // 31 seconds ago writeFileSync(lockPath, JSON.stringify({ timestamp: staleTimestamp }), "utf-8") //#when const lock = acquireLock(dirPath) //#then expect(lock.acquired).toBe(true) //#cleanup lock.release() }) test("release removes lock file", () => { //#given const dirPath = TEST_DIR const lock = acquireLock(dirPath) const lockPath = join(dirPath, ".lock") //#when lock.release() //#then expect(existsSync(lockPath)).toBe(false) }) test("release is safe to call multiple times", () => { //#given const dirPath = TEST_DIR const lock = acquireLock(dirPath) //#when lock.release() lock.release() //#then expect(existsSync(join(dirPath, ".lock"))).toBe(false) }) })