diff --git a/.changeset/empty-badgers-wish.md b/.changeset/empty-badgers-wish.md new file mode 100644 index 0000000..1b56c6f --- /dev/null +++ b/.changeset/empty-badgers-wish.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat(config): Implement schema validation when loading configuration diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 69d39a4..2de6105 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -67,7 +67,7 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] ** Enhance for organization Purpose **: Add new language `Typescript(ts)` with same code as javascript(js), also support for nodejs(node) template name for those who prefer it than the programming language name - [x] **Testing**: Stabilize the integration test of the `new` command - [x] Make sure to clean up if the `dk new` command fail -- [ ] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. +- [x] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. - [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. #### Multi-Language Support diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index b7cfd11..43bd418 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -96,7 +96,7 @@ describe("dk config", () => { it("should throw an error if no config file is found for setting", async () => { const { all, exitCode } = await execute( "bun", - [CLI_PATH, "conf", "--set", "lang", "en"], + [CLI_PATH, "config", "--set", "lang", "en"], { all: true, reject: false }, ); @@ -196,7 +196,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Clé de configuration 'non_existent_key' non trouvée.", + "Clé de configuration 'non_existent_key' introuvable", ); }); }); diff --git a/packages/devkit/__tests__/integrations/config/list.spec.ts b/packages/devkit/__tests__/integrations/config/list.spec.ts index 4dac95c..53577fc 100644 --- a/packages/devkit/__tests__/integrations/config/list.spec.ts +++ b/packages/devkit/__tests__/integrations/config/list.spec.ts @@ -27,6 +27,10 @@ let globalConfigDir: string; const localConfig: CliConfig = { ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "en", + }, templates: { javascript: { templates: { @@ -43,7 +47,7 @@ const localConfig: CliConfig = { }, }, }, - node: { + nodejs: { templates: { "node-api": { description: "A Node.js API boilerplate", @@ -57,19 +61,51 @@ const localConfig: CliConfig = { const globalConfig: CliConfig = { ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "fr", + }, templates: { - python: { + typescript: { templates: { - django: { - description: "A Django template", - location: "https://github.com/django/django", - alias: "dj", + "ts-lib": { + description: "A TypeScript library template", + location: "https://github.com/ts-lib-template", + alias: "tl", }, }, }, }, }; +const invalidLocalConfigMissingLocation: Partial = { + ...localConfig, + templates: { + javascript: { + templates: { + "bad-template": { + description: "Missing Location", + alias: "bt", + packageManager: "npm", + } as any, + }, + } as any, + }, +} as any; + +const invalidGlobalConfigMalformedSetting: Partial = { + ...globalConfig, + settings: { + ...globalConfig.settings, + defaultPackageManager: "invalid-package-manager-alias" as any, + }, +}; + +const invalidLocalConfigMissingRequiredSettings: Partial = { + ...localConfig, + settings: {} as any, +} as any; + describe("dk config list", () => { beforeAll(() => { vi.unmock("#utils/shell.js"); @@ -112,8 +148,8 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("Using local configuration."); expect(all).toContain("Javascript"); - expect(all).toContain("Node"); - expect(all).not.toContain("Python"); + expect(all).toContain("Nodejs"); + expect(all).not.toContain("Typescript"); }); describe("Include defaults option", () => { @@ -133,7 +169,7 @@ describe("dk config list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Available Templates:"); + expect(all).toContain("Modèles disponibles :"); expect(all).toContain("remix"); }); @@ -153,8 +189,14 @@ describe("dk config list", () => { ); expect(exitCode).toBe(0); + expect(all).toContain( + "Using global configuration.(including default templates)", + ); expect(all).toContain("Available Templates:"); expect(all).toContain("remix"); + expect(all).toContain("Typescript"); + expect(all).toContain("Javascript"); + expect(all).toContain("Nodejs"); }); it("should use both the `default` config and the local config if exists and `--include-defaults` is used", async () => { @@ -176,7 +218,7 @@ describe("dk config list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); expect(all).toContain("remix"); - expect(all).toContain("Node"); + expect(all).toContain("Nodejs"); expect(all).toContain("node-api"); }); @@ -202,8 +244,8 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("Using local and global configurations."); expect(all).toContain("Javascript"); - expect(all).toContain("Node"); - expect(all).toContain("Python"); + expect(all).toContain("Nodejs"); + expect(all).toContain("Typescript"); }); it("should use both the `default` config and the global config if exists and `--include-defaults` is used", async () => { @@ -222,11 +264,11 @@ describe("dk config list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Available Templates:"); + expect(all).toContain("Modèles disponibles :"); expect(all).toContain("Javascript"); expect(all).toContain("remix"); - expect(all).toContain("Python"); - expect(all).toContain("django"); + expect(all).toContain("Typescript"); + expect(all).toContain("ts-lib"); expect(all).not.toContain("node-api"); }); }); @@ -249,9 +291,9 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("Using global configuration."); - expect(all).toContain("Python"); + expect(all).toContain("Typescript"); expect(all).not.toContain("Javascript"); - expect(all).not.toContain("Node"); + expect(all).not.toContain("Nodejs"); }); it("should show an error when --global is used and no global config exists", async () => { @@ -274,7 +316,11 @@ describe("dk config list", () => { }); it("should handle a config file with an empty templates section", async () => { - const emptyConfig = { ...localConfig, templates: {} }; + const emptyConfig = { + ...localConfig, + templates: {}, + settings: { ...localConfig.settings }, + } as CliConfig; await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), emptyConfig); const { all, exitCode } = await execute( "bun", @@ -288,16 +334,20 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("No templates found in the configuration file."); expect(all).not.toContain("JAVASCRIPT"); - expect(all).not.toContain("NODE"); - expect(all).not.toContain("PYTHON"); + expect(all).not.toContain("NODEJS"); + expect(all).not.toContain("TYPESCRIPT"); }); it("should handle both local and global configs being empty", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { + ...localConfig, templates: {}, + settings: { ...localConfig.settings }, }); await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { + ...globalConfig, templates: {}, + settings: { ...globalConfig.settings }, }); const { all, exitCode } = await execute( @@ -312,7 +362,93 @@ describe("dk config list", () => { expect(exitCode).toBe(0); expect(all).toContain("No templates found in the configuration file."); expect(all).not.toContain("Javascript"); - expect(all).not.toContain("Node"); - expect(all).not.toContain("Python"); + expect(all).not.toContain("Nodejs"); + expect(all).not.toContain("Typescript"); + }); + + describe("Configuration Validation Failures", () => { + const VALIDATION_ERROR_MESSAGE = "Configuration validation failed."; + const TEMPLATE_ERROR_FRAGMENT = "is missing required field: 'location'"; + const SETTINGS_PM_ERROR_FRAGMENT = + "The value for setting 'defaultPackageManager' is invalid"; + const SETTINGS_MISSING_ERROR_FRAGMENT = + "The value for setting 'defaultPackageManager' is invalid or missing."; + + it("should fail and exit if local config is invalid (missing required template field)", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + invalidLocalConfigMissingLocation, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list"], + { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(TEMPLATE_ERROR_FRAGMENT); + }); + + it("should fail and exit if global config is invalid (malformed settings field) when using --all", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + invalidGlobalConfigMalformedSetting, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list", "--all"], + { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(SETTINGS_PM_ERROR_FRAGMENT); + }); + + it("should fail and exit if local config has empty/missing required settings fields", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + invalidLocalConfigMissingRequiredSettings, + ); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "list"], + { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }, + ); + + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(SETTINGS_MISSING_ERROR_FRAGMENT); + expect(all).toContain( + "The value for setting 'cacheStrategy' is invalid or missing.", + ); + expect(all).toContain( + "The value for setting 'language' is invalid or missing.", + ); + }); }); }); diff --git a/packages/devkit/__tests__/integrations/info.spec.ts b/packages/devkit/__tests__/integrations/info.spec.ts index 7966614..1f27e6a 100644 --- a/packages/devkit/__tests__/integrations/info.spec.ts +++ b/packages/devkit/__tests__/integrations/info.spec.ts @@ -10,11 +10,47 @@ import { } from "vitest"; import path from "path"; import os from "os"; -import { CLI_PATH, fs, CONFIG_FILE_NAMES, execute } from "./common.js"; +import { + CLI_PATH, + fs, + CONFIG_FILE_NAMES, + execute, + type CliConfig, + defaultCliConfig, +} from "./common.js"; const LOCAL_CONFIG_FILE_NAME = CONFIG_FILE_NAMES[1]; const GLOBAL_CONFIG_FILE_NAME = CONFIG_FILE_NAMES[0]; +const mockConfig: CliConfig = { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "en", + defaultPackageManager: "npm", + cacheStrategy: "always-refresh", + }, + templates: { + javascript: { + templates: { + basic: { + description: "basic js template", + location: "http://a", + alias: "b", + }, + }, + }, + }, +}; + +const invalidConfig: Partial = { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + defaultPackageManager: "invalid-pm" as any, + }, +} as any; + const escapeRegex = (string: string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; @@ -101,10 +137,10 @@ describe("dk info", () => { }); it("should report FOUND for both local and global config files when they exist", async () => { - await fs.writeJson(globalConfigPath, {}); + await fs.writeJson(globalConfigPath, mockConfig); const localConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - await fs.writeJson(localConfigPath, {}); + await fs.writeJson(localConfigPath, mockConfig); const { all, exitCode } = await execute("bun", [CLI_PATH, "info"], { all: true, @@ -127,9 +163,45 @@ describe("dk info", () => { ); }); + it("should display active configuration properties (Local config only)", async () => { + const localConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); + await fs.writeJson(localConfigPath, mockConfig); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "info"], { + all: true, + env: { HOME: MOCKED_GLOBAL_HOME_DIR }, + }); + + expect(exitCode).toBe(0); + const EXPECTED_FOUND = "[FOUND]"; + expect(all).toMatch( + createPaddedRegex( + "Local Config Path", + `${localConfigPath} ${EXPECTED_FOUND}`, + ), + ); + }); + + it("should report validation failure and exit with code 1 if a config file is invalid", async () => { + const localConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); + await fs.writeJson(localConfigPath, invalidConfig); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "info"], { + all: true, + env: { HOME: MOCKED_GLOBAL_HOME_DIR }, + reject: false, + }); + + expect(exitCode).toBe(1); + expect(all).toContain("Configuration validation failed."); + expect(all).toContain( + "The value for setting 'defaultPackageManager' is invalid", + ); + }); + it("should report FOUND for local and NOT FOUND for global config", async () => { const localConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - await fs.writeJson(localConfigPath, {}); + await fs.writeJson(localConfigPath, mockConfig); const { all, exitCode } = await execute("bun", [CLI_PATH, "in"], { all: true, diff --git a/packages/devkit/__tests__/integrations/init.spec.ts b/packages/devkit/__tests__/integrations/init.spec.ts index 27873c4..d0c86e0 100644 --- a/packages/devkit/__tests__/integrations/init.spec.ts +++ b/packages/devkit/__tests__/integrations/init.spec.ts @@ -13,330 +13,382 @@ import { CLI_PATH, fs, CONFIG_FILE_NAMES, - defaultCliConfig as schemaDefaultCliConfig, type CliConfig, - SCHEMA_PATH, + defaultCliConfig, execute, } from "./common.js"; const LOCAL_CONFIG_FILE_NAME = CONFIG_FILE_NAMES[1]; -const defaultCliConfig = { - $schema: SCHEMA_PATH, - ...schemaDefaultCliConfig, -}; +const GLOBAL_CONFIG_FILE_NAME = CONFIG_FILE_NAMES[0]; let tempDir: string; let originalCwd: string; +let globalConfigDir: string; +let localConfig: CliConfig; +let globalConfig: CliConfig; + +const invalidLocalConfigMalformedSetting: Partial = { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + defaultPackageManager: "invalid-package-manager-alias" as any, + }, +} as any; + +const invalidLocalConfigMissingRequiredSettings: Partial = { + ...defaultCliConfig, + settings: {} as any, +} as any; + +const createLocalConfig = async () => { + localConfig = { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "fr", + cacheStrategy: "always-refresh", + defaultPackageManager: "npm", + }, + }; + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); +}; + +const createGlobalConfig = async () => { + globalConfig = { + ...defaultCliConfig, + settings: { + ...defaultCliConfig.settings, + language: "en", + cacheStrategy: "daily", + defaultPackageManager: "bun", + }, + }; + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); +}; -describe("dk init", () => { +describe("dk config", () => { beforeAll(() => { vi.unmock("#utils/shell.js"); }); beforeEach(async () => { originalCwd = process.cwd(); - tempDir = path.join(os.tmpdir(), `devkit-test-${Date.now()}`); - await fs.ensureDir(tempDir); + tempDir = path.join(os.tmpdir(), `devkit-test-config-${Date.now()}`); + globalConfigDir = path.join( + os.tmpdir(), + `devkit-global-config-dir-${Date.now()}`, + ); + await fs.ensureDir(tempDir); process.chdir(tempDir); - process.env.HOME = os.tmpdir(); + await fs.ensureDir(globalConfigDir); }); afterEach(async () => { process.chdir(originalCwd); await fs.remove(tempDir); - - delete process.env.HOME; + await fs.remove(globalConfigDir); }); - it("should create a local config file in a bare directory", async () => { - process.env.HOME = tempDir; - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - env: { HOME: tempDir }, - }); + describe("Core Behavior", () => { + it("should warn the user when no command or option is provided", async () => { + const { all, exitCode } = await execute("bun", [CLI_PATH, "config"], { + all: true, + reject: false, + }); - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - const configPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); + expect(exitCode).toBe(0); + expect(all).toContain( + "Warning: No command or option provided. Use `dk config --help` to see available commands.", + ); + }); - const fileExists = await fs.pathExists(configPath); - expect(fileExists).toBe(true); - const configContent = await fs.readJson(configPath); - expect(configContent).toEqual(defaultCliConfig); + it("should throw an error if no config file is found for setting", async () => { + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "conf", "--set", "lang", "en"], + { all: true, reject: false }, + ); - await fs.remove(configPath); + expect(exitCode).toBe(1); + expect(all).toContain( + "::[DEV]>> Devkit encountered an unexpected internal issue: No local configuration file found. Run 'devkit config init --local' to create one.", + ); + }); }); - it("should create a global config file when --global flag is used", async () => { - const homedir = os.homedir(); - const globalConfigPath = path.join(homedir, CONFIG_FILE_NAMES[0]); - - if (await fs.pathExists(globalConfigPath)) { - await fs.remove(globalConfigPath); - } - - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "init", "--global"], - { all: true }, - ); + describe("GET Functionality (Reading Config)", () => { + it("should get a single setting from the local config using the full key", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "language"], + { all: true }, + ); - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - const fileExists = await fs.pathExists(globalConfigPath); - expect(fileExists).toBe(true); + expect(exitCode).toBe(0); + expect(all).toContain("language: fr"); + expect(all).toContain( + "Paramètres de configuration récupérés avec succès.", + ); + }); - await fs.remove(globalConfigPath); - }); -}); + it("should get a single setting from the local config using a short alias (lang)", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "lang"], + { all: true }, + ); -describe("dk init with existing file", () => { - beforeAll(() => { - vi.unmock("#utils/shell.js"); - }); + expect(exitCode).toBe(0); + expect(all).toContain("lang: fr"); + expect(all).toContain( + "Paramètres de configuration récupérés avec succès.", + ); + }); - const basicConfig: CliConfig = { - templates: {}, - settings: { - defaultPackageManager: "bun", - cacheStrategy: "daily", - language: "en", - }, - }; + it("should get multiple settings from the local config using full keys", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "language", "cacheStrategy"], + { all: true }, + ); - let rootConfigPath = ""; - beforeEach(async () => { - originalCwd = process.cwd(); - tempDir = path.join(os.tmpdir(), `devkit-test-${Date.now()}`); - await fs.ensureDir(tempDir); - rootConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); + expect(exitCode).toBe(0); + expect(all).toContain("language: fr"); + expect(all).toContain("cacheStrategy: always-refresh"); + expect(all).toContain( + "Paramètres de configuration récupérés avec succès.", + ); + }); - process.chdir(tempDir); - process.env.HOME = os.tmpdir(); - await fs.writeJson(rootConfigPath, basicConfig); - }); + it("should get multiple settings from the local config using short aliases (pm, cache)", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "pm", "cache"], + { all: true }, + ); - afterEach(async () => { - process.chdir(originalCwd); - await fs.remove(tempDir); + expect(exitCode).toBe(0); + expect(all).toContain("pm: npm"); + expect(all).toContain("cache: always-refresh"); + expect(all).toContain( + "Paramètres de configuration récupérés avec succès.", + ); + }); - delete process.env.HOME; - }); + it("should get a setting from the global config with --global flag", async () => { + await createGlobalConfig(); + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "language", "--global"], + { all: true, env: { HOME: globalConfigDir } }, + ); - it("should prompt for override the existing global config when --global flag is used and there is already an existing config file", async () => { - const homedir = os.homedir(); - const globalConfigPath = path.join(homedir, CONFIG_FILE_NAMES[0]); + expect(exitCode).toBe(0); + expect(all).toContain("language: en"); + expect(all).toContain( + "Paramètres de configuration récupérés avec succès.", + ); + }); - if (!(await fs.pathExists(globalConfigPath))) { - await fs.writeJson(globalConfigPath, { - ...defaultCliConfig, - settings: { - ...defaultCliConfig.settings, - cacheStrategy: "always-refresh", - }, - }); - } + it("should fail gracefully if a key to get is not found", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "non_existent_key"], + { all: true }, + ); - const { all, exitCode } = await execute( - "bun", - [CLI_PATH, "init", "--global"], - { - all: true, - input: "\n", - }, - ); + expect(exitCode).toBe(0); + expect(all).toContain( + "Clé de configuration 'non_existent_key' introuvable.", + ); + }); + }); - expect(exitCode).toBe(0); - expect(all).toContain( - `Config file already exists at ${globalConfigPath}. Do you want to overwrite it?`, - ); + describe("SET Functionality (Updating Config)", () => { + it("should set a single setting in the local config using full key", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "--set", "language", "en"], + { all: true }, + ); - expect(all).toContain("Configuration file created successfully!"); - const fileExists = await fs.pathExists(globalConfigPath); - expect(fileExists).toBe(true); + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); - const newGlobalConfig = await fs.readJson(globalConfigPath); - expect(newGlobalConfig).toEqual(defaultCliConfig); + expect(exitCode).toBe(0); + expect(all).toContain("Configuration mise à jour avec succès !"); + expect(updatedConfig.settings.language).toBe("en"); + }); - await fs.remove(globalConfigPath); - }); + it("should set a single setting in the local config using a short alias (lang)", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "--set", "lang", "en"], + { all: true }, + ); - it("should not overwrite the file if user selects 'no'", async () => { - const initialContent = await fs.readJson(rootConfigPath); - expect(initialContent).toEqual(basicConfig); + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - input: "\u001b[B\n", + expect(exitCode).toBe(0); + expect(all).toContain("Configuration mise à jour avec succès !"); + expect(updatedConfig.settings.language).toBe("en"); + expect(updatedConfig.settings.defaultPackageManager).toBe("npm"); }); - expect(exitCode).toBe(0); - expect(all).toContain("Operation aborted."); - const newContent = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - expect(newContent).toEqual(basicConfig); - }); + it("should set multiple settings in the local config using full keys", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [ + CLI_PATH, + "config", + "--set", + "language", + "en", + "cacheStrategy", + "never-refresh", + ], + { all: true }, + ); - it("should overwrite the file if user selects 'yes'", async () => { - const initialContent = await fs.readJson(rootConfigPath); - expect(initialContent).toEqual(basicConfig); + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - input: "\n", + expect(exitCode).toBe(0); + expect(all).toContain("Configuration mise à jour avec succès !"); + expect(updatedConfig.settings.language).toBe("en"); + expect(updatedConfig.settings.cacheStrategy).toBe("never-refresh"); }); - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - const newContent = await fs.readJson( - path.join(tempDir, LOCAL_CONFIG_FILE_NAME), - ); - expect(newContent).not.toEqual(basicConfig); - expect(newContent).toEqual(defaultCliConfig); - }); - - describe("In a sub directory", () => { - it("should ask to override at the root and if `yes`, override", async () => { - const initialContent = await fs.readJson(rootConfigPath); - expect(initialContent).toEqual(basicConfig); - - const subDirectory = path.join(tempDir, "src", "utils"); - await fs.ensureDir(subDirectory); + it("should set multiple settings in the local config using short aliases (pm, cache)", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "-s", "pm", "pnpm", "cache", "never-refresh"], + { all: true }, + ); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - input: "\n", - }); + const updatedConfig = await fs.readJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + ); expect(exitCode).toBe(0); - expect(all).toContain(`Config file already exists at ${rootConfigPath}`); - - expect(all).toContain("Configuration file created successfully!"); - const newContent = await fs.readJson(rootConfigPath); - - expect(newContent).not.toEqual(basicConfig); - expect(newContent).toEqual(defaultCliConfig); + expect(all).toContain("Configuration mise à jour avec succès !"); + expect(updatedConfig.settings.defaultPackageManager).toBe("pnpm"); + expect(updatedConfig.settings.cacheStrategy).toBe("never-refresh"); }); - }); -}); - -describe("dk init in a monorepo", () => { - beforeAll(() => { - vi.unmock("#utils/shell.js"); - }); - beforeEach(async () => { - originalCwd = process.cwd(); - tempDir = path.join(os.tmpdir(), `devkit-test-monorepo-${Date.now()}`); - await fs.ensureDir(tempDir); - await fs.ensureDir(path.join(tempDir, "node_modules")); - process.chdir(tempDir); - - process.env.HOME = tempDir; + it("should set a single setting in the global config with --global flag", async () => { + await createGlobalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "--global", "--set", "language", "fr"], + { all: true, env: { HOME: globalConfigDir } }, + ); - await fs.writeJson(path.join(tempDir, "package.json"), { - private: true, - workspaces: ["packages/*"], - }); + const updatedConfig = await fs.readJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + ); - const nestedPackagePath = path.join(tempDir, "packages", "my-app"); - await fs.ensureDir(nestedPackagePath); - await fs.writeJson(path.join(nestedPackagePath, "package.json"), { - name: "my-app", + expect(exitCode).toBe(0); + expect(all).toContain("Configuration updated successfully!"); + expect(updatedConfig.settings.language).toBe("fr"); }); - }); - afterEach(async () => { - process.chdir(originalCwd); - await fs.remove(tempDir); - delete process.env.HOME; - }); + it("should fail if --set has an odd number of arguments", async () => { + await createLocalConfig(); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "--set", "language", "en", "invalid"], + { all: true, reject: false }, + ); - it("should create a config in the monorepo root", async () => { - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - cwd: tempDir, + expect(exitCode).toBe(0); + expect(all).toContain( + "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (ex: --set clé1 valeur1 clé2 valeur2).", + ); }); - - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - const rootConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - const fileExists = await fs.pathExists(rootConfigPath); - expect(fileExists).toBe(true); - const configContent = await fs.readJson(rootConfigPath); - expect(configContent).toEqual(defaultCliConfig); }); - it("should overwrite the root config when the user confirms", async () => { - const rootConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - const rootConfigContent = { - settings: { defaultPackageManager: "yarn" }, - }; - await fs.writeJson(rootConfigPath, rootConfigContent); - - const initialContent = await fs.readJson(rootConfigPath); - expect(initialContent).toEqual(rootConfigContent); + describe("Configuration Validation Failures", () => { + const VALIDATION_ERROR_MESSAGE = + "Failed to read configuration: Configuration validation failed"; + const SETTINGS_PM_ERROR_FRAGMENT = + "The value for setting 'defaultPackageManager' is invalid"; + const SETTINGS_MISSING_ERROR_FRAGMENT = + "The value for setting 'defaultPackageManager' is invalid or missing."; + + it("should fail and exit if local config is invalid (malformed settings field) during SET", async () => { + await createLocalConfig(); + + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "--set", "pm", "invalid-pm"], + { + all: true, + reject: false, + }, + ); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - cwd: tempDir, - input: "\n", + expect(exitCode).toBe(1); + expect(all).toContain("Devkit a rencontré un problème interne inattendu"); + expect(all).not.toContain(SETTINGS_PM_ERROR_FRAGMENT); }); - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - - const newContent = await fs.readJson(rootConfigPath); - expect(newContent).toEqual(defaultCliConfig); - }); - - it("should not overwrite the root config when the user declines", async () => { - const rootConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - const rootConfigContent = { - settings: { defaultPackageManager: "yarn" }, - }; - await fs.writeJson(rootConfigPath, rootConfigContent); + it("should fail and exit if local config is invalid (malformed settings field) during GET", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + invalidLocalConfigMalformedSetting, + ); - const initialContent = await fs.readJson(rootConfigPath); - expect(initialContent).toEqual(rootConfigContent); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "language"], + { + all: true, + reject: false, + }, + ); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - cwd: tempDir, - input: "\u001b[B\n", + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(SETTINGS_PM_ERROR_FRAGMENT); }); - expect(exitCode).toBe(0); - expect(all).toContain("Operation aborted."); - - const newContent = await fs.readJson(rootConfigPath); - expect(newContent).toEqual(rootConfigContent); - }); + it("should fail and exit if local config is invalid (missing required settings fields)", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + invalidLocalConfigMissingRequiredSettings, + ); - describe("In a package", () => { - it("should ask to override at the root even if inside a package and if `yes`, override", async () => { - const nestedPackagePath = path.join(tempDir, "packages", "my-app"); - const { all, exitCode } = await execute("bun", [CLI_PATH, "init"], { - all: true, - cwd: nestedPackagePath, - }); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "config", "language"], + { + all: true, + reject: false, + }, + ); - expect(exitCode).toBe(0); - expect(all).toContain("Configuration file created successfully!"); - const rootConfigPath = path.join(tempDir, LOCAL_CONFIG_FILE_NAME); - const fileExists = await fs.pathExists(rootConfigPath); - expect(fileExists).toBe(true); - const configContent = await fs.readJson(rootConfigPath); - expect(configContent).toEqual(defaultCliConfig); - - const nestedConfigPath = path.join( - nestedPackagePath, - LOCAL_CONFIG_FILE_NAME, - ); - const nestedFileExists = await fs.pathExists(nestedConfigPath); - expect(nestedFileExists).toBe(false); + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(SETTINGS_MISSING_ERROR_FRAGMENT); }); }); }); diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index de1b055..a1ff947 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -68,18 +68,41 @@ const globalConfig: CliConfig = { language: "fr", }, templates: { - python: { + typescript: { templates: { - django: { - description: "A Django template", - location: "https://github.com/django/django", - alias: "dj", + "ts-lib": { + description: "A TypeScript library template", + location: "https://github.com/ts-lib-template", + alias: "tl", }, }, }, }, }; +const invalidLocalConfigMissingLocation: Partial = { + ...localConfig, + templates: { + javascript: { + templates: { + "bad-template": { + description: "Missing Location", + alias: "bt", + packageManager: "npm", + } as any, + }, + } as any, + }, +} as any; + +const invalidGlobalConfigMalformedSetting: Partial = { + ...globalConfig, + settings: { + ...globalConfig.settings, + defaultPackageManager: "invalid-package-manager-alias" as any, + }, +} as any; + describe("dk list", () => { beforeAll(() => { vi.unmock("#utils/shell.js"); @@ -120,7 +143,7 @@ describe("dk list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); expect(all).toContain("Nodejs"); - expect(all).not.toContain("Python"); + expect(all).not.toContain("Typescript"); }); it("should list templates from both local and global configurations when --all is used", async () => { @@ -144,7 +167,7 @@ describe("dk list", () => { expect(all).toContain("Available Templates:"); expect(all).toContain("Javascript"); expect(all).toContain("Nodejs"); - expect(all).toContain("Python"); + expect(all).toContain("Typescript"); }); it("should only list templates from global config when --global is used", async () => { @@ -169,7 +192,7 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain("Configuration sources loaded successfully."); expect(all).toContain("Available Templates:"); - expect(all).toContain("Python"); + expect(all).toContain("Typescript"); expect(all).not.toContain("Javascript"); expect(all).not.toContain("Nodejs"); }); @@ -328,7 +351,7 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain("Settings:"); expect(all).toContain("language"); - expect(all).toContain("es"); + expect(all).toContain("en"); expect(all).toContain("Available Templates:"); }); @@ -351,16 +374,34 @@ describe("dk list", () => { expect(exitCode).toBe(0); expect(all).toContain("Settings:"); expect(all).toContain("language"); - expect(all).toContain("es"); + expect(all).toContain("en"); expect(all).toContain("Available Templates:"); }); - it("should include defaults when --include-defaults is used", async () => { - await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { - settings: {}, - templates: {}, - }); + it("should display settings from merged config when --settings, --all and mode=table are used", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + const { all, exitCode } = await execute( + "bun", + [CLI_PATH, "list", "--settings", "--all", "--mode", "table"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("Settings:"); + expect(all).toContain("language"); + expect(all).toContain("en"); + expect(all).toContain("Available Templates:"); + }); + + it("should include defaults when --include-defaults is used", async () => { const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--include-defaults"], @@ -435,16 +476,7 @@ describe("dk list", () => { expect(all).not.toContain("Available Templates:"); }); - it("should handle both local and global configs being empty (warns)", async () => { - await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { - settings: {}, - templates: {}, - }); - await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { - settings: {}, - templates: {}, - }); - + it("should handle both local and global configs are empty (warns)", async () => { const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--all"], @@ -464,6 +496,7 @@ describe("dk list", () => { path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig, ); + await fs.writeJson( path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), globalConfig, @@ -484,7 +517,7 @@ describe("dk list", () => { expect(all).toContain("Language"); expect(all).toContain("Javascript"); expect(all).toContain("Nodejs"); - expect(all).not.toContain("Python"); + expect(all).not.toContain("Typescript"); }); it("should filter templates by 'js' language alias in table mode", async () => { @@ -509,15 +542,6 @@ describe("dk list", () => { }); it("should handle both local and global configs being empty", async () => { - await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), { - settings: {}, - templates: {}, - }); - await fs.writeJson(path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), { - settings: {}, - templates: {}, - }); - const { all, exitCode } = await execute( "bun", [CLI_PATH, "list", "--all", "--mode", "table"], @@ -555,7 +579,7 @@ describe("dk list", () => { expect(all).toContain("vue-basic"); expect(all).not.toContain("react-ts"); expect(all).not.toContain("Nodejs"); - expect(all).not.toContain("Python"); + expect(all).not.toContain("Typescript"); }); }); @@ -646,9 +670,61 @@ describe("dk list", () => { ); expect(all).toContain("Javascript"); expect(all).toContain("remix"); - expect(all).toContain("Python"); - expect(all).toContain("django"); + expect(all).toContain("Typescript"); + expect(all).toContain("ts-lib"); expect(all).not.toContain("node-api"); }); }); + + describe("Configuration Validation Failures (New Feature Tests)", () => { + const VALIDATION_ERROR_MESSAGE = + "[CONFIG]>> Failed to read configuration: Configuration validation failed."; + const TEMPLATE_ERROR_FRAGMENT = "is missing required field: 'location'"; + const SETTINGS_ERROR_FRAGMENT = + "The value for setting 'defaultPackageManager' is invalid"; + + it("should fail and exit if local config is invalid (missing required template field)", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + invalidLocalConfigMissingLocation, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + globalConfig, + ); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "list"], { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }); + + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(TEMPLATE_ERROR_FRAGMENT); + expect(all).not.toContain("Available Templates:"); + }); + + it("should fail and exit if global config is invalid (malformed settings field) even when listing local", async () => { + await fs.writeJson( + path.join(tempDir, LOCAL_CONFIG_FILE_NAME), + localConfig, + ); + await fs.writeJson( + path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), + invalidGlobalConfigMalformedSetting, + ); + + const { all, exitCode } = await execute("bun", [CLI_PATH, "list"], { + all: true, + env: { HOME: globalConfigDir }, + reject: false, + }); + + expect(exitCode).toBe(1); + expect(all).toContain(VALIDATION_ERROR_MESSAGE); + expect(all).toContain(SETTINGS_ERROR_FRAGMENT); + expect(all).not.toContain("Available Templates:"); + }); + }); }); diff --git a/packages/devkit/__tests__/units/commands/index.spec.ts b/packages/devkit/__tests__/units/commands/index.spec.ts index c8683a2..3ff2195 100644 --- a/packages/devkit/__tests__/units/commands/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/index.spec.ts @@ -2,6 +2,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import { mockProgram, mockSpinner, mockLogger } from "../../../vitest.setup.js"; import { setupAndParse } from "../../../src/commands/index.js"; import type { CliConfig } from "../../../src/utils/schema/schema.js"; +import { ConfigError } from "../../../src/utils/errors/base.js"; const { mockSetupInitCommand, @@ -13,6 +14,7 @@ const { mockSetupInfoCommand, mockLoadTranslations, mockT, + mockValidateConfig, } = vi.hoisted(() => ({ mockSetupInitCommand: vi.fn(), mockSetupNewCommand: vi.fn(), @@ -23,6 +25,7 @@ const { mockSetupInfoCommand: vi.fn(), mockLoadTranslations: vi.fn().mockResolvedValue(undefined), mockT: vi.fn((key) => key), + mockValidateConfig: vi.fn(), })); vi.mock("#commands/init/index.js", () => ({ @@ -57,6 +60,10 @@ vi.mock("#core/config/loader.js", () => ({ readConfigSources: mockReadConfigSources, })); +vi.mock("#core/config/validation.js", () => ({ + validateConfig: mockValidateConfig, +})); + vi.mock("#utils/i18n/translation-loader.js", () => ({ loadTranslations: mockLoadTranslations, })); @@ -67,7 +74,6 @@ vi.mock("#utils/i18n/translator.js", () => ({ const warnSpy = mockLogger.warning; const optsSpy = vi.spyOn(mockProgram, "opts"); -const parseOptionsSpy = vi.spyOn(mockProgram, "parseOptions"); const mockLocalConfig: Partial = { settings: { @@ -104,55 +110,52 @@ describe("index.ts (Entry point)", () => { default: mockDefaultConfig, configFound: true, }); + + mockValidateConfig.mockImplementation((config) => config); }); afterEach(() => { vi.useRealTimers(); }); - describe("Initialization", () => { - it("should initialize the CLI and set up commands correctly in non-verbose mode", async () => { + describe("Initialization and Translation Loading", () => { + it("should call loadTranslations twice: once for system locale, once for config locale", async () => { optsSpy.mockReturnValue({}); await setupAndParse(); await vi.runAllTimersAsync(); - expect(parseOptionsSpy).toHaveBeenCalledOnce(); - expect(mockSpinner.start).toHaveBeenCalledWith(""); - expect(mockSpinner.stop).toHaveBeenCalledOnce(); - expect(mockSpinner.succeed).not.toHaveBeenCalled(); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(1, null); - expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(2, "fr"); }); - it("should display a success message and info spinner in verbose mode", async () => { - optsSpy.mockReturnValue({ verbose: true }); + it("should successfully validate local and global configs before reading language setting", async () => { + optsSpy.mockReturnValue({}); await setupAndParse(); await vi.runAllTimersAsync(); - expect(parseOptionsSpy).toHaveBeenCalledOnce(); - expect(mockSpinner.start).toHaveBeenCalledWith( - expect.stringContaining("program.status.initializing"), - ); - expect(mockSpinner.succeed).toHaveBeenCalledOnce(); - expect(mockSpinner.succeed).toHaveBeenCalledWith( - expect.stringContaining("messages.success.program_initialized"), - ); - expect(mockSpinner.stop).toHaveBeenCalled(); + expect(mockValidateConfig).toHaveBeenCalledTimes(2); + expect(mockValidateConfig).toHaveBeenCalledWith(mockLocalConfig); + expect(mockValidateConfig).toHaveBeenCalledWith(mockGlobalConfig); - expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(2, "fr"); }); - it("should prioritize local language setting for translations, then global, then null", async () => { + it("should prioritize local language setting, then global, then null/os-locale", async () => { mockReadConfigSources.mockResolvedValueOnce({ local: { settings: { language: "fr" } }, global: { settings: { language: "en" } }, configFound: true, }); + mockValidateConfig + .mockResolvedValueOnce({ settings: { language: "fr" } }) + .mockResolvedValueOnce({ settings: { language: "en" } }); + await setupAndParse(); await vi.runAllTimersAsync(); - expect(mockLoadTranslations).toHaveBeenCalledWith("fr"); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(2, "fr"); vi.clearAllMocks(); mockReadConfigSources.mockResolvedValueOnce({ @@ -160,21 +163,29 @@ describe("index.ts (Entry point)", () => { global: { settings: { language: "es" } }, configFound: true, }); + mockValidateConfig.mockResolvedValueOnce({ + settings: { language: "es" }, + }); mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); - expect(mockLoadTranslations).toHaveBeenCalledWith("es"); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(1, null); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(2, "es"); vi.clearAllMocks(); mockReadConfigSources.mockResolvedValueOnce({ - local: null, + local: { settings: { some_other_setting: true } }, global: null, - configFound: false, + configFound: true, + }); + mockValidateConfig.mockResolvedValueOnce({ + settings: { some_other_setting: true }, }); mockProgram.parse.mockReturnValue(mockProgram); await setupAndParse(); await vi.runAllTimersAsync(); - expect(mockLoadTranslations).toHaveBeenCalledWith(null); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(1, null); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(2, null); }); it("should display a warning if configFound is false (always visible)", async () => { @@ -196,43 +207,29 @@ describe("index.ts (Entry point)", () => { }); }); - describe("Command Setup and Execution", () => { - it("should set up all commands passing ONLY the program object", async () => { - optsSpy.mockReturnValueOnce({}); + describe("Error Handling", () => { + it("should handle and exit gracefully on a validation error (ConfigError)", async () => { + const testError = new ConfigError("Template is malformed"); + + mockValidateConfig.mockRejectedValue(testError); + optsSpy.mockReturnValue({}); await setupAndParse(); await vi.runAllTimersAsync(); - const expectedArg = { program: mockProgram }; - - expect(mockSetupInitCommand).toHaveBeenCalledOnce(); - expect(mockSetupInitCommand).toHaveBeenCalledWith(expectedArg); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(1, null); - expect(mockSetupNewCommand).toHaveBeenCalledOnce(); - expect(mockSetupNewCommand).toHaveBeenCalledWith(expectedArg); + expect(mockValidateConfig).toHaveBeenCalledOnce(); - expect(mockSetupConfigCommand).toHaveBeenCalledOnce(); - expect(mockSetupConfigCommand).toHaveBeenCalledWith(mockProgram); - - expect(mockSetupListCommand).toHaveBeenCalledOnce(); - expect(mockSetupListCommand).toHaveBeenCalledWith(expectedArg); - - expect(mockSetupInfoCommand).toHaveBeenCalledOnce(); - expect(mockSetupInfoCommand).toHaveBeenCalledWith(expectedArg); - - expect(mockProgram.name).toHaveBeenCalledWith("devkit"); - expect(mockProgram.alias).toHaveBeenCalledWith("dk"); - expect(mockProgram.version).toHaveBeenCalledWith( - "1.0.0", - "-V, --version", - "program.version.description", + expect(mockHandleErrorAndExit).toHaveBeenCalledWith( + testError, + mockSpinner, ); - expect(mockProgram.parse).toHaveBeenCalledOnce(); + + expect(mockProgram.parse).not.toHaveBeenCalled(); }); - }); - describe("Error Handling", () => { - it("should handle and exit gracefully on an initialization error", async () => { + it("should handle and exit gracefully on an initialization error (Read Error)", async () => { const testError = new Error("Config load failed"); mockReadConfigSources.mockRejectedValue(testError); optsSpy.mockReturnValue({}); @@ -240,13 +237,36 @@ describe("index.ts (Entry point)", () => { await setupAndParse(); await vi.runAllTimersAsync(); + expect(mockLoadTranslations).toHaveBeenNthCalledWith(1, null); + expect(mockReadConfigSources).toHaveBeenCalledOnce(); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( testError, mockSpinner, ); expect(mockProgram.parse).not.toHaveBeenCalled(); - expect(mockLoadTranslations).not.toHaveBeenCalled(); + + expect(mockValidateConfig).not.toHaveBeenCalled(); + }); + }); + + describe("Command Setup and Execution", () => { + it("should set up all commands passing ONLY the program object", async () => { + optsSpy.mockReturnValueOnce({}); + + await setupAndParse(); + await vi.runAllTimersAsync(); + + const expectedArg = { program: mockProgram }; + + expect(mockSetupInitCommand).toHaveBeenCalledWith(expectedArg); + expect(mockSetupNewCommand).toHaveBeenCalledWith(expectedArg); + expect(mockSetupConfigCommand).toHaveBeenCalledWith(mockProgram); + expect(mockSetupListCommand).toHaveBeenCalledWith(expectedArg); + expect(mockSetupInfoCommand).toHaveBeenCalledWith(expectedArg); + + expect(mockProgram.name).toHaveBeenCalledWith("devkit"); + expect(mockProgram.parse).toHaveBeenCalledOnce(); }); }); }); diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index e38475b..7a34b1b 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -290,6 +290,7 @@ describe("list command", () => { expect(mockPrintSettings).toHaveBeenCalledWith( MOCK_CLI_CONFIG_WITH_SETTINGS.settings, + "tree", ); expect(mockLogger.log).toHaveBeenCalledWith( diff --git a/packages/devkit/__tests__/units/core/config/loader.spec.ts b/packages/devkit/__tests__/units/core/config/loader.spec.ts index cded70e..7389615 100644 --- a/packages/devkit/__tests__/units/core/config/loader.spec.ts +++ b/packages/devkit/__tests__/units/core/config/loader.spec.ts @@ -5,12 +5,19 @@ import { } from "../../../../src/core/config/loader.js"; import { defaultCliConfig } from "../../../../src/utils/schema/schema.js"; -const { mockFs, mockGetConfigPathSources } = vi.hoisted(() => ({ +const MOCK_I18N_ERROR = "i18n-read-fail"; +const MOCK_I18N_WARNING = "i18n-warning-not-found"; + +const { mockFs, mockGetConfigPathSources, mockLogger } = vi.hoisted(() => ({ mockFs: { pathExists: vi.fn(), readJson: vi.fn(), }, mockGetConfigPathSources: vi.fn(), + mockLogger: { + error: vi.fn(), + warning: vi.fn(), + }, })); vi.mock("../../../../src/core/config/finder.js", () => ({ @@ -24,21 +31,33 @@ vi.mock("#utils/fs/file.js", () => ({ }, })); +vi.mock("#utils/logger.js", () => ({ + logger: mockLogger, +})); + +vi.mock("#utils/i18n/translator.js", () => ({ + t: vi.fn((key) => { + if (key.includes("read_fail")) return MOCK_I18N_ERROR; + if (key.includes("not_found")) return MOCK_I18N_WARNING; + return key; + }), +})); + const mockStructuredClone = vi.fn((obj) => JSON.parse(JSON.stringify(obj))); vi.stubGlobal("structuredClone", mockStructuredClone); describe("Configuration Loader Functions", () => { + const localConfig = { settings: { language: "fr" } }; + const globalConfig = { settings: { defaultPackageManager: "pnpm" } }; + const localPath = "/project/.devkitrc"; + const globalPath = "/user/.devkitrc"; + beforeEach(() => { vi.clearAllMocks(); mockFs.pathExists.mockResolvedValue(true); }); describe("readConfigSources", () => { - const localConfig = { settings: { language: "fr" } }; - const globalConfig = { settings: { defaultPackageManager: "pnpm" } }; - const localPath = "/project/.devkitrc"; - const globalPath = "/user/.devkitrc"; - it("should load all three sources (Local, Global, Default) when both config files exist", async () => { mockGetConfigPathSources.mockResolvedValue({ localPath: localPath, @@ -64,6 +83,7 @@ describe("Configuration Loader Functions", () => { expect(sources.configFound).toBe(true); expect(mockStructuredClone).toHaveBeenCalledTimes(3); + expect(mockLogger.error).not.toHaveBeenCalled(); }); it("should load only Global and Default when Local path is null/config doesn't exist", async () => { @@ -85,6 +105,7 @@ describe("Configuration Loader Functions", () => { expect(sources.global).toEqual(globalConfig); expect(sources.default).toEqual(defaultCliConfig); expect(sources.configFound).toBe(true); + expect(mockLogger.error).not.toHaveBeenCalled(); }); it("should return null for Local and Global when neither config file exists", async () => { @@ -103,13 +124,10 @@ describe("Configuration Loader Functions", () => { expect(sources.global).toBeNull(); expect(sources.default).toEqual(defaultCliConfig); expect(sources.configFound).toBe(false); + expect(mockLogger.error).not.toHaveBeenCalled(); }); - it("should handle JSON parsing errors gracefully and return null for the corrupted config", async () => { - const consoleErrorSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - + it("should handle JSON parsing errors gracefully using logger.error and return null for the corrupted config", async () => { mockGetConfigPathSources.mockResolvedValue({ localPath: localPath, globalPath: globalPath, @@ -121,16 +139,13 @@ describe("Configuration Loader Functions", () => { const sources: ConfigurationSources = await readConfigSources(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Warning: Failed to parse configuration file at "${localPath}". The file may be invalid.`, - undefined, - ); + expect(mockLogger.error).toHaveBeenCalledWith(MOCK_I18N_ERROR, "ERR"); + + expect(mockLogger.warning).toHaveBeenCalledWith(MOCK_I18N_WARNING); expect(sources.local).toBeNull(); expect(sources.global).toEqual(globalConfig); expect(sources.configFound).toBe(true); - - consoleErrorSpy.mockRestore(); }); it("should pass options (e.g., forceGlobal) to getConfigPathSources", async () => { diff --git a/packages/devkit/__tests__/units/core/config/validation.spec.ts b/packages/devkit/__tests__/units/core/config/validation.spec.ts new file mode 100644 index 0000000..ed183ff --- /dev/null +++ b/packages/devkit/__tests__/units/core/config/validation.spec.ts @@ -0,0 +1,280 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { validateConfig } from "../../../../src/core/config/validation.js"; +import { ConfigError, DevkitError } from "../../../../src/utils/errors/base.js"; +import { mocktFn } from "../../../../vitest.setup.js"; + +const { + mockValidatePackageManager, + mockValidateCacheStrategy, + mockValidateLanguage, + mockValidateProgrammingLanguage, + mockValidateDescription, +} = vi.hoisted(() => ({ + mockValidatePackageManager: vi.fn(), + mockValidateCacheStrategy: vi.fn(), + mockValidateLanguage: vi.fn(), + mockValidateProgrammingLanguage: vi.fn(), + mockValidateDescription: vi.fn(), +})); + +vi.mock("#utils/validations/config.js", () => ({ + validatePackageManager: mockValidatePackageManager, + validateCacheStrategy: mockValidateCacheStrategy, + validateLanguage: mockValidateLanguage, + validateProgrammingLanguage: mockValidateProgrammingLanguage, +})); + +vi.mock("#utils/validations/templates.js", () => ({ + validateDescription: mockValidateDescription, +})); + +const VALID_CONFIG = { + templates: { + javascript: { + templates: { + vue: { + location: "file://./vue", + description: "A Vue project.", + alias: "v", + packageManager: "npm", + cacheStrategy: "daily", + }, + }, + }, + typescript: { + templates: { + node: { + location: "file://./node", + description: "A Node project.", + }, + }, + }, + }, + settings: { + defaultPackageManager: "npm", + cacheStrategy: "daily", + language: "en", + }, +}; + +const ERROR_KEYS = { + MALFORMED_ROOT: "errors.config.malformed_root", + VALIDATION_FAILED: "errors.config.validation_failed", + MISSING_FIELD: "errors.config.missing_or_malformed", + SETTING_INVALID: "errors.config.setting_invalid", + INVALID_LANG_KEY: "errors.config.invalid_language_key", + TEMPLATE_STRUCTURE: "errors.config.template_structure_malformed", + TEMPLATE_MALFORMED: "errors.config.template_malformed", + TEMPLATE_FIELD_MISSING: "errors.config.template_field_missing", + TEMPLATE_FIELD_INVALID: "errors.config.template_field_invalid", +}; + +describe("validateConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidatePackageManager.mockImplementation(() => {}); + mockValidateCacheStrategy.mockImplementation(() => {}); + mockValidateLanguage.mockImplementation(() => {}); + mockValidateProgrammingLanguage.mockImplementation(() => {}); + mockValidateDescription.mockImplementation(() => {}); + }); + + it("should succeed for a valid configuration object", async () => { + const result = await validateConfig(VALID_CONFIG); + + expect(result).toEqual(VALID_CONFIG); + + expect(mockValidatePackageManager).toHaveBeenCalledTimes(2); + expect(mockValidateCacheStrategy).toHaveBeenCalledTimes(2); + expect(mockValidateLanguage).toHaveBeenCalledTimes(1); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledTimes(2); + expect(mockValidateDescription).toHaveBeenCalledTimes(2); + }); + + it("should throw ConfigError if config is null or a primitive", async () => { + await expect(validateConfig(null)).rejects.toThrow(ConfigError); + await expect(validateConfig(null)).rejects.toThrow( + mocktFn(ERROR_KEYS.MALFORMED_ROOT), + ); + + await expect(validateConfig("not an object")).rejects.toThrow(ConfigError); + await expect(validateConfig(123)).rejects.toThrow(ConfigError); + }); + + it("should fail if 'settings' block is missing or malformed", async () => { + const invalidConfig = { ...VALID_CONFIG, settings: null }; + delete (invalidConfig as any).settings; + + await expect( + validateConfig({ ...VALID_CONFIG, settings: undefined }), + ).rejects.toThrow(ConfigError); + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.MISSING_FIELD, { + field: "settings", + })}`, + }), + ); + }); + + it("should fail if 'templates' block is missing or malformed", async () => { + const invalidConfig = { ...VALID_CONFIG, templates: 123 }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.MISSING_FIELD, { + field: "templates", + })}`, + }), + ); + }); + + it("should fail if defaultPackageManager is invalid or missing", async () => { + mockValidatePackageManager.mockImplementationOnce(() => { + throw new DevkitError("Invalid PM"); + }); + + const invalidConfig = { + ...VALID_CONFIG, + settings: { ...VALID_CONFIG.settings, defaultPackageManager: "invalid" }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.SETTING_INVALID, { setting: "defaultPackageManager" })}`, + }), + ); + + const missingConfig = { + ...VALID_CONFIG, + settings: { ...VALID_CONFIG.settings, defaultPackageManager: null }, + }; + await expect(validateConfig(missingConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.SETTING_INVALID, { setting: "defaultPackageManager" })}`, + }), + ); + }); + + it("should fail if language is invalid", async () => { + mockValidateLanguage.mockImplementationOnce(() => { + throw new DevkitError("Invalid Lang"); + }); + + const invalidConfig = { + ...VALID_CONFIG, + settings: { ...VALID_CONFIG.settings, language: "invalid" }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.SETTING_INVALID, { setting: "language" })}`, + }), + ); + }); + + it("should fail if a template language key is invalid", async () => { + mockValidateProgrammingLanguage.mockImplementationOnce(() => { + throw new ConfigError("Invalid Lang Key"); + }); + + const invalidConfig = { + ...VALID_CONFIG, + templates: { + BAD_LANGUAGE: { + templates: { t1: { location: "a", description: "b" } }, + }, + ...VALID_CONFIG.templates, + }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.INVALID_LANG_KEY, { key: "BAD_LANGUAGE" })}`, + }), + ); + }); + + it("should fail if a template language block lacks the 'templates' object", async () => { + const invalidConfig = { + ...VALID_CONFIG, + templates: { + javascript: { templates: "not an object" as any }, + }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.TEMPLATE_STRUCTURE, { language: "javascript" })}`, + }), + ); + }); + + it("should fail if a template is missing the required 'location' field", async () => { + const invalidConfig = { + ...VALID_CONFIG, + templates: { + javascript: { templates: { vue: { description: "a", location: "" } } }, + }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.TEMPLATE_FIELD_MISSING, { language: "javascript", template: "vue", field: "location" })}`, + }), + ); + }); + + it("should fail if a template has an invalid 'description' field", async () => { + mockValidateDescription.mockImplementation(() => { + throw new ConfigError("Description too short"); + }); + + const invalidConfig = { + ...VALID_CONFIG, + templates: { + javascript: { + templates: { vue: { location: "a", description: "too short" } }, + }, + }, + }; + + await expect(validateConfig(invalidConfig)).rejects.toThrow( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.TEMPLATE_FIELD_MISSING, { language: "javascript", template: "vue", field: "description" })}`, + }), + ); + }); + + it("should collect multiple template errors and throw one ConfigError", async () => { + mockValidatePackageManager.mockImplementationOnce(() => { + throw new DevkitError("Invalid PM"); + }); + + const templates = { + javascript: { + templates: { vue: VALID_CONFIG.templates.javascript.templates.vue }, + }, + typescript: { + templates: { node: { description: "a", location: "" } }, + }, + }; + + const invalidConfig = { ...VALID_CONFIG, templates }; + + await expect(validateConfig(invalidConfig)).rejects.toThrowError( + new ConfigError( + mocktFn(ERROR_KEYS.VALIDATION_FAILED, { + details: ` - ${mocktFn(ERROR_KEYS.SETTING_INVALID, { + setting: "defaultPackageManager", + })}\n - ${mocktFn(ERROR_KEYS.TEMPLATE_FIELD_MISSING, { + language: "typescript", + template: "node", + field: "location", + })}`, + }), + ), + ); + }); +}); diff --git a/packages/devkit/__tests__/units/core/template/printer.spec.ts b/packages/devkit/__tests__/units/core/template/printer.spec.ts index a077948..3955257 100644 --- a/packages/devkit/__tests__/units/core/template/printer.spec.ts +++ b/packages/devkit/__tests__/units/core/template/printer.spec.ts @@ -57,19 +57,12 @@ const ANNOTATED_TEMPLATES: AnnotatedTemplate[] = [ ]; const settings = { - packageManager: "pnpm", + defaultPackageManager: "pnpm", cacheStrategy: "daily", language: "en", }; describe("print-utils", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => - Object.entries(templateMap), - ); - }); - const c: any = mockLogger.colors; const t = mocktFn; @@ -77,11 +70,30 @@ describe("print-utils", () => { const LOCAL_TAG = c.blue("(local)"); const DEFAULT_TAG = c.dim("(default)"); + beforeEach(() => { + vi.clearAllMocks(); + mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => + Object.entries(templateMap), + ); + }); + describe("printTemplates (Mode `Tree`: Default)", () => { it("should print all templates without a filter, grouped by language", () => { + mockFilterTemplatesByWhereClause.mockReturnValue( + Object.entries( + ANNOTATED_TEMPLATES.reduce( + (acc, t) => { + acc[t._name] = t; + return acc; + }, + {} as Record, + ), + ), + ); + printTemplates(ANNOTATED_TEMPLATES, [], "tree"); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(1); expect(mockLogger.log).toHaveBeenCalledTimes(6); @@ -103,14 +115,15 @@ describe("print-utils", () => { it("should print only filtered templates by a filter clause array", () => { const filterClauses = ["alias:nextjs"]; - mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => { - const templateName = Object.keys(templateMap)[0]; - return templateName === "next-app" ? Object.entries(templateMap) : []; - }); + const filteredTemplate = ANNOTATED_TEMPLATES[3]; + + mockFilterTemplatesByWhereClause.mockReturnValue([ + [filteredTemplate._name, filteredTemplate], + ]); printTemplates(ANNOTATED_TEMPLATES, filterClauses); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(1); expect(mockLogger.log).toHaveBeenCalledTimes(2); expect(mockLogger.log).toHaveBeenCalledWith( @@ -127,7 +140,7 @@ describe("print-utils", () => { printTemplates(ANNOTATED_TEMPLATES, filterClauses); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(1); expect(mockLogger.log).not.toHaveBeenCalled(); expect(mockLogger.warning).toHaveBeenCalledOnce(); expect(mockLogger.warning).toHaveBeenCalledWith( @@ -149,11 +162,21 @@ describe("print-utils", () => { describe("printTemplates (Mode `Table`)", () => { it("should call logger.table with all templates including the new Source column", () => { - mockFilterTemplatesByWhereClause.mockImplementation(() => [1]); + mockFilterTemplatesByWhereClause.mockReturnValue( + Object.entries( + ANNOTATED_TEMPLATES.reduce( + (acc, t) => { + acc[t._name] = t; + return acc; + }, + {} as Record, + ), + ), + ); printTemplates(ANNOTATED_TEMPLATES, [], "table"); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(1); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -205,16 +228,15 @@ describe("print-utils", () => { it("should filter templates correctly in table mode", () => { const filterClauses = ["language:javascript"]; - mockFilterTemplatesByWhereClause.mockImplementation((templateMap) => { - const templateName = Object.keys(templateMap)[0]; - return templateName === "express-api" - ? Object.entries(templateMap) - : []; - }); + const filteredTemplate = ANNOTATED_TEMPLATES[2]; + + mockFilterTemplatesByWhereClause.mockReturnValue([ + [filteredTemplate._name, filteredTemplate], + ]); printTemplates(ANNOTATED_TEMPLATES, filterClauses, "table"); - expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(4); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(1); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -240,25 +262,57 @@ describe("print-utils", () => { }); }); - describe("printSettings", () => { - it("should print all settings correctly", () => { + describe("printSettings (Mode `Tree`: Default)", () => { + it("should print all settings correctly in tree mode (default)", () => { printSettings(settings); - expect(mockLogger.log).toHaveBeenCalledTimes(3); + expect(mockLogger.log).toHaveBeenCalledTimes(4); + expect(mockLogger.log).toHaveBeenCalledWith(c.bold("Settings:")); expect(mockLogger.log).toHaveBeenCalledWith( - `${c.yellowBold(" packageManager:")} ${c.cyan("pnpm")}`, + `${c.yellow(" defaultPackageManager:")} ${c.cyan("pnpm")}`, ); expect(mockLogger.log).toHaveBeenCalledWith( - `${c.yellowBold(" cacheStrategy:")} ${c.cyan("daily")}`, + `${c.yellow(" cacheStrategy:")} ${c.cyan("daily")}`, ); expect(mockLogger.log).toHaveBeenCalledWith( - `${c.yellowBold(" language:")} ${c.cyan("en")}`, + `${c.yellow(" language:")} ${c.cyan("en")}`, ); + expect(mockLogger.table).not.toHaveBeenCalled(); }); it("should not print anything if settings object is empty", () => { printSettings({}); - expect(mockLogger.log).not.toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledTimes(1); + }); + }); + + describe("printSettings (Mode `Table`)", () => { + it("should print all settings correctly in table mode", () => { + printSettings(settings, "table"); + + expect(mockLogger.log).toHaveBeenCalledTimes(1); + expect(mockLogger.log).toHaveBeenCalledWith(c.bold("Settings:")); + expect(mockLogger.table).toHaveBeenCalledTimes(1); + + const expectedTableData = [ + [c.bold("Setting Key"), c.bold("Value")], + [c.yellow("defaultPackageManager"), c.cyan("pnpm")], + [c.yellow("cacheStrategy"), c.cyan("daily")], + [c.yellow("language"), c.cyan("en")], + ]; + + expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); + }); + + it("should print only the table headers if settings object is empty in table mode", () => { + printSettings({}, "table"); + + expect(mockLogger.log).toHaveBeenCalledTimes(1); + expect(mockLogger.table).toHaveBeenCalledTimes(1); + + const expectedTableData = [[c.bold("Setting Key"), c.bold("Value")]]; + + expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); }); }); }); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 51120ee..c4c255c 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -326,30 +326,42 @@ }, "config": { "parse_fail": "Failed to parse {file}. Using default configuration.", - "save_fail": "❌ Failed to save configuration file: {file}", - "exists": "File already exists at {path}. Use 'config set' to update it.", - "not_found": "Configuration file not found.", - "read_fail": "Failed to read configuration at {path}.", + "save_fail": "Failed to save configuration file: {file}", + "exists": "File already exists at {path}. Use 'config set' to update it", + "not_found": "Configuration file not found", + "read_fail": "Failed to read configuration", + "read_fail_path": "Failed to read configuration at {path}", "no_file_found": "No configuration file found. Run 'devkit config init' to create a global one, or 'devkit config init --local' to create a local one.", "no_file_found_local": "No local configuration file found. Run 'devkit config init --local' to create one.", - "init_fail": "Failed to initialize configuration.", + "init_fail": "Failed to initialize configuration", "init_local_and_global": "Cannot use both --local and --global flags at the same time.", "global_not_found": "Global configuration file not found. Run 'devkit config init --global' to create one.", "local_not_found": "No local configuration file found. Run 'devkit config init --local' to create one.", - "get_key_not_found": "Configuration key '{key}' not found." + "get_key_not_found": "Configuration key '{key}' not found.", + "validation_failed": "Configuration validation failed. Please fix the errors below in your config file:\n\n{details}", + "malformed_root": "Configuration file must be a valid JSON object.", + "missing_or_malformed": "Required section '{field}' is missing or malformed (must be an object).", + "setting_invalid": "The value for setting '{setting}' is invalid or missing.", + "setting_invalid_type": "Setting '{setting}' must be of type {type}.", + "invalid_language_key": "Template language key '{key}' is invalid. Use lowercase official language names (e.g., '{options}').", + "template_structure_malformed": "The language block for '{language}' is malformed. It must contain a 'templates' object.", + "template_malformed": "Template '{template}' under '{language}' is malformed (must be an object).", + "template_field_missing": "Template '{template}' in '{language}' is missing required field: '{field}' or the value is invalid.", + "template_field_invalid": "The '{field}' value for template '{template}' in '{language}' is invalid.", + "invalid_config_file": "Configuration file at {path} is invalid. Falling back to defaults." }, "template": { "not_found": "Template '{template}' not found in configuration.", "language_not_found": "Template not found for the '{language}' language.", "exists": "Template '{template}' already exists in the configuration. Use 'devkit config set' to update it.", - "single_fail": "❌ Failed to update '{templateName}': {error}", + "single_fail": "Failed to update '{templateName}': {error}", "filter_regex_invalid": "Invalid regex pattern in filter: '{clause}' due to {error}." }, "scaffolding": { - "fail": "❌ Failed to scaffold project: {error}", - "copy_fail": "❌ Failed to copy local template.", - "run_fail": "❌ Failed to run official CLI command.", - "install_fail": "❌ Failed to install dependencies.", + "fail": "Failed to scaffold project: {error}", + "copy_fail": "Failed to copy local template.", + "run_fail": "Failed to run official CLI command.", + "install_fail": "Failed to install dependencies.", "unexpected": "An unexpected error occurred during scaffolding.", "language_not_found": "Scaffolding language not found in configuration: '{language}'" }, @@ -370,9 +382,9 @@ "info_package_manager_not_found": "{manager} version not found. Is it installed?" }, "cache": { - "clone_fail": "❌ Failed to clone repository.", - "refresh_fail": "❌ Failed to refresh template.", - "copy_fail": "❌ Failed to copy files from cache." + "clone_fail": "Failed to clone repository.", + "refresh_fail": "Failed to refresh template.", + "copy_fail": "Failed to copy files from cache." } }, "warnings": { diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index b83ce8a..aac625d 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -326,7 +326,7 @@ }, "config": { "parse_fail": "Échec de l'analyse de {file}. Utilisation de la configuration par défaut.", - "save_fail": "❌ Échec de l'enregistrement du fichier de configuration : {file}", + "save_fail": "Échec de l'enregistrement du fichier de configuration : {file}", "exists": "Le fichier existe déjà à {path}. Utilisez 'config set' pour le mettre à jour.", "not_found": "Fichier de configuration non trouvé.", "read_fail": "Échec de la lecture de la configuration à {path}.", @@ -336,20 +336,31 @@ "init_local_and_global": "Impossible d'utiliser les flags --local et --global en même temps.", "global_not_found": "Fichier de configuration globale non trouvé. Exécutez 'devkit config init --global' pour en créer un.", "local_not_found": "Aucun fichier de configuration local trouvé. Exécutez 'devkit config init --local' pour en créer un.", - "get_key_not_found": "Clé de configuration '{key}' non trouvée." + "get_key_not_found": "Clé de configuration '{key}' introuvable.", + "validation_failed": "La validation de la configuration a échoué. Veuillez corriger les erreurs ci-dessous dans votre fichier de configuration :\n\n{details}", + "malformed_root": "Le fichier de configuration doit être un objet JSON valide.", + "missing_or_malformed": "La section requise '{field}' est manquante ou mal formée (doit être un objet).", + "setting_invalid": "La valeur du paramètre '{setting}' est invalide ou manquante.", + "setting_invalid_type": "Le paramètre '{setting}' doit être de type {type}.", + "invalid_language_key": "La clé de langage de modèle '{key}' est invalide. Utilisez des noms de langages officiels en minuscules (par exemple, 'javascript', 'typescript').", + "template_structure_malformed": "Le bloc de langage pour '{language}' est mal formé. Il doit contenir un objet 'templates'.", + "template_malformed": "Le modèle '{template}' sous '{language}' est mal formé (doit être un objet).", + "template_field_missing": "Le modèle '{template}' dans '{language}' n'a pas le champ requis : '{field}' ou la valeur est invalide.", + "template_field_invalid": "La valeur du champ '{field}' pour le modèle '{template}' dans '{language}' est invalide.", + "invalid_config_file": "Le fichier de configuration à l'emplacement {path} est invalide. Utilisation des valeurs par défaut." }, "template": { "not_found": "Modèle '{template}' non trouvé dans la configuration.", "language_not_found": "Modèle non trouvé pour le langage '{language}'.", "exists": "Le modèle '{template}' existe déjà dans la configuration. Utilisez 'devkit config set' pour le mettre à jour.", - "single_fail": "❌ Échec de la mise à jour de '{templateName}' : {error}", + "single_fail": "Échec de la mise à jour de '{templateName}' : {error}", "filter_regex_invalid": "Pattern regex invalide dans le filtre : '{clause}' à cause de {error}." }, "scaffolding": { - "fail": "❌ Échec de l'échafaudage du projet : {error}", - "copy_fail": "❌ Échec de la copie du modèle local.", - "run_fail": "❌ Échec de l'exécution de la commande CLI officielle.", - "install_fail": "❌ Échec de l'installation des dépendances.", + "fail": "Échec de l'échafaudage du projet : {error}", + "copy_fail": "Échec de la copie du modèle local.", + "run_fail": "Échec de l'exécution de la commande CLI officielle.", + "install_fail": "Échec de l'installation des dépendances.", "unexpected": "Une erreur inattendue s'est produite pendant l'échafaudage.", "language_not_found": "Langage d'échafaudage non trouvé dans la configuration : '{language}'" }, @@ -370,9 +381,9 @@ "info_package_manager_not_found": "Version de {manager} non trouvée. Est-il installé ?" }, "cache": { - "clone_fail": "❌ Échec du clonage du dépôt.", - "refresh_fail": "❌ Échec du rafraîchissement du modèle.", - "copy_fail": "❌ Échec de la copie des fichiers depuis le cache." + "clone_fail": "Échec du clonage du dépôt.", + "refresh_fail": "Échec du rafraîchissement du modèle.", + "copy_fail": "Échec de la copie des fichiers depuis le cache." } }, "warnings": { diff --git a/packages/devkit/src/commands/index.ts b/packages/devkit/src/commands/index.ts index b66da6b..e930815 100644 --- a/packages/devkit/src/commands/index.ts +++ b/packages/devkit/src/commands/index.ts @@ -8,19 +8,27 @@ import { setupNewCommand } from "#commands/new.js"; import { setupConfigCommand } from "#commands/config/index.js"; import { setupListCommand } from "#commands/list.js"; import { setupInitCommand } from "#commands/init/index.js"; - import { setupInfoCommand } from "#commands/info.js"; import { loadTranslations } from "#utils/i18n/translation-loader.js"; +import { validateConfig } from "#core/config/validation.js"; export async function setupAndParse() { const spinner: TSpinner = logger.spinner(); try { + let rawLocale = null; + await loadTranslations(rawLocale); + const { configFound, global, local } = await readConfigSources({ mergeAll: true, }); - let rawLocale = local?.settings?.language || global?.settings?.language; + const validatedLocal = local ? await validateConfig(local) : null; + const validatedGlobal = global ? await validateConfig(global) : null; + + rawLocale = + validatedLocal?.settings?.language || validatedGlobal?.settings?.language; + await loadTranslations(rawLocale || null); const program = new Command(); diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 28b05a4..13c4b4e 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -108,7 +108,7 @@ export function setupListCommand(options: SetupCommandOptions): void { "\n" + t("commands.list.output.settings_header") + defaultsSuffix, ), ); - printSettings(finalConfig.settings || {}); + printSettings(finalConfig.settings || {}, mode); } let templatesToPrint: AnnotatedTemplate[] = annotatedTemplates; diff --git a/packages/devkit/src/core/config/loader.ts b/packages/devkit/src/core/config/loader.ts index 7b2961e..be5c9f9 100644 --- a/packages/devkit/src/core/config/loader.ts +++ b/packages/devkit/src/core/config/loader.ts @@ -5,6 +5,8 @@ import { } from "#utils/schema/schema.js"; import fs from "#utils/fs/file.js"; import { getConfigPathSources } from "./finder.js"; +import { logger } from "#utils/logger.js"; +import { t } from "#utils/i18n/translator.js"; export type ConfigurationSources = { default: CliConfig; @@ -20,10 +22,13 @@ async function readSingleConfig( try { return (await fs.readJson(path)) as CliConfig; } catch (e: unknown) { - console.error( - `Warning: Failed to parse configuration file at "${path}". The file may be invalid.`, - (e as Error).cause, - ); + if (e instanceof Error) { + logger.error(t("errors.config.read_fail_path", { path }), "ERR"); + logger.warning(t("warnings.not_found")); + } else { + logger.error(t("errors.generic.unexpected"), "UNKNOWN"); + } + return null; } } return null; diff --git a/packages/devkit/src/core/config/validation.ts b/packages/devkit/src/core/config/validation.ts new file mode 100644 index 0000000..d3e2bec --- /dev/null +++ b/packages/devkit/src/core/config/validation.ts @@ -0,0 +1,203 @@ +// oxlint-disable no-unused-vars +import { t } from "#utils/i18n/translator.js"; +import { DevkitError, ConfigError } from "#utils/errors/base.js"; +import { + validatePackageManager, + validateCacheStrategy, + validateLanguage, + validateProgrammingLanguage, +} from "#utils/validations/config.js"; +import { validateDescription } from "#utils/validations/templates.js"; +import type { CliConfig } from "#utils/schema/schema.js"; +import type { SupportedPackageManager } from "#utils/schema/schema.js"; +import type { CacheStrategy } from "#utils/schema/schema.js"; + +export async function validateConfig( + config: unknown, + filePath?: string, +): Promise { + const errors: string[] = []; + + if (typeof config !== "object" || config === null) { + throw new ConfigError(t("errors.config.malformed_root"), filePath); + } + + const cfg = config as { [key: string]: any }; + + if ( + !("settings" in cfg) || + typeof cfg.settings !== "object" || + cfg.settings === null + ) { + errors.push(t("errors.config.missing_or_malformed", { field: "settings" })); + } else { + const settings = cfg.settings; + + try { + if (!settings.defaultPackageManager) throw new DevkitError(""); + validatePackageManager( + settings.defaultPackageManager as SupportedPackageManager, + ); + } catch (e: unknown) { + errors.push( + t("errors.config.setting_invalid", { + setting: "defaultPackageManager", + }), + ); + } + + try { + if (!settings.cacheStrategy) throw new DevkitError(""); + validateCacheStrategy(settings.cacheStrategy as CacheStrategy); + } catch (e: unknown) { + errors.push( + t("errors.config.setting_invalid", { setting: "cacheStrategy" }), + ); + } + + try { + if (!settings.language) throw new DevkitError(""); + validateLanguage(settings.language as string); + } catch (e: unknown) { + errors.push(t("errors.config.setting_invalid", { setting: "language" })); + } + } + + if ( + !("templates" in cfg) || + typeof cfg.templates !== "object" || + cfg.templates === null + ) { + errors.push( + t("errors.config.missing_or_malformed", { field: "templates" }), + ); + } else { + const templates = cfg.templates; + + for (const languageKey in templates) { + const languageBlock = templates[languageKey]; + + try { + validateProgrammingLanguage(languageKey); + } catch (e: unknown) { + errors.push( + t("errors.config.invalid_language_key", { key: languageKey }), + ); + continue; + } + + if ( + typeof languageBlock !== "object" || + languageBlock === null || + !("templates" in languageBlock) || + typeof languageBlock.templates !== "object" + ) { + errors.push( + t("errors.config.template_structure_malformed", { + language: languageKey, + }), + ); + continue; + } + + const templateList = languageBlock.templates; + for (const templateName in templateList) { + const template = templateList[templateName]; + + if (!template || typeof template !== "object") { + errors.push( + t("errors.config.template_malformed", { + language: languageKey, + template: templateName, + }), + ); + continue; + } + + if ( + !("location" in template) || + typeof template.location !== "string" || + !template.location.trim() + ) { + errors.push( + t("errors.config.template_field_missing", { + language: languageKey, + template: templateName, + field: "location", + }), + ); + } + + try { + if ( + !("description" in template) || + typeof template.description !== "string" + ) { + throw new DevkitError(""); + } + validateDescription(template.description); + } catch (e: unknown) { + errors.push( + t("errors.config.template_field_missing", { + language: languageKey, + template: templateName, + field: "description", + }), + ); + } + + if ("packageManager" in template && template.packageManager) { + try { + validatePackageManager(template.packageManager); + } catch { + errors.push( + t("errors.config.template_field_invalid", { + language: languageKey, + template: templateName, + field: "packageManager", + }), + ); + } + } + if ("cacheStrategy" in template && template.cacheStrategy) { + try { + validateCacheStrategy(template.cacheStrategy); + } catch { + errors.push( + t("errors.config.template_field_invalid", { + language: languageKey, + template: templateName, + field: "cacheStrategy", + }), + ); + } + } + + if ("alias" in template && template.alias) { + if (typeof template.alias !== "string") { + errors.push( + t("errors.config.template_field_invalid", { + language: languageKey, + template: templateName, + field: "alias", + }), + ); + } + } + } + } + } + + if (errors.length > 0) { + const errorDetails = errors.map((e) => ` - ${e}`).join("\n"); + + throw new ConfigError( + t("errors.config.validation_failed", { + details: errorDetails, + }), + filePath, + ); + } + + return config as CliConfig; +} diff --git a/packages/devkit/src/core/template/filter.ts b/packages/devkit/src/core/template/filter.ts index 9c9beae..2d70b85 100644 --- a/packages/devkit/src/core/template/filter.ts +++ b/packages/devkit/src/core/template/filter.ts @@ -8,9 +8,6 @@ import { type TemplateMap = LanguageConfig["templates"]; type TemplateEntry = [string, TemplateConfig]; -/** - * @const {object} FILTER_SYMBOLS - Reserved symbols used for presence/absence checks. - */ export const FILTER_SYMBOLS = { PRESENT: "*", MISSING: "~", @@ -18,9 +15,6 @@ export const FILTER_SYMBOLS = { REGEX_END: "/", } as const; -/** - * @const {object} FILTER_DELIMITERS - Supported characters for separating property from value. - */ export const FILTER_DELIMITERS = { COLON: ":", EQUALS: "=", diff --git a/packages/devkit/src/core/template/printer.ts b/packages/devkit/src/core/template/printer.ts index 3513264..3a5883e 100644 --- a/packages/devkit/src/core/template/printer.ts +++ b/packages/devkit/src/core/template/printer.ts @@ -126,11 +126,19 @@ export function printTemplates( return; } - const finalFilteredList: AnnotatedTemplate[] = templatesList.filter( - (template) => { - const templateMap = { [template._name]: template }; - return filterTemplatesByWhereClause(templateMap, whereClauses).length > 0; + const templateMap = templatesList.reduce( + (acc, template) => { + const key = `${template._language}:${template._name}`; + acc[key] = template; + return acc; }, + {} as Record, + ); + + const filteredTemplatesEntries: Array<[string, AnnotatedTemplate]> = + filterTemplatesByWhereClause(templateMap, whereClauses) as any; + const finalFilteredList: AnnotatedTemplate[] = filteredTemplatesEntries.map( + ([_name, template]) => template, ); if (finalFilteredList.length === 0) { @@ -142,6 +150,11 @@ export function printTemplates( return; } + if (mode === "table") { + printTemplatesTable(finalFilteredList, whereClauses); + return; + } + const finalTemplatesByLanguage = finalFilteredList.reduce( (acc, template) => { const lang = template._language; @@ -154,20 +167,44 @@ export function printTemplates( {} as Record, ); - if (mode === "table") { - printTemplatesTable(finalFilteredList, whereClauses); - return; - } - Object.entries(finalTemplatesByLanguage).forEach(([_, templates]) => { printTemplatesTree(templates); }); } -export function printSettings(settings: CliConfig["settings"]): void { +function printSettingsTree(settings: CliConfig["settings"]): void { + logger.log(logger.colors.bold("Settings:")); Object.entries(settings).forEach(([key, value]) => { - const keyString = logger.colors.yellowBold(` ${key}:`); + const keyString = logger.colors.yellow(` ${key}:`); const valueString = logger.colors.cyan(value); logger.log(`${keyString} ${valueString}`); }); } + +function printSettingsTable(settings: CliConfig["settings"]): void { + const tableData: string[][] = []; + const keyHeader = logger.colors.bold("Setting Key"); + const valueHeader = logger.colors.bold("Value"); + + tableData.push([keyHeader, valueHeader]); + + Object.entries(settings).forEach(([key, value]) => { + const row = [logger.colors.yellow(key), logger.colors.cyan(value)]; + tableData.push(row); + }); + + logger.log(logger.colors.bold("Settings:")); + logger.table(tableData); +} + +export function printSettings( + settings: CliConfig["settings"], + mode: DisplayModesValues = "tree", +): void { + if (mode === "table") { + printSettingsTable(settings); + return; + } + + printSettingsTree(settings); +}