From 800c37200546c596fa79627372a6943fd1aa4df2 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Tue, 3 Mar 2026 20:15:15 -0700 Subject: [PATCH] test: add unit tests for state and git lib modules - 10 tests for state.ts: loadState, saveState, appendLog, readLog, now() - 8 tests for git.ts: getBranch, getStatus, commits, staged files, error handling - Coverage goes from 43 to 61 tests (42% increase) --- tests/lib/git.test.ts | 62 +++++++++++++++++++++++++ tests/lib/state.test.ts | 100 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/lib/git.test.ts create mode 100644 tests/lib/state.test.ts diff --git a/tests/lib/git.test.ts b/tests/lib/git.test.ts new file mode 100644 index 0000000..d27b4fe --- /dev/null +++ b/tests/lib/git.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { execSync } from "child_process"; + +// Create a real temp git repo +const testDir = mkdtempSync(join(tmpdir(), "preflight-git-test-")); +execSync("git init && git commit --allow-empty -m 'init'", { cwd: testDir, stdio: "pipe" }); + +vi.mock("../../src/lib/files.js", () => ({ + PROJECT_DIR: testDir, +})); + +const git = await import("../../src/lib/git.js"); + +describe("git", () => { + it("getBranch returns a branch name", () => { + const branch = git.getBranch(); + // Default branch after init is usually main or master + expect(typeof branch).toBe("string"); + expect(branch.length).toBeGreaterThan(0); + }); + + it("getStatus returns string (empty for clean repo)", () => { + const status = git.getStatus(); + expect(typeof status).toBe("string"); + }); + + it("getRecentCommits returns commit lines", () => { + const commits = git.getRecentCommits(1); + expect(commits).toContain("init"); + }); + + it("getLastCommit returns the init commit", () => { + const last = git.getLastCommit(); + expect(last).toContain("init"); + }); + + it("getLastCommitTime returns a date string", () => { + const time = git.getLastCommitTime(); + // Should be parseable as a date + expect(new Date(time).getFullYear()).toBeGreaterThanOrEqual(2024); + }); + + it("getStagedFiles returns empty string for clean repo", () => { + const staged = git.getStagedFiles(); + expect(staged).toBe(""); + }); + + it("run handles invalid git command gracefully", () => { + const result = git.run(["not-a-real-command"]); + // Should return error string, not throw + expect(typeof result).toBe("string"); + }); + + it("run handles timeout", () => { + // This should complete fast, just testing the timeout path exists + const result = git.run(["status"], { timeout: 5000 }); + expect(typeof result).toBe("string"); + }); +}); diff --git a/tests/lib/state.test.ts b/tests/lib/state.test.ts new file mode 100644 index 0000000..18077c5 --- /dev/null +++ b/tests/lib/state.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +const testDir = mkdtempSync(join(tmpdir(), "preflight-state-test-")); + +vi.mock("../../src/lib/files.js", () => ({ + PROJECT_DIR: testDir, +})); + +const { loadState, saveState, appendLog, readLog, now, STATE_DIR } = await import("../../src/lib/state.js"); + +function ensureDir() { + if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true }); +} + +describe("state", () => { + afterEach(() => { + try { rmSync(STATE_DIR, { recursive: true, force: true }); } catch {} + }); + + describe("loadState", () => { + it("returns empty object for missing file", () => { + expect(loadState("nonexistent")).toEqual({}); + }); + + it("returns empty object for corrupt JSON", () => { + ensureDir(); + writeFileSync(join(STATE_DIR, "corrupt.json"), "not json{{{"); + expect(loadState("corrupt")).toEqual({}); + }); + + it("loads valid state", () => { + ensureDir(); + const data = { foo: "bar", count: 42 }; + writeFileSync(join(STATE_DIR, "valid.json"), JSON.stringify(data)); + expect(loadState("valid")).toEqual(data); + }); + }); + + describe("saveState", () => { + it("creates state dir and writes file", () => { + saveState("test", { hello: "world" }); + const content = JSON.parse(readFileSync(join(STATE_DIR, "test.json"), "utf-8")); + expect(content).toEqual({ hello: "world" }); + }); + + it("overwrites existing state", () => { + saveState("overwrite", { v: 1 }); + saveState("overwrite", { v: 2 }); + expect(JSON.parse(readFileSync(join(STATE_DIR, "overwrite.json"), "utf-8"))).toEqual({ v: 2 }); + }); + }); + + describe("appendLog / readLog", () => { + it("appends entries and reads them back", () => { + appendLog("test.jsonl", { action: "a" }); + appendLog("test.jsonl", { action: "b" }); + appendLog("test.jsonl", { action: "c" }); + const entries = readLog("test.jsonl"); + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual({ action: "a" }); + expect(entries[2]).toEqual({ action: "c" }); + }); + + it("returns empty array for missing log", () => { + expect(readLog("missing.jsonl")).toEqual([]); + }); + + it("supports lastN parameter", () => { + appendLog("last.jsonl", { n: 1 }); + appendLog("last.jsonl", { n: 2 }); + appendLog("last.jsonl", { n: 3 }); + const last2 = readLog("last.jsonl", 2); + expect(last2).toHaveLength(2); + expect(last2[0]).toEqual({ n: 2 }); + expect(last2[1]).toEqual({ n: 3 }); + }); + + it("skips corrupt lines gracefully", () => { + ensureDir(); + writeFileSync( + join(STATE_DIR, "mixed.jsonl"), + '{"ok":true}\nnot json\n{"also":"ok"}\n' + ); + const entries = readLog("mixed.jsonl"); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ ok: true }); + expect(entries[1]).toEqual({ also: "ok" }); + }); + }); + + describe("now", () => { + it("returns a valid ISO string", () => { + const ts = now(); + expect(new Date(ts).toISOString()).toBe(ts); + }); + }); +});