Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions tests/lib/files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// =============================================================================
// Tests for src/lib/files.ts — file reading and workspace doc scanning
// =============================================================================

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

let tempDir: string;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "preflight-files-test-"));
});

afterEach(() => {
vi.restoreAllMocks();
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});

// Since PROJECT_DIR is a module-level constant, we test the functions directly
// by reimplementing the core logic. This tests the algorithms, not the binding.

describe("readIfExists logic", () => {
function readIfExists(basePath: string, relPath: string, maxLines = 50): string | null {
const full = join(basePath, relPath);
if (!existsSync(full)) return null;
try {
const buf = readFileSync(full);
if (buf.subarray(0, 8192).includes(0)) return null;
const lines = buf.toString("utf-8").split("\n");
return lines.slice(0, maxLines).join("\n");
} catch {
return null;
}
}

it("returns null for missing file", () => {
expect(readIfExists(tempDir, "nope.md")).toBeNull();
});

it("reads existing text file", () => {
writeFileSync(join(tempDir, "test.md"), "hello world\nsecond line");
expect(readIfExists(tempDir, "test.md")).toBe("hello world\nsecond line");
});

it("truncates to maxLines", () => {
const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`);
writeFileSync(join(tempDir, "long.md"), lines.join("\n"));
const result = readIfExists(tempDir, "long.md", 5);
expect(result!.split("\n")).toHaveLength(5);
expect(result).toBe("line 0\nline 1\nline 2\nline 3\nline 4");
});

it("rejects binary files (null bytes in first 8KB)", () => {
const buf = Buffer.from([0x48, 0x65, 0x6c, 0x00, 0x6f]);
writeFileSync(join(tempDir, "binary.dat"), buf);
expect(readIfExists(tempDir, "binary.dat")).toBeNull();
});

it("accepts files with null bytes after 8KB", () => {
const buf = Buffer.alloc(9000, 0x41); // 9000 'A's
buf[8500] = 0; // null byte after 8KB
writeFileSync(join(tempDir, "late-null.dat"), buf);
// Should pass since null is after the 8KB check window
expect(readIfExists(tempDir, "late-null.dat")).not.toBeNull();
});
});

describe("findWorkspaceDocs logic", () => {
const MAX_SCAN_DEPTH = 10;

// Extracted scanning logic for testability
function findWorkspaceDocs(projectDir: string, opts?: { metadataOnly?: boolean }) {
const { statSync, readdirSync } = require("fs");
const docs: Record<string, any> = {};
const claudeDir = join(projectDir, ".claude");
if (!existsSync(claudeDir)) return docs;

const scanDir = (dir: string, prefix = "", depth = 0): void => {
if (depth > MAX_SCAN_DEPTH) return;
try {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory() && !entry.startsWith(".") && !entry.includes("node_modules") && entry !== "preflight-state") {
scanDir(full, prefix ? `${prefix}/${entry}` : entry, depth + 1);
} else if (entry.endsWith(".md") && stat.size < 50000) {
docs[prefix ? `${prefix}/${entry}` : entry] = {
content: opts?.metadataOnly ? "" : readFileSync(full, "utf-8").split("\n").slice(0, 40).join("\n"),
mtime: stat.mtime,
size: stat.size,
};
}
}
} catch {}
};

scanDir(claudeDir);
return docs;
}

it("returns empty when .claude/ does not exist", () => {
expect(findWorkspaceDocs(tempDir)).toEqual({});
});

it("finds markdown files in .claude/", () => {
const claudeDir = join(tempDir, ".claude");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, "notes.md"), "# Notes\nSome content");
writeFileSync(join(claudeDir, "other.txt"), "not markdown");

const docs = findWorkspaceDocs(tempDir);
expect(Object.keys(docs)).toEqual(["notes.md"]);
expect(docs["notes.md"].content).toContain("# Notes");
});

it("scans nested directories", () => {
const subDir = join(tempDir, ".claude", "sub");
mkdirSync(subDir, { recursive: true });
writeFileSync(join(subDir, "deep.md"), "deep doc");

const docs = findWorkspaceDocs(tempDir);
expect(docs["sub/deep.md"]).toBeDefined();
expect(docs["sub/deep.md"].content).toBe("deep doc");
});

it("metadataOnly skips content", () => {
const claudeDir = join(tempDir, ".claude");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, "doc.md"), "content here");

const docs = findWorkspaceDocs(tempDir, { metadataOnly: true });
expect(docs["doc.md"].content).toBe("");
expect(docs["doc.md"].size).toBeGreaterThan(0);
});

it("skips preflight-state directory", () => {
const stateDir = join(tempDir, ".claude", "preflight-state");
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, "internal.md"), "should be skipped");

const docs = findWorkspaceDocs(tempDir);
expect(Object.keys(docs)).toEqual([]);
});

it("skips dot-directories", () => {
const hiddenDir = join(tempDir, ".claude", ".hidden");
mkdirSync(hiddenDir, { recursive: true });
writeFileSync(join(hiddenDir, "secret.md"), "hidden");

const docs = findWorkspaceDocs(tempDir);
expect(Object.keys(docs)).toEqual([]);
});

it("skips files over 50KB", () => {
const claudeDir = join(tempDir, ".claude");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, "huge.md"), "x".repeat(60000));

const docs = findWorkspaceDocs(tempDir);
expect(Object.keys(docs)).toEqual([]);
});
});
154 changes: 154 additions & 0 deletions tests/lib/state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// =============================================================================
// Tests for src/lib/state.ts — state persistence and JSONL logging
// =============================================================================

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, statSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { rmSync } from "fs";

// We need to mock PROJECT_DIR before importing state functions
let tempDir: string;
let stateDir: string;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "preflight-state-test-"));
stateDir = join(tempDir, ".claude", "preflight-state");
// Mock the STATE_DIR and PROJECT_DIR
vi.doMock("../../src/lib/files.js", () => ({
PROJECT_DIR: tempDir,
}));
});

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
try {
rmSync(tempDir, { recursive: true, force: true });
} catch { /* cleanup best-effort */ }
});

async function getState() {
return await import("../../src/lib/state.js");
}

describe("loadState", () => {
it("returns empty object when file does not exist", async () => {
const { loadState } = await getState();
expect(loadState("nonexistent")).toEqual({});
});

it("loads valid JSON state file", async () => {
const { loadState } = await getState();
mkdirSync(stateDir, { recursive: true });
const data = { count: 42, items: ["a", "b"] };
writeFileSync(join(stateDir, "test.json"), JSON.stringify(data));
expect(loadState("test")).toEqual(data);
});

it("returns empty object for corrupt JSON", async () => {
const { loadState } = await getState();
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, "corrupt.json"), "{not valid json");
expect(loadState("corrupt")).toEqual({});
});
});

describe("saveState", () => {
it("creates state dir and writes JSON file", async () => {
const { saveState, loadState } = await getState();
const data = { key: "value", nested: { x: 1 } };
saveState("mystate", data);
expect(existsSync(join(stateDir, "mystate.json"))).toBe(true);
expect(loadState("mystate")).toEqual(data);
});

it("overwrites existing state file", async () => {
const { saveState, loadState } = await getState();
saveState("overwrite", { version: 1 });
saveState("overwrite", { version: 2 });
expect(loadState("overwrite")).toEqual({ version: 2 });
});
});

describe("appendLog / readLog", () => {
it("appends JSONL entries and reads them back", async () => {
const { appendLog, readLog } = await getState();
appendLog("test.jsonl", { action: "first", ts: 1 });
appendLog("test.jsonl", { action: "second", ts: 2 });
appendLog("test.jsonl", { action: "third", ts: 3 });

const entries = readLog("test.jsonl");
expect(entries).toHaveLength(3);
expect(entries[0]).toEqual({ action: "first", ts: 1 });
expect(entries[2]).toEqual({ action: "third", ts: 3 });
});

it("readLog returns empty array for missing file", async () => {
const { readLog } = await getState();
expect(readLog("missing.jsonl")).toEqual([]);
});

it("readLog with lastN returns only last N entries", async () => {
const { appendLog, readLog } = await getState();
for (let i = 0; i < 10; i++) {
appendLog("big.jsonl", { i });
}
const last3 = readLog("big.jsonl", 3);
expect(last3).toHaveLength(3);
expect(last3[0]).toEqual({ i: 7 });
expect(last3[2]).toEqual({ i: 9 });
});

it("readLog skips corrupt lines gracefully", async () => {
const { readLog } = await getState();
mkdirSync(stateDir, { recursive: true });
const content = [
JSON.stringify({ good: 1 }),
"not json at all",
JSON.stringify({ good: 2 }),
].join("\n");
writeFileSync(join(stateDir, "mixed.jsonl"), content);

const entries = readLog("mixed.jsonl");
expect(entries).toHaveLength(2);
expect(entries[0]).toEqual({ good: 1 });
expect(entries[1]).toEqual({ good: 2 });
});

it("readLog handles empty file", async () => {
const { readLog } = await getState();
mkdirSync(stateDir, { recursive: true });
writeFileSync(join(stateDir, "empty.jsonl"), "");
expect(readLog("empty.jsonl")).toEqual([]);
});

it("rotates log file when exceeding 5MB", async () => {
const { appendLog } = await getState();
mkdirSync(stateDir, { recursive: true });

// Create a log file > 5MB
const logPath = join(stateDir, "big.jsonl");
const bigData = "x".repeat(6 * 1024 * 1024); // 6MB
writeFileSync(logPath, bigData);

// Appending should trigger rotation
appendLog("big.jsonl", { after: "rotation" });

// Old file should exist as backup
expect(existsSync(logPath + ".old")).toBe(true);
// New file should contain only the new entry
const newContent = readFileSync(logPath, "utf-8").trim();
expect(JSON.parse(newContent)).toEqual({ after: "rotation" });
});
});

describe("now", () => {
it("returns a valid ISO timestamp", async () => {
const { now } = await getState();
const ts = now();
expect(() => new Date(ts)).not.toThrow();
expect(new Date(ts).toISOString()).toBe(ts);
});
});
Loading