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 }); } }); });