convex
Version:
Client for the Convex Cloud
196 lines (167 loc) • 6.11 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 type { Context } from "../../../bundler/context.js";
import {
AGENTS_MD_END_MARKER,
AGENTS_MD_START_MARKER,
} from "../../codegen_templates/agentsmd.js";
const { mockPromptYesNo } = vi.hoisted(() => {
return { mockPromptYesNo: vi.fn() };
});
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
captureMessage: vi.fn(),
}));
vi.mock("../../../bundler/log.js", () => ({
logMessage: vi.fn(),
logFinishedStep: vi.fn(),
}));
vi.mock("../versionApi.js", () => ({
downloadGuidelines: vi.fn(async () => "prompt test guidelines content"),
fetchAgentSkillsSha: vi.fn(async () => "prompt-test-sha"),
getVersion: vi.fn(async () => ({
kind: "ok",
data: {
message: null,
guidelinesHash: "prompt-test-guidelines-hash",
agentSkillsSha: "prompt-test-agent-skills-sha",
disableSkillsCli: false,
},
})),
}));
vi.mock("child_process", () => ({
default: {
spawn: vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: (event: string, cb: (code: number) => void) => {
if (event === "close") cb(0);
},
})),
},
}));
vi.mock("../utils/prompts.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/prompts.js")>();
return {
...actual,
promptYesNo: mockPromptYesNo,
};
});
import { maybeSetupAiFiles } from "./index.js";
function makeTmpDir(): string {
return fs.mkdtempSync(`${os.tmpdir()}${path.sep}`);
}
const fakeCtx: Context = {
fs: {
readFile: (p: string) => fs.readFileSync(p, { encoding: "utf8" }),
exists: (p: string) => fs.existsSync(p),
readdir: (p: string) => fs.readdirSync(p),
stat: (p: string) => fs.statSync(p),
readUtf8File: (p: string) => fs.readFileSync(p, { encoding: "utf8" }),
lstat: (p: string) => fs.lstatSync(p),
} as any,
deprecationMessagePrinted: false,
crash: async (args) => {
throw new Error(args.printedMessage ?? "crash");
},
registerCleanup: () => "handle",
removeCleanup: () => null as any,
bigBrainAuth: () => null,
_updateBigBrainAuth: () => {},
};
describe("maybeSetupAiFiles interactive prompt", () => {
let tmpDir: string;
let convexDir: string;
let originalIsTTY: boolean | undefined;
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"), "");
originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
mockPromptYesNo.mockResolvedValue(true);
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY!;
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.unstubAllEnvs();
vi.clearAllMocks();
});
test("user accepts prompt: AI files are installed", async () => {
mockPromptYesNo.mockResolvedValue(true);
await maybeSetupAiFiles({ ctx: fakeCtx, convexDir, projectDir: tmpDir });
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(
"prompt test guidelines content",
);
});
test("user declines prompt: no config and no AI files are written", async () => {
mockPromptYesNo.mockResolvedValue(false);
await maybeSetupAiFiles({ ctx: fakeCtx, convexDir, projectDir: tmpDir });
expect(fs.existsSync(guidelinesPath())).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(false);
expect(fs.existsSync(projectConfigPath())).toBe(false);
});
test("non-interactive terminal skips the prompt and does not install AI files", async () => {
process.stdin.isTTY = false;
await maybeSetupAiFiles({ ctx: fakeCtx, convexDir, projectDir: tmpDir });
expect(fs.existsSync(guidelinesPath())).toBe(false);
expect(fs.existsSync(statePath())).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "AGENTS.md"))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "CLAUDE.md"))).toBe(false);
expect(fs.existsSync(projectConfigPath())).toBe(false);
});
test("existing state file updates AI files without prompting", async () => {
const stateDir = path.join(convexDir, "_generated", "ai");
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
path.join(stateDir, "ai-files.state.json"),
JSON.stringify(
{
guidelinesHash: "hash",
agentsMdSectionHash: "hash",
claudeMdHash: "hash",
agentSkillsSha: "sha",
installedSkillNames: [],
},
null,
2,
),
);
await maybeSetupAiFiles({ ctx: fakeCtx, convexDir, projectDir: tmpDir });
expect(fs.existsSync(path.join(stateDir, "ai-files.state.json"))).toBe(
true,
);
expect(fs.existsSync(guidelinesPath())).toBe(true);
expect(fs.readFileSync(guidelinesPath(), "utf8")).toBe(
"prompt test guidelines content",
);
});
test("existing AGENTS.md managed section rebuilds state without prompting", async () => {
fs.writeFileSync(
path.join(tmpDir, "AGENTS.md"),
[
"# Team notes",
"",
AGENTS_MD_START_MARKER,
"Managed section",
AGENTS_MD_END_MARKER,
"",
].join("\n"),
);
await maybeSetupAiFiles({ ctx: fakeCtx, convexDir, projectDir: tmpDir });
expect(fs.existsSync(statePath())).toBe(true);
expect(fs.existsSync(guidelinesPath())).toBe(true);
});
});