UNPKG

convex

Version:

Client for the Convex Cloud

573 lines (474 loc) 20.9 kB
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import fs from "fs"; import os from "os"; import path from "path"; import { checkAiFilesStaleness, safelyAttemptToDisableAiFiles, enableAiFiles, removeAiFiles, installAiFiles, } from "./index.js"; import { statusAiFiles } from "./status.js"; import { logMessage } from "../../../bundler/log.js"; import { AGENTS_MD_START_MARKER } from "../../codegen_templates/agentsmd.js"; import { CLAUDE_MD_START_MARKER } from "../../codegen_templates/claudemd.js"; vi.mock("../../../bundler/log.js", () => ({ logMessage: vi.fn(), })); vi.mock("../versionApi.js", () => ({ downloadGuidelines: vi.fn(async () => "integration guidelines content"), fetchAgentSkillsSha: vi.fn(async () => "integration-sha"), getVersion: vi.fn(async () => ({ kind: "ok", data: { message: null, guidelinesHash: "integration-guidelines-hash", agentSkillsSha: "integration-agent-skills-sha", disableSkillsCli: false, }, })), })); vi.mock("child_process", () => ({ default: { spawn: vi.fn(() => { return { stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, on: (event: string, cb: (code: number) => void) => { if (event === "close") cb(0); }, }; }), }, })); // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function readJson(filePath: string): any { return JSON.parse(fs.readFileSync(filePath, "utf8")); } function makeTmpDir(): string { return fs.mkdtempSync(`${os.tmpdir()}${path.sep}`); } // --------------------------------------------------------------------------- // Default convex/ directory (no override) // --------------------------------------------------------------------------- describe("ai-files integration with default convex/ directory", () => { let tmpDir: string; let convexDir: string; const aiDir = () => path.join(convexDir, "_generated", "ai"); const guidelinesPath = () => path.join(aiDir(), "guidelines.md"); const statePath = () => path.join(aiDir(), "ai-files.state.json"); const projectConfigPath = () => path.join(tmpDir, "convex.json"); beforeEach(() => { tmpDir = makeTmpDir(); convexDir = path.join(tmpDir, "convex"); fs.mkdirSync(convexDir, { recursive: true }); fs.writeFileSync(path.join(convexDir, "schema.ts"), ""); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); vi.clearAllMocks(); }); test("install creates guidelines, state, AGENTS.md, and CLAUDE.md", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(guidelinesPath())).toBe(true); expect(fs.existsSync(statePath())).toBe(true); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true); expect(fs.readFileSync(guidelinesPath(), "utf8")).toBe( "integration guidelines content", ); }); test("preserves existing AGENTS.md content and injects managed section", async () => { fs.writeFileSync( path.join(tmpDir, "AGENTS.md"), "# My Project\n\nImportant team guidelines here.\n", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8"); expect(content).toContain("# My Project"); expect(content).toContain("Important team guidelines here."); expect(content).toContain("convex-ai-start"); expect(content).toMatch( /convex[\\/]+_generated[\\/]+ai[\\/]+guidelines\.md/, ); }); test("does not overwrite pre-existing CLAUDE.md", async () => { fs.writeFileSync( path.join(tmpDir, "CLAUDE.md"), "My custom CLAUDE.md content\n", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8"); expect(content).toContain("My custom CLAUDE.md content"); }); test("second update is idempotent", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); const firstGuidelines = fs.readFileSync(guidelinesPath(), "utf8"); const firstState = fs.readFileSync(statePath(), "utf8"); await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.readFileSync(guidelinesPath(), "utf8")).toBe(firstGuidelines); expect(fs.readFileSync(statePath(), "utf8")).toBe(firstState); }); test("removes legacy .cursor/rules/convex_rules.mdc", async () => { fs.mkdirSync(path.join(tmpDir, ".cursor", "rules"), { recursive: true }); fs.writeFileSync( path.join(tmpDir, ".cursor", "rules", "convex_rules.mdc"), "legacy", ); await installAiFiles({ projectDir: tmpDir, convexDir }); expect( fs.existsSync(path.join(tmpDir, ".cursor", "rules", "convex_rules.mdc")), ).toBe(false); }); test("staleness check nags when stored hash is stale", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); const state = readJson(statePath()); state.guidelinesHash = "deliberately-stale-hash"; fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n"); await checkAiFilesStaleness({ canonicalGuidelinesHash: "canonical-hash", canonicalAgentSkillsSha: null, projectDir: tmpDir, convexDir, }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("out of date"), ); }); test("staleness check is silent when disabled in convex.json", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); const state = readJson(statePath()); state.guidelinesHash = "deliberately-stale-hash"; fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n"); vi.mocked(logMessage).mockClear(); await checkAiFilesStaleness({ canonicalGuidelinesHash: "canonical-hash", canonicalAgentSkillsSha: null, projectDir: tmpDir, convexDir, }); const calls = vi.mocked(logMessage).mock.calls.map((c) => c[0]); expect( calls.find((m) => typeof m === "string" && m.includes("out of date")), ).toBeUndefined(); }); test("disable keeps files but sets convex.json preference", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(false); expect(fs.existsSync(guidelinesPath())).toBe(true); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true); }); test("disable before install writes only convex.json and no AI state file", async () => { await safelyAttemptToDisableAiFiles(tmpDir); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(false); expect(fs.existsSync(statePath())).toBe(false); expect(fs.existsSync(guidelinesPath())).toBe(false); }); test("remove deletes ai directory and AGENTS.md managed section", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await removeAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(aiDir())).toBe(false); }); test("status reports not installed after remove", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await removeAiFiles({ projectDir: tmpDir, convexDir }); vi.mocked(logMessage).mockClear(); await statusAiFiles({ projectDir: tmpDir, convexDir }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("not installed"), ); }); test("status reports installed and enabled after install", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); vi.mocked(logMessage).mockClear(); await statusAiFiles({ projectDir: tmpDir, convexDir }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("enabled"), ); }); test("disable after CLAUDE.md user edits preserves the file", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); fs.appendFileSync(path.join(tmpDir, "CLAUDE.md"), "My custom note\n"); await safelyAttemptToDisableAiFiles(tmpDir); expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8")).toContain( "My custom note", ); }); test("update recreates missing CLAUDE.md", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); fs.rmSync(path.join(tmpDir, "CLAUDE.md"), { force: true }); await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8")).toContain( "convex/_generated/ai/guidelines.md", ); }); test("enable sets enabled flag to true", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(false); await enableAiFiles({ projectDir: tmpDir, convexDir }); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(true); }); test("full cycle: disable -> remove -> enable reinstalls everything", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); await removeAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(aiDir())).toBe(false); await enableAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(guidelinesPath())).toBe(true); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(true); }); test("remove strips managed section from AGENTS.md but preserves user content", async () => { fs.writeFileSync( path.join(tmpDir, "AGENTS.md"), "# My Project\n\nTeam guidelines here.\n", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const before = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8"); expect(before).toContain(AGENTS_MD_START_MARKER); await removeAiFiles({ projectDir: tmpDir, convexDir }); const after = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8"); expect(after).toContain("# My Project"); expect(after).toContain("Team guidelines here."); expect(after).not.toContain(AGENTS_MD_START_MARKER); }); test("remove on AGENTS.md with only Convex content deletes the file", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); await removeAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(false); }); test("remove deletes CLAUDE.md when empty after stripping managed section", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true); await removeAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(false); }); test("remove keeps CLAUDE.md with user content after stripping managed section", async () => { fs.writeFileSync( path.join(tmpDir, "CLAUDE.md"), "My project-specific Claude instructions\n", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const before = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8"); expect(before).toContain(CLAUDE_MD_START_MARKER); await removeAiFiles({ projectDir: tmpDir, convexDir }); const after = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8"); expect(after).toContain("My project-specific Claude instructions"); expect(after).not.toContain(CLAUDE_MD_START_MARKER); }); test("checkAiFilesStaleness nags when no state file exists", async () => { vi.mocked(logMessage).mockClear(); await checkAiFilesStaleness({ canonicalGuidelinesHash: null, canonicalAgentSkillsSha: null, projectDir: tmpDir, convexDir, }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("not installed"), ); }); test("checkAiFilesStaleness is silent when hashes match", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); const state = readJson(statePath()); vi.mocked(logMessage).mockClear(); await checkAiFilesStaleness({ canonicalGuidelinesHash: state.guidelinesHash, canonicalAgentSkillsSha: state.agentSkillsSha, projectDir: tmpDir, convexDir, }); const calls = vi.mocked(logMessage).mock.calls.map((c) => c[0]); expect( calls.find((m) => typeof m === "string" && m.includes("out of date")), ).toBeUndefined(); expect( calls.find((m) => typeof m === "string" && m.includes("not installed")), ).toBeUndefined(); }); test("AGENTS.md managed section is replaced not duplicated on repeated updates", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await installAiFiles({ projectDir: tmpDir, convexDir }); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8"); const markerCount = content.split(AGENTS_MD_START_MARKER).length - 1; expect(markerCount).toBe(1); }); test("status reports disabled state after disable", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); vi.mocked(logMessage).mockClear(); await statusAiFiles({ projectDir: tmpDir, convexDir }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("disabled"), ); }); }); // --------------------------------------------------------------------------- // Functions directory override (convex.json.functions = "src/convex/") // --------------------------------------------------------------------------- describe("ai-files integration with functions directory override", () => { let tmpDir: string; let convexDir: string; const aiDir = () => path.join(convexDir, "_generated", "ai"); const guidelinesPath = () => path.join(aiDir(), "guidelines.md"); const statePath = () => path.join(aiDir(), "ai-files.state.json"); const projectConfigPath = () => path.join(tmpDir, "convex.json"); beforeEach(() => { tmpDir = makeTmpDir(); convexDir = path.join(tmpDir, "src", "convex"); fs.mkdirSync(convexDir, { recursive: true }); fs.writeFileSync(path.join(convexDir, "schema.ts"), ""); fs.writeFileSync( path.join(tmpDir, "convex.json"), JSON.stringify({ functions: "src/convex/" }, null, 2) + "\n", "utf8", ); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); vi.clearAllMocks(); }); test("installs into overridden functions directory, not default convex/", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(guidelinesPath())).toBe(true); expect(fs.existsSync(statePath())).toBe(true); expect( fs.existsSync( path.join(tmpDir, "convex", "_generated", "ai", "guidelines.md"), ), ).toBe(false); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true); expect(fs.existsSync(path.join(tmpDir, "src", "AGENTS.md"))).toBe(false); }); test("preserves existing AGENTS.md content and injects managed section", async () => { fs.writeFileSync( path.join(tmpDir, "AGENTS.md"), "# Existing\n\nUser content.\n", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "AGENTS.md"), "utf8"); expect(content).toContain("# Existing"); expect(content).toContain("User content."); expect(content).toContain("convex-ai-start"); expect(content).toMatch( /src[\\/]+convex[\\/]+_generated[\\/]+ai[\\/]+guidelines\.md/, ); }); test("preserves existing CLAUDE.md content", async () => { fs.writeFileSync( path.join(tmpDir, "CLAUDE.md"), "My custom CLAUDE.md content\n", "utf8", ); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8"); expect(content).toContain("My custom CLAUDE.md content"); }); test("second update is idempotent", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); const firstGuidelines = fs.readFileSync(guidelinesPath(), "utf8"); const firstState = fs.readFileSync(statePath(), "utf8"); await installAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.readFileSync(guidelinesPath(), "utf8")).toBe(firstGuidelines); expect(fs.readFileSync(statePath(), "utf8")).toBe(firstState); }); test("removes legacy cursor rules file during update", async () => { fs.mkdirSync(path.join(tmpDir, ".cursor", "rules"), { recursive: true }); fs.writeFileSync( path.join(tmpDir, ".cursor", "rules", "convex_rules.mdc"), "legacy", "utf8", ); await installAiFiles({ projectDir: tmpDir, convexDir }); expect( fs.existsSync(path.join(tmpDir, ".cursor", "rules", "convex_rules.mdc")), ).toBe(false); }); test("staleness check logs update nag for stale stored hash", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); const state = readJson(statePath()); state.guidelinesHash = "deliberately-stale-hash"; fs.writeFileSync( statePath(), JSON.stringify(state, null, 2) + "\n", "utf8", ); await checkAiFilesStaleness({ canonicalGuidelinesHash: "canonical-hash", canonicalAgentSkillsSha: null, projectDir: tmpDir, convexDir, }); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("out of date"), ); }); test("disable sets convex.json preference and keeps files", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(false); expect(fs.existsSync(guidelinesPath())).toBe(true); expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true); expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(true); }); test("remove deletes files and status reports not installed", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await removeAiFiles({ projectDir: tmpDir, convexDir }); await statusAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(aiDir())).toBe(false); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("not installed"), ); }); test("disable after CLAUDE.md user edits preserves file", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); fs.appendFileSync( path.join(tmpDir, "CLAUDE.md"), "My custom note\n", "utf8", ); await safelyAttemptToDisableAiFiles(tmpDir); expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8")).toContain( "My custom note", ); }); test("update recreates missing CLAUDE.md", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); fs.rmSync(path.join(tmpDir, "CLAUDE.md"), { force: true }); await installAiFiles({ projectDir: tmpDir, convexDir }); const content = fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf8"); expect(content).toMatch( /src[\\/]+convex[\\/]+_generated[\\/]+ai[\\/]+guidelines\.md/, ); }); test("enable sets enabled flag to true and re-enables status", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); await enableAiFiles({ projectDir: tmpDir, convexDir }); await statusAiFiles({ projectDir: tmpDir, convexDir }); expect(readJson(projectConfigPath()).aiFiles.enabled).toBe(true); expect(vi.mocked(logMessage)).toHaveBeenCalledWith( expect.stringContaining("enabled"), ); }); test("disable + remove + enable works with overridden functions directory", async () => { await installAiFiles({ projectDir: tmpDir, convexDir }); await safelyAttemptToDisableAiFiles(tmpDir); await removeAiFiles({ projectDir: tmpDir, convexDir }); expect( fs.existsSync(path.join(tmpDir, "src", "convex", "_generated", "ai")), ).toBe(false); await enableAiFiles({ projectDir: tmpDir, convexDir }); expect(fs.existsSync(guidelinesPath())).toBe(true); }); });