convex
Version:
Client for the Convex Cloud
306 lines (250 loc) • 8.52 kB
text/typescript
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import * as Sentry from "@sentry/node";
import { promises as fs } from "fs";
import {
aiFilesStateSchema,
attemptReadAiState,
hasAiState,
readAiStateOrDefault,
writeAiState,
} from "./state.js";
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
vi.mock("fs", () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
},
}));
const mockFs = vi.mocked(fs);
const mockCaptureException = vi.mocked(Sentry.captureException);
const dummyConvexDir = "/tmp/test-project/convex";
describe("aiFilesStateSchema", () => {
test("accepts a fully populated valid state object", () => {
const result = aiFilesStateSchema.safeParse({
guidelinesHash: "abc123",
agentsMdSectionHash: "def456",
claudeMdHash: "ghi789",
agentSkillsSha: "deadbeef",
});
expect(result.success).toBe(true);
});
test("accepts null hashes", () => {
const result = aiFilesStateSchema.safeParse({
guidelinesHash: null,
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
});
expect(result.success).toBe(true);
});
test("strips legacy local skill tracking fields", () => {
const result = aiFilesStateSchema.safeParse({
version: 2,
guidelinesHash: "abc",
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
installedSkillNames: ["convex-migrations"],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual({
guidelinesHash: "abc",
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
});
}
});
test("rejects a number where a string hash is expected", () => {
const result = aiFilesStateSchema.safeParse({
guidelinesHash: 123,
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
});
expect(result.success).toBe(false);
});
test("rejects missing required fields", () => {
const result = aiFilesStateSchema.safeParse({
guidelinesHash: "abc",
});
expect(result.success).toBe(false);
});
});
describe("attemptReadAiState", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.resetAllMocks());
test("returns no-file when the state file does not exist", async () => {
mockFs.readFile.mockRejectedValue(
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
);
const result = await attemptReadAiState(dummyConvexDir);
expect(result).toEqual({ kind: "no-file" });
expect(mockCaptureException).not.toHaveBeenCalled();
});
test("returns no-file when the state file is empty", async () => {
mockFs.readFile.mockResolvedValueOnce("");
const result = await attemptReadAiState(dummyConvexDir);
expect(result).toEqual({ kind: "no-file" });
expect(mockCaptureException).not.toHaveBeenCalled();
});
test("throws when reading the state file fails for reasons other than not found", async () => {
const error = Object.assign(new Error("EACCES"), { code: "EACCES" });
mockFs.readFile.mockRejectedValueOnce(error);
await expect(attemptReadAiState(dummyConvexDir)).rejects.toBe(error);
expect(mockCaptureException).not.toHaveBeenCalled();
});
test("returns ok with parsed state", async () => {
mockFs.readFile.mockResolvedValueOnce(
JSON.stringify({
version: 1,
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: null,
}),
);
const result = await attemptReadAiState(dummyConvexDir);
expect(result).toEqual({
kind: "ok",
state: {
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: null,
},
});
});
test("strips legacy tracking fields when reading older state files", async () => {
mockFs.readFile.mockResolvedValueOnce(
JSON.stringify({
version: 2,
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: "skills-sha",
installedSkillNames: ["convex-migrations"],
}),
);
const result = await attemptReadAiState(dummyConvexDir);
expect(result).toEqual({
kind: "ok",
state: {
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: "skills-sha",
},
});
});
test("returns parse-error and captures exception when JSON is invalid", async () => {
mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");
const result = await attemptReadAiState(dummyConvexDir);
expect(result.kind).toBe("parse-error");
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
});
test("returns parse-error and captures exception when schema validation fails", async () => {
mockFs.readFile.mockResolvedValueOnce(
JSON.stringify({
guidelinesHash: 99,
agentsMdSectionHash: null,
}),
);
const result = await attemptReadAiState(dummyConvexDir);
expect(result.kind).toBe("parse-error");
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
});
});
describe("readAiStateOrDefault", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.resetAllMocks());
test("returns default state when no state file exists", async () => {
mockFs.readFile.mockRejectedValue(
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
);
const result = await readAiStateOrDefault(dummyConvexDir);
expect(result).toEqual({
guidelinesHash: null,
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
});
});
test("returns default state on parse error", async () => {
mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");
const result = await readAiStateOrDefault(dummyConvexDir);
expect(result).toEqual({
guidelinesHash: null,
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
});
expect(mockCaptureException).toHaveBeenCalled();
});
test("returns parsed state when state file exists", async () => {
const stored = {
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: "deadbeef",
};
mockFs.readFile.mockResolvedValueOnce(JSON.stringify(stored));
const result = await readAiStateOrDefault(dummyConvexDir);
expect(result).toEqual(stored);
});
});
describe("hasAiState", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.resetAllMocks());
test("returns false when state file does not exist", async () => {
mockFs.readFile.mockRejectedValue(
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
);
const result = await hasAiState(dummyConvexDir);
expect(result).toBe(false);
expect(mockCaptureException).not.toHaveBeenCalled();
});
test("returns true when a valid state file exists", async () => {
mockFs.readFile.mockResolvedValueOnce(
JSON.stringify({
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: null,
}),
);
const result = await hasAiState(dummyConvexDir);
expect(result).toBe(true);
});
test("returns false and captures exception when the state file is invalid", async () => {
mockFs.readFile.mockResolvedValueOnce("not valid json {{{}");
const result = await hasAiState(dummyConvexDir);
expect(result).toBe(false);
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
});
});
describe("writeAiState", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.resetAllMocks());
test("writes state file", async () => {
mockFs.writeFile.mockResolvedValue(undefined);
const state = {
guidelinesHash: "abc",
agentsMdSectionHash: "def",
claudeMdHash: null,
agentSkillsSha: null,
};
await writeAiState({ state, convexDir: dummyConvexDir });
expect(mockFs.writeFile).toHaveBeenCalledTimes(1);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("ai-files.state.json"),
JSON.stringify(state, null, 2) + "\n",
"utf8",
);
});
});