Skip to main content
Glama
config.test.ts28.5 kB
import { vi, test, expect, beforeEach, MockInstance, beforeAll } from "vitest"; import { parseProjectConfig, ProjectConfig, writeProjectConfig, readProjectConfig, resetUnknownKeyWarnings, } from "./config.js"; import { Context, oneoffContext } from "../../bundler/context.js"; import { logFailure } from "../../bundler/log.js"; import { stripVTControlCharacters } from "util"; let ctx: Context; let stderrSpy: MockInstance; beforeAll(async () => { stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); }); beforeEach(async () => { const originalContext = await oneoffContext({ url: undefined, adminKey: undefined, envFile: undefined, }); ctx = { ...originalContext, crash: (args: { printedMessage: string | null }) => { if (args.printedMessage !== null) { logFailure(args.printedMessage); } throw new Error(); }, }; stderrSpy.mockClear(); resetUnknownKeyWarnings(); // Reset warning state between tests }); const assertParses = async ( inp: Record<string, any>, expected?: ProjectConfig, ) => { const result = await parseProjectConfig(ctx, inp); expect(result).toEqual(expected ?? inp); }; const assertParseError = async (inp: any, err: string) => { stderrSpy.mockClear(); await expect(parseProjectConfig(ctx, inp)).rejects.toThrow(); const calledWith = stderrSpy.mock.calls as string[][]; expect(stripVTControlCharacters(calledWith[0][0])).toEqual(err); }; test("parseProjectConfig basic valid configs", async () => { await assertParses( { functions: "functions/", }, { functions: "functions/", // default values (note that node version *has no default* node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }, ); await assertParses( { functions: "functions/", // unknown property futureFeature: 123, // deprecated team: "team", project: "proj", prodUrl: "prodUrl", authInfo: [ { applicationID: "hello", domain: "world", }, ], }, { functions: "functions/", // default values node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, // unknown properties are preserved ...({ futureFeature: 123 } as any), // deprecated team: "team", project: "proj", prodUrl: "prodUrl", authInfo: [ { applicationID: "hello", domain: "world", }, ], }, ); }); test("parseProjectConfig - node defaults", async () => { // No node field -> gets defaulted await assertParses( {}, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }, ); // node exists but externalPackages missing -> gets defaulted await assertParses( { node: { extraField: 123 } }, { functions: "convex/", node: { externalPackages: [], ...{ extraField: 123 } }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }, ); // node with nodeVersion but no externalPackages await assertParses( { node: { nodeVersion: "18", extraField: 123 } }, { functions: "convex/", node: { externalPackages: [], nodeVersion: "18", ...{ extraField: 123 } }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }, ); }); test("parseProjectConfig - node validation errors", async () => { await assertParseError( { node: { externalPackages: "not-an-array" } }, "✖ `node.externalPackages` in `convex.json`: Expected array, received string\n", ); await assertParseError( { node: { externalPackages: [123] } }, "✖ `node.externalPackages.0` in `convex.json`: Expected string, received number\n", ); await assertParseError( { node: { nodeVersion: 18 } }, "✖ `node.nodeVersion` in `convex.json`: Expected string, received number\n", ); }); test("parseProjectConfig - codegen fields", async () => { // fileType with valid values await assertParses( { codegen: { fileType: "ts" } }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false, fileType: "ts" }, }, ); await assertParses( { codegen: { fileType: "js/dts" } }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false, fileType: "js/dts" }, }, ); // legacyComponentApi await assertParses( { codegen: { legacyComponentApi: false } }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false, legacyComponentApi: false, }, }, ); await assertParses( { codegen: { legacyComponentApi: true } }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false, legacyComponentApi: true, }, }, ); }); test("parseProjectConfig - codegen validation errors", async () => { // Invalid fileType value await assertParseError( { codegen: { fileType: "invalid" } }, "✖ `codegen.fileType` in `convex.json`: Invalid enum value. Expected 'ts' | 'js/dts', received 'invalid'\n", ); // Invalid legacyComponentApi type await assertParseError( { codegen: { legacyComponentApi: "yes" } }, "✖ `codegen.legacyComponentApi` in `convex.json`: Expected boolean, received string\n", ); // Cross-field validation: generateCommonJSApi: true with fileType: "ts" should fail await assertParseError( { generateCommonJSApi: true, codegen: { fileType: "ts" } }, '✖ `generateCommonJSApi` in `convex.json`: Cannot use `generateCommonJSApi: true` with `codegen.fileType: "ts"`. CommonJS modules require JavaScript generation. Either set `codegen.fileType: "js/dts"` or remove `generateCommonJSApi`.\n', ); }); test("parseProjectConfig - top-level validation", async () => { await assertParseError( "not-an-object", "✖ Expected `convex.json` to contain an object\n", ); await assertParseError( 123, "✖ Expected `convex.json` to contain an object\n", ); await assertParseError( null, "✖ Expected `convex.json` to contain an object\n", ); await assertParseError( [], "✖ Expected `convex.json` to contain an object\n", ); }); test("writeProjectConfig strips defaults hierarchically", async () => { let writtenContent = ""; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => path === "convex.json", writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; // Test full defaults - no file written when all defaults const fullDefaults: ProjectConfig = { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }; await writeProjectConfig(testCtx, fullDefaults); // When all defaults are stripped, no file is written expect(writtenContent).toBe(""); // - node should be stripped // - extra fields should pass through const partialNode: ProjectConfig = { functions: "my-functions/", node: { externalPackages: [] }, // All defaults generateCommonJSApi: true, codegen: { staticApi: false, staticDataModel: false }, ...{ extraField: 123 }, }; await writeProjectConfig(testCtx, partialNode); const written2 = JSON.parse(writtenContent); expect(written2.node).toBeUndefined(); // Stripped expect(written2.functions).toBe("my-functions/"); expect(written2.generateCommonJSApi).toBe(true); expect(written2.codegen).toBeUndefined(); // All false, so stripped expect(written2.extraField).toBe(123); // preserved // Test hierarchical - codegen partially set const partialCodegen: ProjectConfig = { functions: "convex/", node: { externalPackages: [], nodeVersion: "18" }, generateCommonJSApi: false, codegen: { staticApi: true, staticDataModel: false }, }; await writeProjectConfig(testCtx, partialCodegen); const written3 = JSON.parse(writtenContent); expect(written3.codegen).toEqual({ staticApi: true }); // false stripped expect(written3.node).toEqual({ nodeVersion: "18" }); // externalPackages stripped }); test("writeProjectConfig - filters deprecated fields", async () => { let writtenContent = ""; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => path === "convex.json", writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; const withDeprecated: ProjectConfig = { functions: "my-functions/", // Non-default to ensure writing node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, project: "my-project", team: "my-team", prodUrl: "https://example.com", }; await writeProjectConfig(testCtx, withDeprecated); const written = JSON.parse(writtenContent); // Deprecated fields should not be written expect(written.project).toBeUndefined(); expect(written.team).toBeUndefined(); expect(written.prodUrl).toBeUndefined(); // But non-deprecated fields should be written expect(written.functions).toBe("my-functions/"); }); test("writeProjectConfig - preserves optional codegen fields", async () => { let writtenContent = ""; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => path === "convex.json", writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; // fileType and legacyComponentApi should NOT be stripped even when explicitly set const withOptionalFields: ProjectConfig = { functions: "my-functions/", // Non-default to ensure writing node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false, fileType: "ts", legacyComponentApi: false, }, }; await writeProjectConfig(testCtx, withOptionalFields); const written = JSON.parse(writtenContent); // fileType and legacyComponentApi should be preserved even though staticApi/staticDataModel are stripped expect(written.codegen).toEqual({ fileType: "ts", legacyComponentApi: false, }); expect(written.functions).toBe("my-functions/"); }); test("readProjectConfig - returns defaults when file doesn't exist", async () => { const testCtx = { ...ctx, fs: { ...ctx.fs, exists: () => false, readUtf8File: (path: string) => { // Mock package.json without react-scripts if (path === "package.json") { return JSON.stringify({ name: "test-app" }); } throw new Error(`Unexpected read: ${path}`); }, }, }; const { projectConfig, configPath } = await readProjectConfig(testCtx); expect(configPath).toBe("convex.json"); expect(projectConfig).toEqual({ functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }); }); test("read-write-read - deprecated fields are removed", async () => { // Helper to test that reading, writing, and reading again cleans up deprecated fields const assertCleansUpDeprecated = async ( rawJson: any, expectedAfterCleanup: ProjectConfig, ) => { let writtenContent: string | null = null; let hasBeenWritten = false; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => { if (path === "convex.json") { // File exists initially (with deprecated fields), and after writing if content was written return !hasBeenWritten || writtenContent !== null; } if (path === "package.json") { return true; } return false; }, readUtf8File: (path: string) => { if (path === "convex.json") { if (!hasBeenWritten) { // First read - return the raw JSON with deprecated fields return JSON.stringify(rawJson); } // Second read - return what was written if (writtenContent === null) { throw new Error("File doesn't exist"); } return writtenContent; } if (path === "package.json") { return JSON.stringify({ name: "test-app" }); } throw new Error(`Unexpected read: ${path}`); }, writeUtf8File: (_path: string, content: string) => { writtenContent = content; hasBeenWritten = true; }, mkdir: () => {}, }, }; // First read - should parse deprecated fields but keep them const { projectConfig: firstRead } = await readProjectConfig(testCtx); // Write it await writeProjectConfig(testCtx, firstRead); // Read again - deprecated fields should be gone const { projectConfig: secondRead } = await readProjectConfig(testCtx); expect(secondRead).toEqual(expectedAfterCleanup); }; // Test 1: Config with all deprecated fields await assertCleansUpDeprecated( { functions: "my-functions/", project: "my-project", team: "my-team", prodUrl: "https://example.com", }, { functions: "my-functions/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }, ); // Test 2: Config with deprecated fields AND non-default values await assertCleansUpDeprecated( { functions: "backend/", project: "my-project", team: "my-team", prodUrl: "https://example.com", node: { externalPackages: ["axios"], nodeVersion: "20" }, generateCommonJSApi: true, }, { functions: "backend/", node: { externalPackages: ["axios"], nodeVersion: "20" }, generateCommonJSApi: true, codegen: { staticApi: false, staticDataModel: false }, }, ); // Test 3: Config with authInfo (deprecated but still written if non-empty) await assertCleansUpDeprecated( { authInfo: [{ applicationID: "app123", domain: "example.com" }], project: "my-project", }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, authInfo: [{ applicationID: "app123", domain: "example.com" }], }, ); // Test 4: Config with empty authInfo (should be stripped like other defaults) await assertCleansUpDeprecated( { functions: "my-functions/", authInfo: [], project: "my-project", }, { functions: "my-functions/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, // authInfo should be gone since it was empty }, ); }); test("roundtrip - write then read gives same config", async () => { // Helper function to test roundtrip const assertRoundtrips = async (config: ProjectConfig) => { let writtenContent: string | null = null; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => { if (path === "convex.json") { return writtenContent !== null; } if (path === "package.json") { return true; } return false; }, readUtf8File: (path: string) => { if (path === "convex.json") { if (writtenContent === null) { throw new Error("File doesn't exist"); } return writtenContent; } if (path === "package.json") { return JSON.stringify({ name: "test-app" }); } throw new Error(`Unexpected read: ${path}`); }, writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; await writeProjectConfig(testCtx, config); const { projectConfig: readBack } = await readProjectConfig(testCtx); expect(readBack).toEqual(config); }; // Test 1: All defaults (file won't be written, but reading returns defaults) await assertRoundtrips({ functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }); // Test 2: Custom functions path await assertRoundtrips({ functions: "my-functions/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }); // Test 3: External packages await assertRoundtrips({ functions: "convex/", node: { externalPackages: ["axios", "lodash"] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }); // Test 4: Node version await assertRoundtrips({ functions: "convex/", node: { externalPackages: [], nodeVersion: "18" }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, }); // Test 5: Generate CommonJS API await assertRoundtrips({ functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: true, codegen: { staticApi: false, staticDataModel: false }, }); // Test 6: Codegen options await assertRoundtrips({ functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: true, staticDataModel: true }, }); // Test 7: AuthInfo (deprecated but still supported) await assertRoundtrips({ functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, authInfo: [{ applicationID: "app123", domain: "example.com" }], }); // Test 8: Complex config with multiple non-defaults await assertRoundtrips({ functions: "backend/", node: { externalPackages: ["@aws-sdk/client-s3"], nodeVersion: "20" }, generateCommonJSApi: true, codegen: { staticApi: true, staticDataModel: false }, }); }); test("parseProjectConfig - preserves unknown properties", async () => { // Unknown properties should be preserved for forward/backward compatibility await assertParses( { functions: "convex/", unknownField: "some-value", futureFeature: { nested: "data", count: 42, }, }, { functions: "convex/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, unknownField: "some-value", futureFeature: { nested: "data", count: 42, }, } as any, ); // Unknown properties alongside known ones await assertParses( { functions: "my-functions/", generateCommonJSApi: true, customMetadata: { version: "1.0.0", author: "test", }, experimentalFlag: true, }, { functions: "my-functions/", node: { externalPackages: [] }, generateCommonJSApi: true, codegen: { staticApi: false, staticDataModel: false }, customMetadata: { version: "1.0.0", author: "test", }, experimentalFlag: true, } as any, ); }); test("writeProjectConfig - preserves unknown properties", async () => { let writtenContent = ""; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => path === "convex.json", writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; // Unknown properties should be written back const configWithUnknown: any = { functions: "my-functions/", node: { externalPackages: [] }, generateCommonJSApi: false, codegen: { staticApi: false, staticDataModel: false }, unknownField: "preserve-me", futureFeature: { enabled: true, config: { nested: "value" }, }, }; await writeProjectConfig(testCtx, configWithUnknown); const written = JSON.parse(writtenContent); // Non-default known fields should be present expect(written.functions).toBe("my-functions/"); // Unknown fields should be preserved expect(written.unknownField).toBe("preserve-me"); expect(written.futureFeature).toEqual({ enabled: true, config: { nested: "value" }, }); // Defaults should still be stripped expect(written.node).toBeUndefined(); expect(written.codegen).toBeUndefined(); expect(written.generateCommonJSApi).toBeUndefined(); }); test("roundtrip - unknown properties survive read-write-read", async () => { let writtenContent: string | null = null; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => { if (path === "convex.json") { return writtenContent !== null; } if (path === "package.json") { return true; } return false; }, readUtf8File: (path: string) => { if (path === "convex.json") { if (writtenContent === null) { throw new Error("File doesn't exist"); } return writtenContent; } if (path === "package.json") { return JSON.stringify({ name: "test-app" }); } throw new Error(`Unexpected read: ${path}`); }, writeUtf8File: (_path: string, content: string) => { writtenContent = content; }, mkdir: () => {}, }, }; // Start with a config containing unknown properties const originalConfig: any = { functions: "backend/", node: { externalPackages: ["axios"] }, generateCommonJSApi: true, codegen: { staticApi: false, staticDataModel: false }, // Unknown properties that should survive customField: "important-data", metadata: { version: "2.0", internal: true, }, experimentalFeatures: ["feature1", "feature2"], }; // Write await writeProjectConfig(testCtx, originalConfig); // Read back const { projectConfig: readBack } = await readProjectConfig(testCtx); // All unknown properties should be preserved expect((readBack as any).customField).toBe("important-data"); expect((readBack as any).metadata).toEqual({ version: "2.0", internal: true, }); expect((readBack as any).experimentalFeatures).toEqual([ "feature1", "feature2", ]); // Known properties should also be correct expect(readBack.functions).toBe("backend/"); expect(readBack.node.externalPackages).toEqual(["axios"]); expect(readBack.generateCommonJSApi).toBe(true); // Write again await writeProjectConfig(testCtx, readBack); // Read again const { projectConfig: readBack2 } = await readProjectConfig(testCtx); // Everything should still match expect(readBack2).toEqual(readBack); }); test("read-write-read - unknown properties with deprecated fields", async () => { // Verify unknown properties survive even when deprecated fields are removed let writtenContent: string | null = null; let hasBeenWritten = false; const testCtx = { ...ctx, fs: { ...ctx.fs, exists: (path: string) => { if (path === "convex.json") { return !hasBeenWritten || writtenContent !== null; } if (path === "package.json") { return true; } return false; }, readUtf8File: (path: string) => { if (path === "convex.json") { if (!hasBeenWritten) { // First read: has deprecated fields AND unknown properties return JSON.stringify({ functions: "my-functions/", project: "my-project", // deprecated team: "my-team", // deprecated customField: "keep-this", // unknown, should survive futureConfig: { value: 123 }, // unknown, should survive }); } if (writtenContent === null) { throw new Error("File doesn't exist"); } return writtenContent; } if (path === "package.json") { return JSON.stringify({ name: "test-app" }); } throw new Error(`Unexpected read: ${path}`); }, writeUtf8File: (_path: string, content: string) => { writtenContent = content; hasBeenWritten = true; }, mkdir: () => {}, }, }; // First read const { projectConfig: firstRead } = await readProjectConfig(testCtx); // Deprecated fields present in memory expect(firstRead.project).toBe("my-project"); expect(firstRead.team).toBe("my-team"); // Unknown fields also present expect((firstRead as any).customField).toBe("keep-this"); expect((firstRead as any).futureConfig).toEqual({ value: 123 }); // Write (deprecated fields will be filtered out) await writeProjectConfig(testCtx, firstRead); // Read again const { projectConfig: secondRead } = await readProjectConfig(testCtx); // Deprecated fields gone from file (not re-read) expect(secondRead.project).toBeUndefined(); expect(secondRead.team).toBeUndefined(); // But unknown fields should survive! expect((secondRead as any).customField).toBe("keep-this"); expect((secondRead as any).futureConfig).toEqual({ value: 123 }); // Known non-default fields still present expect(secondRead.functions).toBe("my-functions/"); }); test("parseProjectConfig - warns about unknown properties", async () => { // Single unknown property stderrSpy.mockClear(); const config1 = await parseProjectConfig(ctx, { functions: "convex/", unknownField: "value", }); expect(config1.functions).toBe("convex/"); expect((config1 as any).unknownField).toBe("value"); // Check that warning was logged const stderr1 = stderrSpy.mock.calls.map((call) => call[0]).join(""); expect(stripVTControlCharacters(stderr1)).toContain( "Warning: Unknown property in `convex.json`: `unknownField`", ); expect(stripVTControlCharacters(stderr1)).toContain( "These properties will be preserved but are not recognized by this version of Convex", ); // Multiple unknown properties stderrSpy.mockClear(); const config2 = await parseProjectConfig(ctx, { functions: "my-functions/", customField1: "value1", customField2: 42, futureFeature: { nested: true }, }); expect((config2 as any).customField1).toBe("value1"); expect((config2 as any).customField2).toBe(42); // No warning for known fields only stderrSpy.mockClear(); await parseProjectConfig(ctx, { functions: "convex/", generateCommonJSApi: true, }); const stderr3 = stderrSpy.mock.calls.map((call) => call[0]).join(""); expect(stripVTControlCharacters(stderr3)).not.toContain("Warning"); expect(stripVTControlCharacters(stderr3)).not.toContain("Unknown"); // No warning for deprecated fields (they're known, just deprecated) stderrSpy.mockClear(); await parseProjectConfig(ctx, { functions: "convex/", project: "my-project", team: "my-team", }); const stderr4 = stderrSpy.mock.calls.map((call) => call[0]).join(""); expect(stripVTControlCharacters(stderr4)).not.toContain("Warning"); expect(stripVTControlCharacters(stderr4)).not.toContain("Unknown"); // No warning for $schema field (used by JSON schema validation) stderrSpy.mockClear(); await parseProjectConfig(ctx, { functions: "convex/", $schema: "../../../convex/schemas/convex.schema.json", }); const stderr5 = stderrSpy.mock.calls.map((call) => call[0]).join(""); expect(stripVTControlCharacters(stderr5)).not.toContain("Warning"); expect(stripVTControlCharacters(stderr5)).not.toContain("Unknown"); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server