convex
Version:
Client for the Convex Cloud
691 lines (586 loc) • 24 kB
text/typescript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import {
checkAiFilesStalenessAndLog,
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"),
fetchAgentSkillsCatalog: vi.fn(async () => ({
kind: "ok",
data: {
latestRepoSha: "integration-agent-skills-sha",
skills: [
{
skillName: "migration-helper",
status: { kind: "active" },
hash: "hash-a",
lastSeenRepoSha: "integration-agent-skills-sha",
lastSeenAt: 123,
},
{
skillName: "schema-builder",
status: { kind: "active" },
hash: "hash-b",
lastSeenRepoSha: "integration-agent-skills-sha",
lastSeenAt: 123,
},
],
},
})),
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,
disableSkillsCliMessage: null,
},
})),
}));
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);
},
};
}),
},
}));
import child_process from "child_process";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
type TestProjectConfig = {
aiFiles?: {
enabled?: boolean;
disableStalenessMessage?: boolean;
skills?: {
agents?: string[];
};
};
};
function readJson<T>(filePath: string): T {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
}
function patchConvexJson(projectDir: string, patch: object) {
const filePath = path.join(projectDir, "convex.json");
const existing = fs.existsSync(filePath)
? readJson<Record<string, unknown>>(filePath)
: {};
fs.writeFileSync(
filePath,
JSON.stringify({ ...existing, ...patch }, null, 2) + "\n",
"utf8",
);
}
function makeTmpDir(): string {
return fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
}
type TestAiState = {
guidelinesHash: string | null;
agentSkillsSha: string | null;
};
// ---------------------------------------------------------------------------
// 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",
);
// Default agents
expect(vi.mocked(child_process.spawn)).toHaveBeenCalledWith(
"npx",
expect.arrayContaining([
"add",
"get-convex/agent-skills",
"--yes",
"--agent",
"claude-code",
"--agent",
"codex",
]),
expect.any(Object),
);
});
test("install respects explicit agent configuration", async () => {
await installAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: { skills: { agents: ["cursor", "windsurf"] } },
});
expect(vi.mocked(child_process.spawn)).toHaveBeenCalledWith(
"npx",
expect.arrayContaining([
"add",
"get-convex/agent-skills",
"--yes",
"--agent",
"cursor",
"--agent",
"windsurf",
]),
expect.any(Object),
);
});
test("install skips skills CLI when configured agents are empty", async () => {
await installAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: { skills: { agents: [] } },
});
expect(fs.existsSync(guidelinesPath())).toBe(true);
expect(fs.existsSync(statePath())).toBe(true);
expect(vi.mocked(child_process.spawn)).not.toHaveBeenCalled();
});
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<TestAiState>(statePath());
state.guidelinesHash = "deliberately-stale-hash";
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n");
await checkAiFilesStalenessAndLog({
canonicalGuidelinesHash: "canonical-hash",
canonicalAgentSkillsSha: null,
projectDir: tmpDir,
convexDir,
});
expect(vi.mocked(logMessage)).toHaveBeenCalledWith(
expect.stringContaining("out of date"),
);
});
test("disable keeps files but sets convex.json preference", async () => {
await installAiFiles({ projectDir: tmpDir, convexDir });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
expect(
readJson<TestProjectConfig>(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 () => {
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
expect(
readJson<TestProjectConfig>(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");
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
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 and strips deprecated disableStalenessMessage", async () => {
await installAiFiles({ projectDir: tmpDir, convexDir });
patchConvexJson(tmpDir, { aiFiles: { disableStalenessMessage: true } });
expect(
readJson<TestProjectConfig>(projectConfigPath()).aiFiles
?.disableStalenessMessage,
).toBe(true);
const newAiFiles = await enableAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: readJson<TestProjectConfig>(projectConfigPath()).aiFiles,
});
patchConvexJson(tmpDir, { aiFiles: newAiFiles });
const updatedConfig = readJson<TestProjectConfig>(projectConfigPath());
expect(updatedConfig.aiFiles?.enabled).toBe(true);
expect(updatedConfig.aiFiles?.disableStalenessMessage).toBeUndefined();
});
test("full cycle: disable -> remove -> enable reinstalls everything", async () => {
await installAiFiles({ projectDir: tmpDir, convexDir });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(fs.existsSync(aiDir())).toBe(false);
const newAiFiles = await enableAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: readJson<TestProjectConfig>(projectConfigPath()).aiFiles,
});
patchConvexJson(tmpDir, { aiFiles: newAiFiles });
expect(fs.existsSync(guidelinesPath())).toBe(true);
expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(true);
expect(
readJson<TestProjectConfig>(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 checkAiFilesStalenessAndLog({
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<TestAiState>(statePath());
vi.mocked(logMessage).mockClear();
await checkAiFilesStalenessAndLog({
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 });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
vi.mocked(logMessage).mockClear();
await statusAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: readJson<TestProjectConfig>(projectConfigPath()).aiFiles,
});
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<TestAiState>(statePath());
state.guidelinesHash = "deliberately-stale-hash";
fs.writeFileSync(
statePath(),
JSON.stringify(state, null, 2) + "\n",
"utf8",
);
await checkAiFilesStalenessAndLog({
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 });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
expect(
readJson<TestProjectConfig>(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",
);
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
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 });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
const newAiFiles = await enableAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: readJson<TestProjectConfig>(projectConfigPath()).aiFiles,
});
patchConvexJson(tmpDir, { aiFiles: newAiFiles });
await statusAiFiles({ projectDir: tmpDir, convexDir });
expect(
readJson<TestProjectConfig>(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 });
patchConvexJson(tmpDir, { aiFiles: { enabled: false } });
await removeAiFiles({ projectDir: tmpDir, convexDir });
expect(
fs.existsSync(path.join(tmpDir, "src", "convex", "_generated", "ai")),
).toBe(false);
const newAiFiles = await enableAiFiles({
projectDir: tmpDir,
convexDir,
aiFilesConfig: readJson<TestProjectConfig>(projectConfigPath()).aiFiles,
});
patchConvexJson(tmpDir, { aiFiles: newAiFiles });
expect(fs.existsSync(guidelinesPath())).toBe(true);
});
});