convex
Version:
Client for the Convex Cloud
695 lines (619 loc) • 24.5 kB
text/typescript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// @inquirer/testing/vitest must be imported before modules that use @inquirer/*
import { screen } from "@inquirer/testing/vitest";
import path from "path";
import { nodeFs } from "../bundler/fs.js";
import { deploymentSelect } from "./deploymentSelect.js";
import { bigBrainAPI, bigBrainAPIMaybeThrows } from "./lib/utils/utils.js";
import { globalConfigPath } from "./lib/utils/globalConfig.js";
// Mock GET functions — can be configured per test
const mockPlatformGet = vi.fn();
const mockDeploymentGet = vi.fn();
const { mockCreateLocalDeployment } = vi.hoisted(() => ({
mockCreateLocalDeployment: vi.fn(),
}));
// In-memory filesystem — populated in beforeEach, written to by real configure code
let testFiles: Map<string, string>;
vi.mock("../bundler/fs.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../bundler/fs.js")>();
return {
...actual,
nodeFs: {
...actual.nodeFs,
exists: vi.fn(),
readUtf8File: vi.fn(),
writeUtf8File: vi.fn(),
mkdir: vi.fn(),
},
};
});
vi.mock("./lib/utils/utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/utils/utils.js")>();
return {
...actual,
bigBrainAPI: vi.fn(),
bigBrainAPIMaybeThrows: vi.fn(),
typedPlatformClient: vi.fn(() => ({ GET: mockPlatformGet })),
typedDeploymentClient: vi.fn(() => ({ GET: mockDeploymentGet })),
};
});
vi.mock("dotenv", async (importOriginal) => {
const actual = await importOriginal<typeof import("dotenv")>();
return {
...actual,
config: vi.fn(),
};
});
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
close: vi.fn(),
}));
vi.mock("./deploymentCreate.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./deploymentCreate.js")>();
return {
...actual,
createLocalDeployment: mockCreateLocalDeployment,
};
});
vi.mock("./lib/localDeployment/run.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("./lib/localDeployment/run.js")>();
return {
...actual,
fetchLocalBackendStatus: vi.fn().mockResolvedValue({ kind: "running" }),
};
});
/**
* Routes mock Big Brain API calls by path.
* Both `bigBrainAPI` and `bigBrainAPIMaybeThrows` delegate to this.
*/
function setupBigBrainRoutes(routes: Record<string, (data?: any) => any>) {
const handler = (args: { path: string; data?: any }) => {
for (const [routePath, routeHandler] of Object.entries(routes)) {
if (args.path === routePath || args.path.startsWith(routePath)) {
return routeHandler(args.data);
}
}
throw new Error(`Unmocked Big Brain route: ${args.path}`);
};
vi.mocked(bigBrainAPI).mockImplementation(handler as any);
vi.mocked(bigBrainAPIMaybeThrows).mockImplementation(handler as any);
}
describe("npx convex select", () => {
let savedEnv: NodeJS.ProcessEnv;
let savedIsTTY: boolean | undefined;
beforeEach(() => {
savedEnv = { ...process.env };
savedIsTTY = process.stdin.isTTY;
process.env = {};
// Default to interactive TTY for existing tests
process.stdin.isTTY = true as any;
// Start with minimal filesystem: package.json for readProjectConfig fallback
testFiles = new Map([[path.resolve("package.json"), "{}"]]);
vi.resetAllMocks();
// Wire up the in-memory filesystem to the nodeFs mock
vi.mocked(nodeFs.exists).mockImplementation((p: string) =>
testFiles.has(path.resolve(p)),
);
vi.mocked(nodeFs.readUtf8File).mockImplementation((p: string) => {
const content = testFiles.get(path.resolve(p));
if (content === undefined) {
const err: any = new Error(
`ENOENT: no such file or directory, open '${p}'`,
);
err.code = "ENOENT";
throw err;
}
return content;
});
vi.mocked(nodeFs.writeUtf8File).mockImplementation(
(p: string, content: string) => {
testFiles.set(path.resolve(p), content);
},
);
// typedDeploymentClient GET is called by fetchDeploymentCanonicalUrls
mockDeploymentGet.mockResolvedValue({
data: {
convexCloudUrl: "https://example.convex.cloud",
convexSiteUrl: "https://example.convex.site",
},
});
// typedPlatformClient is used for reference-based deployment resolution
vi.mocked(mockPlatformGet).mockResolvedValue({ data: undefined });
});
afterEach(() => {
process.env = savedEnv;
process.stdin.isTTY = savedIsTTY as any;
});
// Suppress process.exit and stderr
beforeEach(() => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as any);
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("with project configured", () => {
beforeEach(() => {
process.env.CONVEX_DEPLOYMENT = "dev:joyful-capybara-123";
testFiles.set(
globalConfigPath(),
JSON.stringify({ accessToken: "test-token" }),
);
});
it("selects a dev deployment by name (abc-xyz-123)", async () => {
// For a deployment name selector, the system looks up the *selected*
// deployment's team/project (not the current CONVEX_DEPLOYMENT's).
setupBigBrainRoutes({
"deployment/clever-otter-890/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://clever-otter-890.convex.cloud",
deploymentName: "clever-otter-890",
deploymentType: "dev",
}),
});
await deploymentSelect.parseAsync(["clever-otter-890"], { from: "user" });
expect(bigBrainAPI).toHaveBeenCalledWith(
expect.objectContaining({
path: "deployment/authorize_within_current_project",
data: expect.objectContaining({
selectedDeploymentName: "clever-otter-890",
}),
}),
);
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:clever-otter-890");
expect(envContent).toContain("team: my-team, project: my-project");
expect(envContent).toContain(
"CONVEX_URL=https://clever-otter-890.convex.cloud",
);
});
it("selects dev deployment with 'dev' selector", async () => {
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/my-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://joyful-capybara-123.convex.cloud",
deploymentName: "joyful-capybara-123",
deploymentType: "dev",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "joyful-capybara-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["dev"], { from: "user" });
expect(mockPlatformGet).toHaveBeenCalledWith(
"/teams/{team_id_or_slug}/projects/{project_slug}/deployment",
expect.objectContaining({
params: expect.objectContaining({
path: { team_id_or_slug: "my-team", project_slug: "my-project" },
query: { defaultDev: true },
}),
}),
);
expect(bigBrainAPI).toHaveBeenCalledWith(
expect.objectContaining({
path: "deployment/authorize_within_current_project",
data: expect.objectContaining({
selectedDeploymentName: "joyful-capybara-123",
}),
}),
);
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:joyful-capybara-123");
});
it("selects dev deployment by reference 'dev/nicolas'", async () => {
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/my-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "nicolas-key",
url: "https://nicolas-dev-123.convex.cloud",
deploymentName: "nicolas-dev-123",
deploymentType: "dev",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "nicolas-dev-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["dev/nicolas"], { from: "user" });
expect(mockPlatformGet).toHaveBeenCalledWith(
"/teams/{team_id_or_slug}/projects/{project_slug}/deployment",
expect.objectContaining({
params: expect.objectContaining({
path: { team_id_or_slug: "my-team", project_slug: "my-project" },
query: { reference: "dev/nicolas" },
}),
}),
);
expect(bigBrainAPI).toHaveBeenCalledWith(
expect.objectContaining({
path: "deployment/authorize_within_current_project",
data: expect.objectContaining({
selectedDeploymentName: "nicolas-dev-123",
}),
}),
);
expect(testFiles.has(path.resolve(".env.local"))).toBe(true);
});
it("selects a preview deployment in another project 'other-project:preview/my-feature'", async () => {
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/other-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "preview-key",
url: "https://feature-preview-123.convex.cloud",
deploymentName: "feature-preview-123",
deploymentType: "preview",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "feature-preview-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["other-project:preview/my-feature"], {
from: "user",
});
expect(mockPlatformGet).toHaveBeenCalledWith(
"/teams/{team_id_or_slug}/projects/{project_slug}/deployment",
expect.objectContaining({
params: expect.objectContaining({
path: {
team_id_or_slug: "my-team",
project_slug: "other-project",
},
query: { reference: "preview/my-feature" },
}),
}),
);
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain(
"CONVEX_DEPLOYMENT=preview:feature-preview-123",
);
});
describe("prod deployment restrictions", () => {
it("fails with an error message when 'prod' selector is used", async () => {
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"deployment/authorize_prod": () => ({
adminKey: "prod-key",
url: "https://graceful-puffin-456.convex.cloud",
deploymentName: "graceful-puffin-456",
deploymentType: "prod",
}),
});
await expect(
deploymentSelect.parseAsync(["prod"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("--deployment prod"),
);
expect(testFiles.has(path.resolve(".env.local"))).toBe(false);
});
it("fails with an error message when a deployment name resolves to a prod deployment", async () => {
setupBigBrainRoutes({
"deployment/graceful-puffin-456/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"deployment/authorize_within_current_project": () => ({
adminKey: "prod-key",
url: "https://graceful-puffin-456.convex.cloud",
deploymentName: "graceful-puffin-456",
deploymentType: "prod",
}),
});
await expect(
deploymentSelect.parseAsync(["graceful-puffin-456"], {
from: "user",
}),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("--deployment graceful-puffin-456"),
);
expect(testFiles.has(path.resolve(".env.local"))).toBe(false);
});
});
describe("side effects on successful selection", () => {
it("fetches the canonical URLs using the resolved deployment credentials", async () => {
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/my-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://joyful-capybara-123.convex.cloud",
deploymentName: "joyful-capybara-123",
deploymentType: "dev",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "joyful-capybara-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["dev"], { from: "user" });
expect(mockDeploymentGet).toHaveBeenCalledWith("/get_canonical_urls");
});
it("writes the fetched site URL to the env file", async () => {
mockDeploymentGet.mockResolvedValue({
data: {
convexCloudUrl: "https://joyful-capybara-123.convex.cloud",
convexSiteUrl: "https://joyful-capybara-123.convex.site",
},
});
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/my-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://joyful-capybara-123.convex.cloud",
deploymentName: "joyful-capybara-123",
deploymentType: "dev",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "joyful-capybara-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["dev"], { from: "user" });
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain(
"CONVEX_SITE_URL=https://joyful-capybara-123.convex.site",
);
});
it("uses the existing deployment name to detect unchanged selections", async () => {
// deploymentNameFromSelection(currentSelection) extracts "joyful-capybara-123"
// from process.env.CONVEX_DEPLOYMENT ("dev:joyful-capybara-123") and passes
// it as existingValue to configure so it can detect whether the selection changed.
// Here we verify the full chain ran: the correct name is written to .env.local.
setupBigBrainRoutes({
"deployment/joyful-capybara-123/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"teams/my-team/projects/my-project/deployments": () => true,
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://joyful-capybara-123.convex.cloud",
deploymentName: "joyful-capybara-123",
deploymentType: "dev",
}),
});
mockPlatformGet.mockResolvedValue({
data: { name: "joyful-capybara-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["dev"], { from: "user" });
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain(
"CONVEX_DEPLOYMENT=dev:joyful-capybara-123",
);
});
});
it("selects local deployment with 'local' selector", async () => {
testFiles.set(
path.resolve(".convex/local/default/config.json"),
JSON.stringify({
ports: { cloud: 3210, site: 3211 },
adminKey: "local-key",
backendVersion: "1.0.0",
deploymentName: "local-my_team-my_project-abc",
}),
);
// The local deployment name is looked up via Big Brain for project
// access checks (checkAccessToSelectedProject)
// FIXME We should probably avoid the Big Brain call here so that it works offline
setupBigBrainRoutes({
"deployment/local-my_team-my_project-abc/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
});
await deploymentSelect.parseAsync(["local"], { from: "user" });
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain(
"CONVEX_DEPLOYMENT=local:local-my_team-my_project-abc",
);
// Site URL fetch should be skipped for local deployments
expect(envContent).not.toContain("CONVEX_SITE_URL");
});
it("creates a local deployment when user approves the 'Create one now?' prompt", async () => {
mockCreateLocalDeployment.mockResolvedValue(undefined);
const promise = deploymentSelect.parseAsync(["local"], { from: "user" });
await screen.next();
expect(screen.getScreen()).toContain(
"No local deployment found. Create one now?",
);
screen.keypress("y");
screen.keypress("enter");
await promise;
expect(mockCreateLocalDeployment).toHaveBeenCalledTimes(1);
});
it("fails with 'No local deployment found' when no local config exists and stdin is not a TTY", async () => {
const previousIsTTY = process.stdin.isTTY;
process.stdin.isTTY = false as any;
try {
await expect(
deploymentSelect.parseAsync(["local"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("No local deployment found"),
);
expect(mockCreateLocalDeployment).not.toHaveBeenCalled();
} finally {
process.stdin.isTTY = previousIsTTY as any;
}
});
});
describe("without project configured", () => {
beforeEach(() => {
delete process.env.CONVEX_DEPLOYMENT;
testFiles.set(
globalConfigPath(),
JSON.stringify({ accessToken: "test-token" }),
);
});
it("fails with 'No project configured' for the 'dev' selector", async () => {
await expect(
deploymentSelect.parseAsync(["dev"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("No project configured"),
);
});
it("fails with 'No project configured' for a simple reference selector", async () => {
await expect(
deploymentSelect.parseAsync(["staging"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("No project configured"),
);
});
it("fails with 'No project configured' for a project:reference selector (needs team context)", async () => {
await expect(
deploymentSelect.parseAsync(["my-project:staging"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("No project configured"),
);
});
it("succeeds with a fully-qualified 'team:project:ref' selector", async () => {
setupBigBrainRoutes({
"deployment/authorize_within_current_project": () => ({
adminKey: "fq-key",
url: "https://fully-qualified-123.convex.cloud",
deploymentName: "fully-qualified-123",
deploymentType: "dev",
}),
"teams/my-team/projects/my-project/deployments": () => true,
});
mockPlatformGet.mockResolvedValue({
data: { name: "fully-qualified-123" },
error: undefined,
});
await deploymentSelect.parseAsync(["my-team:my-project:staging"], {
from: "user",
});
expect(mockPlatformGet).toHaveBeenCalledWith(
"/teams/{team_id_or_slug}/projects/{project_slug}/deployment",
expect.objectContaining({
params: expect.objectContaining({
path: { team_id_or_slug: "my-team", project_slug: "my-project" },
query: { reference: "staging" },
}),
}),
);
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:fully-qualified-123");
});
it("succeeds with a deployment name directly (does not need project context)", async () => {
// Deployment names (abc-xyz-123 pattern) don't require a project to
// already be configured — they look up their own team/project info.
setupBigBrainRoutes({
"deployment/clever-otter-890/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
"deployment/authorize_within_current_project": () => ({
adminKey: "dev-key",
url: "https://clever-otter-890.convex.cloud",
deploymentName: "clever-otter-890",
deploymentType: "dev",
}),
});
await deploymentSelect.parseAsync(["clever-otter-890"], { from: "user" });
// deploymentNameFromSelection(currentSelection) returns null when there
// is no CONVEX_DEPLOYMENT configured (kind === "chooseProject"), meaning
// configure treats this as a brand-new selection.
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain("CONVEX_DEPLOYMENT=dev:clever-otter-890");
});
it("fails with 'No project configured' for 'local' when no local deployment exists yet", async () => {
// No `.convex/local/default/config.json` exists, so we'd normally prompt
// "Create one now?". But since creating a local deployment requires a
// project, we should fail up-front instead.
await expect(
deploymentSelect.parseAsync(["local"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("No project configured"),
);
expect(process.stderr.write).not.toHaveBeenCalledWith(
expect.stringContaining("Create one now?"),
);
expect(mockCreateLocalDeployment).not.toHaveBeenCalled();
});
it("selects local deployment without project configured", async () => {
testFiles.set(
path.resolve(".convex/local/default/config.json"),
JSON.stringify({
ports: { cloud: 3210, site: 3211 },
adminKey: "local-key",
backendVersion: "1.0.0",
deploymentName: "local-my_team-my_project-abc",
}),
);
setupBigBrainRoutes({
"deployment/local-my_team-my_project-abc/team_and_project": () => ({
team: "my-team",
project: "my-project",
teamId: 1,
projectId: 1,
}),
});
await deploymentSelect.parseAsync(["local"], { from: "user" });
const envContent = testFiles.get(path.resolve(".env.local"))!;
expect(envContent).toContain(
"CONVEX_DEPLOYMENT=local:local-my_team-my_project-abc",
);
});
});
});