convex
Version:
Client for the Convex Cloud
987 lines (842 loc) • 30.1 kB
text/typescript
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { logMessage } from "../../../bundler/log.js";
import {
attemptReadAiState,
readAiStateOrDefault,
writeAiState,
} from "./state.js";
import {
downloadGuidelines,
fetchAgentSkillsCatalog,
fetchAgentSkillsSha,
getVersion,
} from "../versionApi.js";
import fs from "fs";
import os from "os";
import path from "path";
import {
checkAiFilesStalenessAndLog,
installAiFiles,
removeAiFiles,
} from "./index.js";
import { statusAiFiles } from "./status.js";
import {
AGENTS_MD_START_MARKER,
AGENTS_MD_END_MARKER,
} from "../../codegen_templates/agentsmd.js";
import {
CLAUDE_MD_START_MARKER,
CLAUDE_MD_END_MARKER,
} from "../../codegen_templates/claudemd.js";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
vi.mock("../../../bundler/log.js", () => ({
logMessage: vi.fn(),
}));
vi.mock("./state.js", () => ({
attemptReadAiState: vi.fn(),
readAiStateOrDefault: vi.fn(),
writeAiState: vi.fn(),
hasAiState: vi.fn().mockResolvedValue(false),
}));
vi.mock("../versionApi.js", () => ({
downloadGuidelines: vi.fn(),
fetchAgentSkillsCatalog: vi.fn(),
fetchAgentSkillsSha: vi.fn(),
getVersion: vi.fn(),
}));
vi.mock("child_process", () => ({
default: {
spawn: vi.fn(() => {
const emitter = { on: vi.fn() };
emitter.on.mockImplementation(
(event: string, cb: (arg: number) => void) => {
if (event === "close") cb(0);
},
);
return emitter;
}),
},
}));
const mockLogMessage = vi.mocked(logMessage);
const mockAttemptReadAiState = vi.mocked(attemptReadAiState);
const mockReadAiStateOrDefault = vi.mocked(readAiStateOrDefault);
const mockWriteAiState = vi.mocked(writeAiState);
const mockDownloadGuidelines = vi.mocked(downloadGuidelines);
const mockFetchAgentSkillsCatalog = vi.mocked(fetchAgentSkillsCatalog);
const mockFetchAgentSkillsSha = vi.mocked(fetchAgentSkillsSha);
const mockGetVersion = vi.mocked(getVersion);
/** Minimal valid state used across tests; includes all required fields. */
const baseState = {
guidelinesHash: null,
agentsMdSectionHash: null,
claudeMdHash: null,
agentSkillsSha: null,
};
beforeEach(() => {
mockFetchAgentSkillsCatalog.mockResolvedValue({
kind: "ok",
data: {
latestRepoSha: "canonical-sha-abc123",
skills: [
{
skillName: "migration-helper",
status: { kind: "active" },
hash: "hash-a",
lastSeenRepoSha: "canonical-sha-abc123",
lastSeenAt: 123,
},
{
skillName: "schema-builder",
status: { kind: "active" },
hash: "hash-b",
lastSeenRepoSha: "canonical-sha-abc123",
lastSeenAt: 123,
},
],
},
});
});
// ---------------------------------------------------------------------------
// checkAiFilesStaleness
// ---------------------------------------------------------------------------
describe("checkAiFilesStaleness", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.resetAllMocks();
});
const dummyProjectDir = "/tmp/test-project";
const dummyConvexDir = "/tmp/test-project/convex";
test("logs install nudge when no state file exists, even with null canonical values", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: null,
canonicalAgentSkillsSha: null,
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockAttemptReadAiState).toHaveBeenCalled();
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files install"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("not installed"),
);
});
test("does nothing when both canonical values are null but state exists (version server unavailable)", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, guidelinesHash: "some-hash" },
});
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: null,
canonicalAgentSkillsSha: null,
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).not.toHaveBeenCalled();
});
test("logs install nudge when no state file exists, even if canonical hashes are available", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "canonical-hash",
canonicalAgentSkillsSha: null,
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files install"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files disable"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("not installed"),
);
});
test("does nothing when config has enabled=false (user opted out)", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "canonical-hash",
canonicalAgentSkillsSha: null,
aiFilesConfig: { enabled: false },
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).not.toHaveBeenCalled();
});
test("does nothing when stored guidelines hash matches canonical", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, guidelinesHash: "same-hash" },
});
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "same-hash",
canonicalAgentSkillsSha: null,
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).not.toHaveBeenCalled();
});
test("logs nag message when guidelines hash is stale", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, guidelinesHash: "old-hash" },
});
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "new-canonical-hash",
canonicalAgentSkillsSha: null,
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files update"),
);
});
test("logs nag message when agent skills SHA is stale", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: {
...baseState,
guidelinesHash: "current-hash",
agentSkillsSha: "old-sha",
},
});
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "current-hash",
canonicalAgentSkillsSha: "new-sha",
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files update"),
);
});
test("does nothing when stored guidelinesHash is null (never written)", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "some-hash",
canonicalAgentSkillsSha: "some-sha",
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// installAiFiles
// ---------------------------------------------------------------------------
describe("installAiFiles", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAgentSkillsSha.mockResolvedValue("canonical-sha-abc123");
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: "canonical-sha-abc123",
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
});
afterEach(() => vi.resetAllMocks());
test("runs full init and installs skills when no state exists", async () => {
mockReadAiStateOrDefault.mockResolvedValue(baseState);
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
fs.mkdirSync(convexDir, { recursive: true });
fs.writeFileSync(path.join(convexDir, "schema.ts"), "");
mockDownloadGuidelines.mockResolvedValue("guidelines content");
await installAiFiles({ projectDir: tmpDir, convexDir });
expect(
fs.existsSync(
path.join(convexDir, "_generated", "ai", "guidelines.md"),
),
).toBe(true);
const { default: cp } = await import("child_process");
const spawnCalls = vi.mocked(cp.spawn).mock.calls;
const addCall = spawnCalls.find(
(c) => Array.isArray(c[1]) && c[1].includes("add"),
);
expect(addCall).toBeDefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("logs warning when guidelines download is unavailable", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
mockReadAiStateOrDefault.mockResolvedValue(baseState);
mockDownloadGuidelines.mockResolvedValue(null);
await installAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Could not download Convex AI guidelines"),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("skips skills install when server kill switch is enabled", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
mockReadAiStateOrDefault.mockResolvedValue(baseState);
mockDownloadGuidelines.mockResolvedValue("guidelines content");
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: null,
disableSkillsCli: true,
disableSkillsCliMessage: null,
},
});
await installAiFiles({ projectDir: tmpDir, convexDir });
const { default: cp } = await import("child_process");
const spawnCalls = vi.mocked(cp.spawn).mock.calls;
const addCall = spawnCalls.find(
(c) => Array.isArray(c[1]) && c[1].includes("add"),
);
expect(addCall).toBeUndefined();
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Agent skills are temporarily disabled."),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("logs the server-provided message when skills are disabled", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
mockReadAiStateOrDefault.mockResolvedValue(baseState);
mockDownloadGuidelines.mockResolvedValue("guidelines content");
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: null,
disableSkillsCli: true,
disableSkillsCliMessage:
"Skills are down for maintenance until 3pm PT.",
},
});
await installAiFiles({ projectDir: tmpDir, convexDir });
const { default: cp } = await import("child_process");
const spawnCalls = vi.mocked(cp.spawn).mock.calls;
const addCall = spawnCalls.find(
(c) => Array.isArray(c[1]) && c[1].includes("add"),
);
expect(addCall).toBeUndefined();
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining(
"Skills are down for maintenance until 3pm PT.",
),
);
expect(mockLogMessage).not.toHaveBeenCalledWith(
expect.stringContaining("Agent skills are temporarily disabled."),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// removeAiFiles
// ---------------------------------------------------------------------------
describe("removeAiFiles", () => {
let tmpDir: string;
let convexDir: string;
beforeEach(() => {
vi.clearAllMocks();
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: "canonical-sha-abc123",
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
convexDir = path.join(tmpDir, "convex");
fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
recursive: true,
});
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.resetAllMocks();
});
test("logs removed when canonical skills remove succeeds without local artifacts", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
fs.rmSync(path.join(convexDir, "_generated", "ai"), { recursive: true });
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Convex AI files removed"),
);
});
test("removes ai dir even when no state file exists", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Convex AI files removed"),
);
expect(fs.existsSync(path.join(convexDir, "_generated", "ai"))).toBe(false);
});
test("deletes AGENTS.md if stripping the Convex section leaves it empty", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
const agentsMdContent = `${AGENTS_MD_START_MARKER}\n## Convex\nGuidelines.\n${AGENTS_MD_END_MARKER}\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), agentsMdContent, "utf8");
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(false);
});
test("strips Convex section from AGENTS.md", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
const agentsMdContent =
`# My project\n\n` +
`${AGENTS_MD_START_MARKER}\n## Convex\nGuidelines.\n${AGENTS_MD_END_MARKER}\n\n` +
`# After\n`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), agentsMdContent, "utf8");
await removeAiFiles({ projectDir: tmpDir, convexDir });
const result = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8");
expect(result).toContain("# My project");
expect(result).toContain("# After");
expect(result).not.toContain(AGENTS_MD_START_MARKER);
expect(result).not.toContain("## Convex");
});
test("deletes CLAUDE.md when it only contains the managed section", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
const managed = `${CLAUDE_MD_START_MARKER}\n## Convex\nRead guidelines.\n${CLAUDE_MD_END_MARKER}\n`;
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), managed, "utf8");
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(false);
});
test("leaves CLAUDE.md when it has no managed markers", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "User content\n", "utf8");
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true);
});
test("strips only the Convex section from CLAUDE.md", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
const managed = `${CLAUDE_MD_START_MARKER}\n## Convex\nRead guidelines.\n${CLAUDE_MD_END_MARKER}`;
fs.writeFileSync(
path.join(tmpDir, "CLAUDE.md"),
`# User header\n\n${managed}\n\n# User footer\n`,
"utf8",
);
await removeAiFiles({ projectDir: tmpDir, convexDir });
const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8");
expect(content).toContain("# User header");
expect(content).toContain("# User footer");
expect(content).not.toContain(CLAUDE_MD_START_MARKER);
expect(content).not.toContain("Read guidelines.");
});
test("leaves CLAUDE.md alone when it has no managed markers (legacy)", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, claudeMdHash: "some-hash" },
});
fs.writeFileSync(
path.join(tmpDir, "CLAUDE.md"),
"My custom CLAUDE.md content\n",
"utf8",
);
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true);
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8")).toBe(
"My custom CLAUDE.md content\n",
);
});
test("calls skills remove for each canonical catalog skill name", async () => {
const result = await removeAiFiles({ projectDir: tmpDir, convexDir });
const { default: cp } = await import("child_process");
const spawnCalls = vi.mocked(cp.spawn).mock.calls;
const removeCall = spawnCalls.find(
(c) => Array.isArray(c[1]) && c[1].includes("remove"),
);
expect(result).toEqual({ kind: "success" });
expect(removeCall).toBeDefined();
expect(removeCall![1]).toContain("migration-helper");
expect(removeCall![1]).toContain("schema-builder");
});
test("returns an error when the canonical catalog cannot be fetched", async () => {
mockFetchAgentSkillsCatalog.mockResolvedValueOnce({ kind: "error" });
const result = await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(result).toEqual({
kind: "error",
message:
"Could not fetch canonical agent skills from version.convex.dev. Aborting `convex ai-files remove`.",
});
});
test("deletes skills-lock.json if it becomes empty after removing our skills", async () => {
const lockfileContent = {
version: 1,
skills: {
"migration-helper": { source: "test" },
},
};
fs.writeFileSync(
path.join(tmpDir, "skills-lock.json"),
JSON.stringify(lockfileContent),
"utf8",
);
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "skills-lock.json"))).toBe(false);
});
test("preserves skills-lock.json if it contains other skills", async () => {
const lockfileContent = {
version: 1,
skills: {
"migration-helper": { source: "test" },
"some-other-skill": { source: "other" },
},
};
fs.writeFileSync(
path.join(tmpDir, "skills-lock.json"),
JSON.stringify(lockfileContent),
"utf8",
);
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(path.join(tmpDir, "skills-lock.json"))).toBe(true);
});
test("skips skills remove when server kill switch is enabled", async () => {
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: null,
disableSkillsCli: true,
disableSkillsCliMessage: null,
},
});
await removeAiFiles({ projectDir: tmpDir, convexDir });
const { default: cp } = await import("child_process");
const spawnCalls = vi.mocked(cp.spawn).mock.calls;
const removeCall = spawnCalls.find(
(c) => Array.isArray(c[1]) && c[1].includes("remove"),
);
expect(removeCall).toBeUndefined();
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Agent skills are temporarily disabled."),
);
});
test("does NOT write state after plain remove", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(mockWriteAiState).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// statusAiFiles
// ---------------------------------------------------------------------------
describe("statusAiFiles", () => {
const dummyProjectDir = "/tmp/test-project";
const dummyConvexDir = "/tmp/test-project/convex";
beforeEach(() => {
vi.clearAllMocks();
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: "canonical-guidelines-hash",
agentSkillsSha: "canonical-skills-sha",
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
});
afterEach(() => vi.resetAllMocks());
test("reports not installed when state is missing", async () => {
mockAttemptReadAiState.mockResolvedValue({ kind: "no-file" });
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("not installed"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files install"),
);
});
test("reports disabled when config has enabled=false", async () => {
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
aiFilesConfig: { enabled: false },
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("disabled"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files enable"),
);
});
test("reports enabled when state exists and messages are not disabled", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("enabled"),
);
});
test("reports guidelines as up to date when hash matches canonical", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
const { hashSha256 } = await import("../utils/hash.js");
const content = "guidelines content";
const hash = hashSha256(content);
fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
recursive: true,
});
fs.writeFileSync(
path.join(convexDir, "_generated", "ai", "guidelines.md"),
content,
"utf8",
);
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, guidelinesHash: hash },
});
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: hash,
agentSkillsSha: null,
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
await statusAiFiles({ projectDir: tmpDir, convexDir });
const calls = mockLogMessage.mock.calls.map((c) => c[0]);
expect(calls.some((m) => /guidelines\.md.*up to date/.test(m))).toBe(
true,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("reports guidelines as out of date when hash differs from canonical", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
const { hashSha256 } = await import("../utils/hash.js");
const content = "old guidelines content";
fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
recursive: true,
});
fs.writeFileSync(
path.join(convexDir, "_generated", "ai", "guidelines.md"),
content,
"utf8",
);
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: { ...baseState, guidelinesHash: hashSha256(content) },
});
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: "new-canonical-hash",
agentSkillsSha: null,
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
await statusAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("out of date"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files update"),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("reports guidelines as locally modified when disk hash differs from stored", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
const { hashSha256 } = await import("../utils/hash.js");
fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
recursive: true,
});
fs.writeFileSync(
path.join(convexDir, "_generated", "ai", "guidelines.md"),
"user-modified content",
"utf8",
);
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: {
...baseState,
guidelinesHash: hashSha256("original content"),
},
});
await statusAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("modified locally"),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("reports guidelines as missing when guidelines.md is empty", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
fs.mkdirSync(path.join(convexDir, "_generated", "ai"), {
recursive: true,
});
fs.writeFileSync(
path.join(convexDir, "_generated", "ai", "guidelines.md"),
"",
"utf8",
);
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
await statusAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("guidelines.md: not on disk"),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("reports agent skills as out of date when SHA differs from canonical", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: {
...baseState,
agentSkillsSha: "old-sha",
},
});
mockGetVersion.mockResolvedValue({
kind: "ok",
data: {
message: null,
guidelinesHash: null,
agentSkillsSha: "new-sha",
disableSkillsCli: false,
disableSkillsCliMessage: null,
},
});
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("out of date"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("npx convex ai-files update"),
);
});
test("skips staleness check when network is unavailable", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: {
...baseState,
guidelinesHash: "old-hash",
agentSkillsSha: "old-sha",
},
});
mockGetVersion.mockResolvedValue({ kind: "error" });
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
const calls = mockLogMessage.mock.calls.map((c) => c[0]);
expect(calls.some((m) => /out of date/.test(m))).toBe(false);
});
test("reports skills as installed when present", async () => {
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: {
...baseState,
agentSkillsSha: "canonical-skills-sha",
},
});
await statusAiFiles({
projectDir: dummyProjectDir,
convexDir: dummyConvexDir,
});
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("Agent skills: installed"),
);
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("up to date"),
);
});
test("reports CLAUDE.md section as missing when file exists without markers", async () => {
const tmpDir = fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
const convexDir = path.join(tmpDir, "convex");
try {
fs.writeFileSync(
path.join(tmpDir, "CLAUDE.md"),
"User content\n",
"utf8",
);
mockAttemptReadAiState.mockResolvedValue({
kind: "ok",
state: baseState,
});
await statusAiFiles({ projectDir: tmpDir, convexDir });
expect(mockLogMessage).toHaveBeenCalledWith(
expect.stringContaining("CLAUDE.md: no Convex section present"),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});