diff --git a/src/content-cli.ts b/src/content-cli.ts index 60cb630..e05648c 100644 --- a/src/content-cli.ts +++ b/src/content-cli.ts @@ -2,7 +2,7 @@ import semverSatisfies = require("semver/functions/satisfies"); import { Command } from "commander"; -import { Configurator, ModuleHandler } from "./core/command/module-handler"; +import { Configurator, IModuleConstructor, ModuleHandler } from "./core/command/module-handler"; import { Context } from "./core/command/cli-context"; import { VersionUtils } from "./core/utils/version"; import { logger } from "./core/utils/logger"; @@ -14,74 +14,109 @@ import { ContentCLIHelp } from "./core/command/CustomHelp"; * This is the main entry point for the CLI. */ -// Check if the Node.js version satisfies the minimum requirements const requiredVersion = ">=10.10.0"; -if (!semverSatisfies(process.version, requiredVersion)) { - logger.error( - `Node version ${process.version} not supported. Please upgrade your node version to ${requiredVersion}` - ); - process.exit(1); -} -// Global configuration options -const program: Command = new Command(); -program.configureHelp({ - formatHelp: (cmd, helper) => new ContentCLIHelp().formatHelp(cmd, helper), - subcommandTerm:cmd => new ContentCLIHelp().subcommandTerm(cmd), - optionTerm: opt => new ContentCLIHelp().optionTerm(opt), -}); -program.version(VersionUtils.getCurrentCliVersion()); -program.option("-q, --quietmode", "Reduce output to a minimum", false); -program.option("-p, --profile [profile]"); -program.option("--gitProfile [gitProfile]", "Git profile to use"); -program.option("--debug", "Print debug messages", false); -program.option("--dev", "Development Mode", false); -program.parseOptions(process.argv); - -if (!program.opts().quietmode) { - console.log(`Content CLI - (C) Copyright 2025 - Celonis SE - Version ${VersionUtils.getCurrentCliVersion()}`); - console.log(); +export interface CreateProgramOptions { + /** + * Explicit list of module classes to register. When provided, the factory + * skips automatic, filesystem-based module discovery. + */ + modules?: IModuleConstructor[]; + devMode?: boolean; } -if (program.opts().debug) { - logger.transports.forEach(t => { - t.level = "debug"; +/** + * Build a fully-configured Commander program without parsing argv + */ +export function createProgram(context: Context, opts: CreateProgramOptions = {}): Command { + const program = new Command(); + program.configureHelp({ + formatHelp: (cmd, helper) => new ContentCLIHelp().formatHelp(cmd, helper), + subcommandTerm: cmd => new ContentCLIHelp().subcommandTerm(cmd), + optionTerm: opt => new ContentCLIHelp().optionTerm(opt), }); + program.version(VersionUtils.getCurrentCliVersion()); + program.option("-q, --quietmode", "Reduce output to a minimum", false); + program.option("-p, --profile [profile]"); + program.option("--gitProfile [gitProfile]", "Git profile to use"); + program.option("--debug", "Print debug messages", false); + program.option("--dev", "Development Mode", false); + + const moduleHandler = new ModuleHandler(program, context); + configureRootCommands(moduleHandler.configurator); + + if (opts.modules) { + for (const moduleClass of opts.modules) { + const moduleInstance = new moduleClass(); + moduleInstance.register(context, moduleHandler.configurator); + } + } else { + const rootPath = __dirname; + const devMode = opts.devMode ?? !!program.opts().dev; + moduleHandler.discoverAndRegisterModules(rootPath, devMode); + } + + return program; } -/** +/** * To support the legacy command structure, we have to configure some root commands * that the individual modules will extend. - */ + */ function configureRootCommands(configurator: Configurator): void { - configurator.command("list") - .description("Commands to list content.") - .alias("ls"); + configurator.command("list").description("Commands to list content.").alias("ls"); } async function run(): Promise { - const context = new Context(program.opts()); - await context.init(); + if (!semverSatisfies(process.version, requiredVersion)) { + logger.error( + `Node version ${process.version} not supported. Please upgrade your node version to ${requiredVersion}` + ); + process.exit(1); + } - const moduleHandler = new ModuleHandler(program, context); + // Parse global options up-front so banner/debug-level decisions can use + // them before module discovery runs. + const bootstrapProgram = new Command(); + bootstrapProgram.option("-q, --quietmode", "Reduce output to a minimum", false); + bootstrapProgram.option("-p, --profile [profile]"); + bootstrapProgram.option("--gitProfile [gitProfile]", "Git profile to use"); + bootstrapProgram.option("--debug", "Print debug messages", false); + bootstrapProgram.option("--dev", "Development Mode", false); + bootstrapProgram.allowUnknownOption(true); + bootstrapProgram.parseOptions(process.argv); + const globalOpts = bootstrapProgram.opts(); - configureRootCommands(moduleHandler.configurator); + if (!globalOpts.quietmode) { + console.log(`Content CLI - (C) Copyright 2025 - Celonis SE - Version ${VersionUtils.getCurrentCliVersion()}`); + console.log(); + } - moduleHandler.discoverAndRegisterModules(__dirname, program.opts().dev); + if (globalOpts.debug) { + logger.transports.forEach(t => { + t.level = "debug"; + }); + } + + const context = new Context(globalOpts); + await context.init(); + + const program = createProgram(context, { devMode: !!globalOpts.dev }); try { - program.parse(process.argv); + await program.parseAsync(process.argv); } catch (error) { logger.error(`An unexpected error occurred: ${error}`); } } -run(); +if (require.main === module) { + run(); +} -// catch uncaught exceptions process.on("uncaughtException", (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => { console.error("\n💥 UNCAUGHT EXCEPTION!\n"); console.error("Error:", error); console.error("Origin:", origin); process.exit(1); -}); \ No newline at end of file +}); diff --git a/tests/commands/asset-registry/asset-registry-module.spec.ts b/tests/commands/asset-registry/asset-registry-module.spec.ts deleted file mode 100644 index 17bde19..0000000 --- a/tests/commands/asset-registry/asset-registry-module.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import Module = require("../../../src/commands/asset-registry/module"); -import { Command, OptionValues } from "commander"; -import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; -import { testContext } from "../../utls/test-context"; -import { createMockConfigurator } from "../../utls/configurator-mock"; - -jest.mock("../../../src/commands/asset-registry/asset-registry.service"); - -describe("Asset Registry Module", () => { - let module: Module; - let mockCommand: Command; - let mockService: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - module = new Module(); - mockCommand = {} as Command; - - mockService = { - listTypes: jest.fn().mockResolvedValue(undefined), - listSkills: jest.fn().mockResolvedValue(undefined), - getType: jest.fn().mockResolvedValue(undefined), - getSchema: jest.fn().mockResolvedValue(undefined), - validate: jest.fn().mockResolvedValue(undefined), - getExamples: jest.fn().mockResolvedValue(undefined), - } as any; - - (AssetRegistryService as jest.MockedClass) - .mockImplementation(() => mockService); - }); - - it("should call getSchema with correct parameters", async () => { - const options: OptionValues = { assetType: "BOARD_V2", json: true }; - await (module as any).getSchema(testContext, mockCommand, options); - expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", true); - }); - - it("should call validate with --configuration sub-mode options", async () => { - const options: OptionValues = { - assetType: "BOARD_V2", - packageKey: "my-pkg", - configuration: '{"components":[]}', - json: true, - }; - await (module as any).validate(testContext, mockCommand, options); - expect(mockService.validate).toHaveBeenCalledWith({ - assetType: "BOARD_V2", - packageKey: "my-pkg", - nodeKey: undefined, - configuration: '{"components":[]}', - file: undefined, - json: true, - }); - }); - - it("should call validate with --nodeKey sub-mode options", async () => { - const options: OptionValues = { - assetType: "BOARD_V2", - packageKey: "my-pkg", - nodeKey: "my-view", - json: "", - }; - await (module as any).validate(testContext, mockCommand, options); - expect(mockService.validate).toHaveBeenCalledWith({ - assetType: "BOARD_V2", - packageKey: "my-pkg", - nodeKey: "my-view", - configuration: undefined, - file: undefined, - json: false, - }); - }); - - it("should call validate with file mode options", async () => { - const options: OptionValues = { - assetType: "BOARD_V2", - file: "request.json", - json: "", - }; - await (module as any).validate(testContext, mockCommand, options); - expect(mockService.validate).toHaveBeenCalledWith({ - assetType: "BOARD_V2", - packageKey: undefined, - nodeKey: undefined, - configuration: undefined, - file: "request.json", - json: false, - }); - }); - - it("should call getExamples with correct parameters", async () => { - const options: OptionValues = { assetType: "BOARD_V2", json: "" }; - await (module as any).getExamples(testContext, mockCommand, options); - expect(mockService.getExamples).toHaveBeenCalledWith("BOARD_V2", false); - }); - - it("should call listTypes", async () => { - const options: OptionValues = { json: true }; - await (module as any).listTypes(testContext, mockCommand, options); - expect(mockService.listTypes).toHaveBeenCalledWith(true); - }); - - it("should call listSkills", async () => { - const options: OptionValues = { json: true }; - await (module as any).listSkills(testContext, mockCommand, options); - expect(mockService.listSkills).toHaveBeenCalledWith(true); - - jest.clearAllMocks(); - const optionsNoJson: OptionValues = { json: "" }; - await (module as any).listSkills(testContext, mockCommand, optionsNoJson); - expect(mockService.listSkills).toHaveBeenCalledWith(false); - }); - - it("should call getType", async () => { - const options: OptionValues = { assetType: "BOARD_V2", json: "" }; - await (module as any).getType(testContext, mockCommand, options); - expect(mockService.getType).toHaveBeenCalledWith("BOARD_V2", false); - }); - - describe("register", () => { - it("registers all expected command groups without throwing", () => { - const mockConfigurator = createMockConfigurator(); - - expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow(); - - expect(mockConfigurator.command).toHaveBeenCalledWith("asset-registry"); - expect(mockConfigurator.command).toHaveBeenCalledWith("skills"); - expect(mockConfigurator.command).toHaveBeenCalledWith("list"); - expect(mockConfigurator.command).toHaveBeenCalledWith("get"); - expect(mockConfigurator.command).toHaveBeenCalledWith("schema"); - expect(mockConfigurator.command).toHaveBeenCalledWith("examples"); - expect(mockConfigurator.command).toHaveBeenCalledWith("validate"); - }); - - it("wires an action handler for every leaf subcommand", () => { - const mockConfigurator = createMockConfigurator(); - - new Module().register(testContext, mockConfigurator); - - // Each leaf command terminates the fluent chain with .action(handler). - // Keep this count in sync when adding or removing commands in module.ts. - const expectedLeafCommands = 6; - expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands); - for (const call of mockConfigurator.action.mock.calls) { - expect(typeof call[0]).toBe("function"); - } - }); - }); -}); diff --git a/tests/commands/configuration-management/module.spec.ts b/tests/commands/configuration-management/module.spec.ts deleted file mode 100644 index e24159c..0000000 --- a/tests/commands/configuration-management/module.spec.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import Module = require("../../../src/commands/configuration-management/module"); -import { Command, OptionValues } from "commander"; -import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; -import { StagingPackageService } from "../../../src/commands/configuration-management/staging-package.service"; -import { MetadataService } from "../../../src/commands/configuration-management/metadata.service"; -import { T2tcCommandService } from "../../../src/commands/t2tc/t2tc-command.service"; -import { NodeDependencyService } from "../../../src/commands/configuration-management/node-dependency.service"; -import { PackageVersionCommandService } from "../../../src/commands/configuration-management/package-version-command.service"; -import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; -import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; -import { testContext } from "../../utls/test-context"; -import { createMockConfigurator } from "../../utls/configurator-mock"; - -jest.mock("../../../src/commands/configuration-management/config-command.service"); -jest.mock("../../../src/commands/configuration-management/staging-package.service"); -jest.mock("../../../src/commands/configuration-management/metadata.service"); -jest.mock("../../../src/commands/t2tc/t2tc-command.service"); -jest.mock("../../../src/commands/configuration-management/node-dependency.service"); -jest.mock("../../../src/commands/configuration-management/node-diff.service"); -jest.mock("../../../src/commands/configuration-management/package-version-command.service"); -jest.mock("../../../src/commands/configuration-management/single-package-import.service"); - -/** Mirrors default values on `config variables list` Commander options (keep in sync with module.ts). */ -const variablesListOptionDefaults: OptionValues = { - packageKeys: [], - keysByVersion: [], - keysByVersionFile: "", -}; - -describe("Configuration Management Module - Action Validations", () => { - let module: Module; - let mockCommand: Command; - let mockConfigCommandService: jest.Mocked; - let mockStagingPackageService: jest.Mocked; - let mockMetadataService: jest.Mocked; - let mockT2tcCommandService: jest.Mocked; - let mockNodeDependencyService: jest.Mocked; - let mockNodeDiffService: jest.Mocked; - let mockSinglePackageImportService: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - module = new Module(); - mockCommand = {} as Command; - - mockConfigCommandService = { - listVariables: jest.fn().mockResolvedValue(undefined), - } as any; - - mockStagingPackageService = { - listStagingPackages: jest.fn().mockResolvedValue(undefined), - } as any; - - mockMetadataService = { - exportPackagesMetadata: jest.fn().mockResolvedValue(undefined), - } as any; - - mockT2tcCommandService = { - listPackages: jest.fn().mockResolvedValue(undefined), - batchExportPackages: jest.fn().mockResolvedValue(undefined), - batchImportPackages: jest.fn().mockResolvedValue(undefined), - diffPackages: jest.fn().mockResolvedValue(undefined), - } as any; - - mockNodeDependencyService = { - listNodeDependencies: jest.fn().mockResolvedValue(undefined), - } as any; - - mockNodeDiffService = { - diff: jest.fn().mockResolvedValue(undefined), - diffWithFile: jest.fn().mockResolvedValue(undefined), - } as any; - - mockSinglePackageImportService = { - importPackage: jest.fn().mockResolvedValue(undefined), - } as any; - - (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); - (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); - (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); - (T2tcCommandService as jest.MockedClass).mockImplementation(() => mockT2tcCommandService); - (NodeDependencyService as jest.MockedClass).mockImplementation(() => mockNodeDependencyService); - (NodeDiffService as jest.MockedClass).mockImplementation(() => mockNodeDiffService); - (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); - }); - - describe("listActivePackages validation", () => { - describe("packageKeys and keysByVersion validation", () => { - it("should throw error when both packageKeys and keysByVersion are provided", async () => { - const options: OptionValues = { - packageKeys: ["package1", "package2"], - keysByVersion: ["package3.1.0.0", "package4.1.0.0"], - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageKeys or --keysByVersion, but not both."); - - expect(mockT2tcCommandService.listPackages).not.toHaveBeenCalled(); - }); - - it("should pass validation when only packageKeys is provided", async () => { - const options: OptionValues = { - packageKeys: ["package1", "package2"], - json: true, - }; - - await (module as any).listPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( - true, - undefined, - undefined, - ["package1", "package2"], - undefined, - undefined, - undefined, - undefined, - undefined - ); - }); - - it("should pass validation when only keysByVersion is provided", async () => { - const options: OptionValues = { - keysByVersion: ["package3.1.0.0", "package4.1.0.0"], - json: true, - }; - - await (module as any).listPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( - true, - undefined, - undefined, - undefined, - ["package3.1.0.0", "package4.1.0.0"], - undefined, - undefined, - undefined, - undefined - ); - }); - }); - describe("branches validation", () => { - it("should pass validation when branches is provided", async () => { - const options: OptionValues = { - branches: true, - json: true, - }; - - await (module as any).listPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( - true, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, - undefined - ); - }); - }); - }); - - describe("listStagingPackages validation", () => { - it("should pass validation when branches is provided", async () => { - const options: OptionValues = { - branches: true, - json: true, - staging: true, - }; - - await (module as any).listPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( - true, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - true, - true - ); - }); - - it("should throw error when packageKeys is provided", async () => { - const options: OptionValues = { - packageKeys: ["package1", "package2"], - staging: true, - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); - }); - - it("should throw error when withDependencies is provided", async () => { - const options: OptionValues = { - withDependencies: true, - staging: true, - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); - }); - - it("should throw error when keysByVersion is provided", async () => { - const options: OptionValues = { - keysByVersion: ["package3.1.0.0", "package4.1.0.0"], - staging: true, - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); - }); - - it("should throw error when variableValue is provided", async () => { - const options: OptionValues = { - variableValue: "myValue", - staging: true, - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); - }); - - it("should throw error when variableType is provided", async () => { - const options: OptionValues = { - variableType: "myType", - staging: true, - }; - - await expect( - (module as any).listPackages(testContext, mockCommand, options) - ).rejects.toThrow("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); - }); - }); - - describe("config package list handler", () => { - it("should list staging packages with json and flavors", async () => { - const options: OptionValues = { - json: true, - flavors: ["APP"], - }; - - await (module as any).listStagingPackages(testContext, mockCommand, options); - - expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith(["APP"], false, true); - }); - - it("should default flavors to an empty list and json to undefined when not provided", async () => { - const options: OptionValues = {}; - - await (module as any).listStagingPackages(testContext, mockCommand, options); - - expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith([], false, undefined); - }); - - it("should not pass legacy listPackages options", async () => { - const options: OptionValues = { - flavors: ["APP", "ANALYSIS"], - }; - - await (module as any).listStagingPackages(testContext, mockCommand, options); - - expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith(["APP", "ANALYSIS"], false, undefined); - expect(mockT2tcCommandService.listPackages).not.toHaveBeenCalled(); - }); - }); - - describe("batchExportPackages validation", () => { - describe("packageKeys and keysByVersion validation", () => { - it("should throw error when both packageKeys and keysByVersion are provided", async () => { - const options: OptionValues = { - packageKeys: ["package1", "package2"], - keysByVersion: ["package3:v1", "package4:v2"], - }; - - await expect( - (module as any).batchExportPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageKeys or --keysByVersion, but not both."); - - expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); - }); - - it("should throw error when neither packageKeys nor keysByVersion are provided", async () => { - const options: OptionValues = {}; - - await expect( - (module as any).batchExportPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageKeys or --keysByVersion, but not both."); - - expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); - }); - - it("should pass validation when only packageKeys is provided", async () => { - const options: OptionValues = { - packageKeys: ["package1", "package2"], - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1", "package2"], - undefined, - false, - undefined, - undefined - ); - }); - - it("should pass validation when only keysByVersion is provided", async () => { - const options: OptionValues = { - keysByVersion: ["package3:v1", "package4:v2"], - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - undefined, - ["package3:v1", "package4:v2"], - false, - undefined, - undefined - ); - }); - }); - - describe("gitProfile and gitBranch validation", () => { - it("should throw error when gitProfile is provided without gitBranch option", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - gitProfile: "myProfile", - }; - - await expect( - (module as any).batchExportPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please specify a branch using --gitBranch when using a Git profile."); - - expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); - }); - - it("should pass validation when gitProfile provided with gitBranch option", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - gitBranch: "main", - gitProfile: "myProfile", - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1"], - undefined, - false, - "main", - undefined - ); - }); - - it("should pass validation when gitBranch is provided without gitProfile in context", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - gitBranch: "main", - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1"], - undefined, - false, - "main", - undefined - ); - }); - - it("should pass validation when neither gitProfile in context nor gitBranch in options are provided", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalled(); - }); - }); - - describe("withDependencies option", () => { - it("should default withDependencies to false when not provided", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1"], - undefined, - false, - undefined, - undefined - ); - }); - - it("should pass withDependencies as true when provided", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - withDependencies: true, - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1"], - undefined, - true, - undefined, - undefined - ); - }); - - it("should pass unzip option when provided", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - unzip: true, - }; - - await (module as any).batchExportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( - ["package1"], - undefined, - false, - undefined, - true - ); - }); - }); - - describe("combined validation scenarios", () => { - it("should fail on first validation error (packageKeys conflict) before checking gitProfile", async () => { - const options: OptionValues = { - packageKeys: ["package1"], - keysByVersion: ["package2:v1"], - gitProfile: "myProfile", - }; - - await expect( - (module as any).batchExportPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageKeys or --keysByVersion, but not both."); - - expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); - }); - }); - }); - - describe("batchImportPackages validation", () => { - describe("gitProfile and gitBranch validation", () => { - it("should throw error when gitProfile is provided without gitBranch option", async () => { - const options: OptionValues = { - file: "export.zip", - gitProfile: "myProfile", - }; - - await expect( - (module as any).batchImportPackages(testContext, mockCommand, options) - ).rejects.toThrow("Please specify a branch using --gitBranch when using a Git profile."); - - expect(mockT2tcCommandService.batchImportPackages).not.toHaveBeenCalled(); - }); - - it("should pass validation when gitProfile is provided with gitBranch option", async () => { - const options: OptionValues = { - file: "export.zip", - gitBranch: "main", - gitProfile: "myProfile", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - "export.zip", - undefined, - undefined, - "main", - undefined - ); - }); - - it("should pass validation when gitBranch is provided without gitProfile in context", async () => { - const options: OptionValues = { - directory: "./exported", - gitBranch: "develop", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - undefined, - "./exported", - undefined, - "develop", - undefined - ); - }); - - it("should pass validation when neither gitProfile in context nor gitBranch in options are provided", async () => { - const options: OptionValues = { - file: "export.zip", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - "export.zip", - undefined, - undefined, - undefined, - undefined - ); - }); - }); - - describe("import options", () => { - it("should pass file option correctly", async () => { - const options: OptionValues = { - file: "my-export.zip", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - "my-export.zip", - undefined, - undefined, - undefined, - undefined - ); - }); - - it("should pass directory option correctly", async () => { - const options: OptionValues = { - directory: "./my-exports", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - undefined, - "./my-exports", - undefined, - undefined, - undefined - ); - }); - - it("should pass overwrite option correctly", async () => { - const options: OptionValues = { - file: "export.zip", - overwrite: true, - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - "export.zip", - undefined, - true, - undefined, - undefined - ); - }); - - it("should handle all options together", async () => { - const options: OptionValues = { - directory: "./exports", - overwrite: true, - gitBranch: "feature-branch", - }; - - await (module as any).batchImportPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( - undefined, - "./exports", - true, - "feature-branch", - undefined - ); - }); - }); - }); - - describe("importSinglePackage", () => { - it("should pass file option correctly", async () => { - const options: OptionValues = { - file: "single-package.zip", - }; - - await (module as any).importSinglePackage(testContext, mockCommand, options); - - expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( - "single-package.zip", - undefined, - undefined, - undefined - ); - }); - - it("should pass directory option correctly", async () => { - const options: OptionValues = { - directory: "./single-package-dir", - }; - - await (module as any).importSinglePackage(testContext, mockCommand, options); - - expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( - undefined, - "./single-package-dir", - undefined, - undefined - ); - }); - - it("should pass overwrite and json options correctly", async () => { - const options: OptionValues = { - file: "single-package.zip", - overwrite: true, - json: true, - }; - - await (module as any).importSinglePackage(testContext, mockCommand, options); - - expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( - "single-package.zip", - undefined, - true, - true - ); - }); - }); - - describe("listVariables validation", () => { - it("should throw when --packageKeys and --keysByVersion are both provided", async () => { - const options: OptionValues = { - ...variablesListOptionDefaults, - packageKeys: ["pkg-a"], - keysByVersion: ["key-1:1.0.0"], - }; - - await expect( - (module as any).listVariables(testContext, mockCommand, options) - ).rejects.toThrow( - "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." - ); - - expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); - }); - - it("should throw when --packageKeys and --keysByVersionFile are both provided", async () => { - const options: OptionValues = { - ...variablesListOptionDefaults, - packageKeys: ["pkg-a"], - keysByVersionFile: "mapping.json", - }; - - await expect( - (module as any).listVariables(testContext, mockCommand, options) - ).rejects.toThrow( - "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." - ); - - expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); - }); - - it("should throw when neither staging nor versioned inputs are provided", async () => { - const options: OptionValues = {...variablesListOptionDefaults}; - - await expect( - (module as any).listVariables(testContext, mockCommand, options) - ).rejects.toThrow( - "Please provide --packageKeys for staging, or --keysByVersion / --keysByVersionFile for versioned packages." - ); - - expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); - }); - - it("should call listVariables for staging when only --packageKeys is provided", async () => { - const options: OptionValues = { - ...variablesListOptionDefaults, - packageKeys: ["pkg-a", "pkg-b"], - json: true, - }; - - await (module as any).listVariables(testContext, mockCommand, options); - - expect(mockConfigCommandService.listVariables).toHaveBeenCalledWith( - true, - [], - "", - ["pkg-a", "pkg-b"] - ); - }); - - it("should call listVariables for versioned when only --keysByVersion is provided", async () => { - const options: OptionValues = { - ...variablesListOptionDefaults, - keysByVersion: ["k:v"], - json: false, - }; - - await (module as any).listVariables(testContext, mockCommand, options); - - expect(mockConfigCommandService.listVariables).toHaveBeenCalledWith( - false, - ["k:v"], - "", - [] - ); - }); - }); - - describe("createPackageVersion validation", () => { - let mockPackageVersionCommandService: jest.Mocked; - - beforeEach(() => { - mockPackageVersionCommandService = { - createPackageVersion: jest.fn().mockResolvedValue(undefined), - } as any; - - (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); - }); - - it("should throw error when both --packageVersion and --versionBumpOption PATCH are provided", async () => { - const options: OptionValues = { - packageKey: "my-package", - packageVersion: "1.2.0", - versionBumpOption: "PATCH", - }; - - await expect( - (module as any).createPackageVersion(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageVersion or --versionBumpOption, but not both."); - - expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); - }); - - it("should throw error when neither --packageVersion nor --versionBumpOption PATCH are provided", async () => { - const options: OptionValues = { - packageKey: "my-package", - versionBumpOption: "NONE", - }; - - await expect( - (module as any).createPackageVersion(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageVersion or --versionBumpOption PATCH."); - - expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); - }); - - it("should throw error when --packageVersion is missing and --versionBumpOption is not provided (defaults to NONE)", async () => { - const options: OptionValues = { - packageKey: "my-package", - }; - - await expect( - (module as any).createPackageVersion(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --packageVersion or --versionBumpOption PATCH."); - - expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); - }); - - it("should pass validation when only --packageVersion is provided", async () => { - const options: OptionValues = { - packageKey: "my-package", - packageVersion: "1.2.0", - versionBumpOption: "NONE", - summaryOfChanges: "New features", - }; - - await (module as any).createPackageVersion(testContext, mockCommand, options); - - expect(mockPackageVersionCommandService.createPackageVersion).toHaveBeenCalledWith( - "my-package", - "1.2.0", - "NONE", - "New features", - undefined, - undefined, - ); - }); - - it("should pass validation when only --versionBumpOption PATCH is provided", async () => { - const options: OptionValues = { - packageKey: "my-package", - versionBumpOption: "PATCH", - summaryOfChanges: "Bug fixes", - }; - - await (module as any).createPackageVersion(testContext, mockCommand, options); - - expect(mockPackageVersionCommandService.createPackageVersion).toHaveBeenCalledWith( - "my-package", - undefined, - "PATCH", - "Bug fixes", - undefined, - undefined, - ); - }); - }); - - describe("register", () => { - it("registers all expected top-level command groups without throwing", () => { - const mockConfigurator = createMockConfigurator(); - - expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow(); - - // Top-level groups attached to the root configurator. The 't2tc' group - // lives in its own module (tests/commands/t2tc/module.spec.ts). - expect(mockConfigurator.command).toHaveBeenCalledWith("config"); - expect(mockConfigurator.command).toHaveBeenCalledWith("list"); - expect(mockConfigurator.command).toHaveBeenCalledWith("package"); - }); - - it("wires an action handler for every leaf subcommand", () => { - const mockConfigurator = createMockConfigurator(); - - new Module().register(testContext, mockConfigurator); - - // Each leaf command terminates the fluent chain with .action(handler). - // Keep this count in sync when adding or removing commands in module.ts. - // The 4 't2tc package' leaf commands moved to their own module. - const expectedLeafCommands = 20; - expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands); - for (const call of mockConfigurator.action.mock.calls) { - expect(typeof call[0]).toBe("function"); - } - }); - - it("marks the moved config commands as deprecated", () => { - const mockConfigurator = createMockConfigurator(); - - new Module().register(testContext, mockConfigurator); - - // config list/export/import/diff/validate are duplicated under t2tc package / - // config package and the originals carry a deprecation notice. - const expectedDeprecatedCommands = 5; - expect(mockConfigurator.deprecationNotice).toHaveBeenCalledTimes(expectedDeprecatedCommands); - for (const call of mockConfigurator.deprecationNotice.mock.calls) { - expect(typeof call[0]).toBe("string"); - } - }); - }); - - describe("listNodeDependencies", () => { - it("should call listNodeDependencies with correct parameters", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - packageVersion: "1.0.0", - }; - - await (module as any).listNodeDependencies(testContext, mockCommand, options); - - expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( - "test-package", - "test-node", - "1.0.0", - undefined - ); - }); - - it("should pass json option when provided", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - packageVersion: "2.0.0", - json: true, - }; - - await (module as any).listNodeDependencies(testContext, mockCommand, options); - - expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( - "test-package", - "test-node", - "2.0.0", - true - ); - }); - - it("should handle different package versions", async () => { - const options: OptionValues = { - packageKey: "production-package", - nodeKey: "production-node", - packageVersion: "3.5.2", - json: false, - }; - - await (module as any).listNodeDependencies(testContext, mockCommand, options); - - expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( - "production-package", - "production-node", - "3.5.2", - false - ); - }); - - it("should handle all parameters correctly", async () => { - const options: OptionValues = { - packageKey: "my-package", - nodeKey: "my-node", - packageVersion: "1.2.3", - json: true, - }; - - await (module as any).listNodeDependencies(testContext, mockCommand, options); - - expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledTimes(1); - expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( - "my-package", - "my-node", - "1.2.3", - true - ); - }); - }); - - describe("diffPackages", () => { - it("should call diffPackages using minimal parameters", async () => { - const options: OptionValues = { - file: "package.zip", - }; - - await (module as any).diffPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( - "package.zip", undefined, undefined, undefined - ); - }); - - it("should pass json parameter", async () => { - const options: OptionValues = { - file: "package.zip", - json: true, - }; - - await (module as any).diffPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( - "package.zip", undefined, undefined, true - ); - }); - - it("should pass hasChanges parameter", async () => { - const options: OptionValues = { - file: "package.zip", - hasChanges: true, - }; - - await (module as any).diffPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( - "package.zip", true, undefined, undefined - ); - }); - - it("should pass baseVersion parameter", async () => { - const options: OptionValues = { - file: "package.zip", - baseVersion: "1.0.0", - }; - - await (module as any).diffPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( - "package.zip", undefined, "1.0.0", undefined - ); - }); - - it("should pass both parameters when hasChanges and baseVersion are used together", async () => { - const options: OptionValues = { - file: "package.zip", - hasChanges: true, - baseVersion: "STAGING" - }; - - await (module as any).diffPackages(testContext, mockCommand, options); - - expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( - "package.zip", true, "STAGING", undefined - ); - }); - }); - - describe("diffNode validation", () => { - it("should throw when both --file and --compareVersion are provided", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - baseVersion: "STAGING", - compareVersion: "1.0.0", - file: "./node.json", - }; - - await expect( - (module as any).diffNode(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --compareVersion or --file, but not both."); - - expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); - expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); - }); - - it("should throw when neither --file nor --compareVersion is provided", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - baseVersion: "STAGING", - }; - - await expect( - (module as any).diffNode(testContext, mockCommand, options) - ).rejects.toThrow("Please provide either --compareVersion or --file, but not both."); - - expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); - expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); - }); - - it("should call diff when only --compareVersion is provided", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - baseVersion: "STAGING", - compareVersion: "1.0.0", - json: true, - }; - - await (module as any).diffNode(testContext, mockCommand, options); - - expect(mockNodeDiffService.diff).toHaveBeenCalledWith( - "test-package", - "test-node", - "STAGING", - "1.0.0", - true - ); - expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); - }); - - it("should call diffWithFile when only --file is provided", async () => { - const options: OptionValues = { - packageKey: "test-package", - nodeKey: "test-node", - baseVersion: "STAGING", - file: "./node.json", - }; - - await (module as any).diffNode(testContext, mockCommand, options); - - expect(mockNodeDiffService.diffWithFile).toHaveBeenCalledWith( - "test-package", - "test-node", - "STAGING", - "./node.json", - undefined - ); - expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); - }); - }); -}); - diff --git a/tests/integration/commands/asset-registry.spec.ts b/tests/integration/commands/asset-registry.spec.ts new file mode 100644 index 0000000..3829b35 --- /dev/null +++ b/tests/integration/commands/asset-registry.spec.ts @@ -0,0 +1,144 @@ +import Module = require("../../../src/commands/asset-registry/module"); +import { Command } from "commander"; +import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; +import { buildTestProgram } from "../../utls/cli-program"; + +jest.mock("../../../src/commands/asset-registry/asset-registry.service"); + +describe("asset-registry command integration", () => { + let program: Command; + let mockService: jest.Mocked; + + beforeEach(() => { + mockService = { + listTypes: jest.fn().mockResolvedValue(undefined), + listSkills: jest.fn().mockResolvedValue(undefined), + getType: jest.fn().mockResolvedValue(undefined), + getSchema: jest.fn().mockResolvedValue(undefined), + validate: jest.fn().mockResolvedValue(undefined), + getExamples: jest.fn().mockResolvedValue(undefined), + } as any; + + (AssetRegistryService as jest.MockedClass) + .mockImplementation(() => mockService); + + program = buildTestProgram([Module]); + }); + + function runCli(args: string[]): Promise { + return program.parseAsync(["node", "content-cli", ...args]); + } + + describe("asset-registry schema", () => { + it("calls getSchema with --json", async () => { + await runCli(["asset-registry", "schema", "--assetType", "BOARD_V2", "--json"]); + expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", true); + }); + + it("defaults --json to false when omitted", async () => { + await runCli(["asset-registry", "schema", "--assetType", "BOARD_V2"]); + expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", false); + }); + }); + + describe("asset-registry validate", () => { + it("forwards --configuration sub-mode options", async () => { + await runCli([ + "asset-registry", "validate", + "--assetType", "BOARD_V2", + "--packageKey", "my-pkg", + "--configuration", '{"components":[]}', + "--json", + ]); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: undefined, + configuration: '{"components":[]}', + file: undefined, + json: true, + }); + }); + + it("forwards --nodeKey sub-mode options", async () => { + await runCli([ + "asset-registry", "validate", + "--assetType", "BOARD_V2", + "--packageKey", "my-pkg", + "--nodeKey", "my-view", + ]); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: "my-pkg", + nodeKey: "my-view", + configuration: undefined, + file: undefined, + json: false, + }); + }); + + it("forwards --file mode options", async () => { + await runCli([ + "asset-registry", "validate", + "--assetType", "BOARD_V2", + "--file", "request.json", + ]); + expect(mockService.validate).toHaveBeenCalledWith({ + assetType: "BOARD_V2", + packageKey: undefined, + nodeKey: undefined, + configuration: undefined, + file: "request.json", + json: false, + }); + }); + }); + + describe("asset-registry examples", () => { + it("calls getExamples without --json", async () => { + await runCli(["asset-registry", "examples", "--assetType", "BOARD_V2"]); + expect(mockService.getExamples).toHaveBeenCalledWith("BOARD_V2", false); + }); + + it("calls getExamples with --json", async () => { + await runCli(["asset-registry", "examples", "--assetType", "BOARD_V2", "--json"]); + expect(mockService.getExamples).toHaveBeenCalledWith("BOARD_V2", true); + }); + }); + + describe("asset-registry list", () => { + it("calls listTypes with --json", async () => { + await runCli(["asset-registry", "list", "--json"]); + expect(mockService.listTypes).toHaveBeenCalledWith(true); + }); + + it("calls listTypes without --json", async () => { + await runCli(["asset-registry", "list"]); + expect(mockService.listTypes).toHaveBeenCalledWith(false); + }); + }); + + describe("asset-registry skills list", () => { + it("calls listSkills with --json", async () => { + await runCli(["asset-registry", "skills", "list", "--json"]); + expect(mockService.listSkills).toHaveBeenCalledWith(true); + }); + + it("calls listSkills without --json", async () => { + await runCli(["asset-registry", "skills", "list"]); + expect(mockService.listSkills).toHaveBeenCalledWith(false); + }); + }); + + describe("asset-registry get", () => { + it("calls getType with the requested assetType", async () => { + await runCli(["asset-registry", "get", "--assetType", "BOARD_V2"]); + expect(mockService.getType).toHaveBeenCalledWith("BOARD_V2", false); + }); + + it("calls getType with --json", async () => { + await runCli(["asset-registry", "get", "--assetType", "BOARD_V2", "--json"]); + expect(mockService.getType).toHaveBeenCalledWith("BOARD_V2", true); + }); + }); +}); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts new file mode 100644 index 0000000..cdb6bbe --- /dev/null +++ b/tests/integration/commands/configuration-management.spec.ts @@ -0,0 +1,833 @@ +import Module = require("../../../src/commands/configuration-management/module"); +import { Command } from "commander"; +import { ConfigCommandService } from "../../../src/commands/configuration-management/config-command.service"; +import { StagingPackageService } from "../../../src/commands/configuration-management/staging-package.service"; +import { MetadataService } from "../../../src/commands/configuration-management/metadata.service"; +import { T2tcCommandService } from "../../../src/commands/t2tc/t2tc-command.service"; +import { NodeDependencyService } from "../../../src/commands/configuration-management/node-dependency.service"; +import { PackageVersionCommandService } from "../../../src/commands/configuration-management/package-version-command.service"; +import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; +import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; +import { buildTestProgram } from "../../utls/cli-program"; +import { loggingTestTransport } from "../../jest.setup"; + +jest.mock("../../../src/commands/configuration-management/config-command.service"); +jest.mock("../../../src/commands/configuration-management/staging-package.service"); +jest.mock("../../../src/commands/configuration-management/metadata.service"); +jest.mock("../../../src/commands/t2tc/t2tc-command.service"); +jest.mock("../../../src/commands/configuration-management/node-dependency.service"); +jest.mock("../../../src/commands/configuration-management/node-diff.service"); +jest.mock("../../../src/commands/configuration-management/package-version-command.service"); +jest.mock("../../../src/commands/configuration-management/single-package-import.service"); + +describe("configuration-management command integration", () => { + let program: Command; + let mockConfigCommandService: jest.Mocked; + let mockStagingPackageService: jest.Mocked; + let mockMetadataService: jest.Mocked; + let mockT2tcCommandService: jest.Mocked; + let mockNodeDependencyService: jest.Mocked; + let mockNodeDiffService: jest.Mocked; + let mockPackageVersionCommandService: jest.Mocked; + let mockSinglePackageImportService: jest.Mocked; + + beforeEach(() => { + mockConfigCommandService = { + listVariables: jest.fn().mockResolvedValue(undefined), + } as any; + + mockStagingPackageService = { + listStagingPackages: jest.fn().mockResolvedValue(undefined), + } as any; + + mockMetadataService = { + exportPackagesMetadata: jest.fn().mockResolvedValue(undefined), + } as any; + + mockT2tcCommandService = { + listPackages: jest.fn().mockResolvedValue(undefined), + batchExportPackages: jest.fn().mockResolvedValue(undefined), + batchImportPackages: jest.fn().mockResolvedValue(undefined), + diffPackages: jest.fn().mockResolvedValue(undefined), + } as any; + + mockNodeDependencyService = { + listNodeDependencies: jest.fn().mockResolvedValue(undefined), + } as any; + + mockNodeDiffService = { + diff: jest.fn().mockResolvedValue(undefined), + diffWithFile: jest.fn().mockResolvedValue(undefined), + } as any; + + mockPackageVersionCommandService = { + createPackageVersion: jest.fn().mockResolvedValue(undefined), + getPackageVersion: jest.fn().mockResolvedValue(undefined), + } as any; + + mockSinglePackageImportService = { + importPackage: jest.fn().mockResolvedValue(undefined), + } as any; + + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); + (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); + (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); + (T2tcCommandService as jest.MockedClass).mockImplementation(() => mockT2tcCommandService); + (NodeDependencyService as jest.MockedClass).mockImplementation(() => mockNodeDependencyService); + (NodeDiffService as jest.MockedClass).mockImplementation(() => mockNodeDiffService); + (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); + (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); + + program = buildTestProgram([Module]); + }); + + function runCli(args: string[]): Promise { + return program.parseAsync(["node", "content-cli", ...args]); + } + + /** + * Action-body validation errors (`throw new Error(...)`) are caught by + * Configurator.action and re-emitted via `logger.error(...)`, so we + * inspect the in-memory winston transport instead of asserting on + * promise rejection. The level field is colorized by `winston.format.cli()`, + * hence the substring match. + */ + function expectErrorLogged(message: string): void { + expect(loggingTestTransport.logMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + level: expect.stringContaining("error"), + message: expect.stringContaining(message), + }), + ])); + } + + describe("config list (deprecated listPackages)", () => { + it("rejects when both --packageKeys and --keysByVersion are provided", async () => { + await runCli([ + "config", "list", + "--packageKeys", "package1", "package2", + "--keysByVersion", "package3.1.0.0", "package4.1.0.0", + ]); + + expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expect(mockT2tcCommandService.listPackages).not.toHaveBeenCalled(); + }); + + it("forwards only --packageKeys when provided", async () => { + await runCli([ + "config", "list", + "--packageKeys", "package1", "package2", + "--json", + ]); + + expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( + true, + undefined, + "", + ["package1", "package2"], + undefined, + undefined, + undefined, + false, + false + ); + }); + + it("forwards only --keysByVersion when provided", async () => { + await runCli([ + "config", "list", + "--keysByVersion", "package3.1.0.0", "package4.1.0.0", + "--json", + ]); + + expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( + true, + undefined, + "", + undefined, + ["package3.1.0.0", "package4.1.0.0"], + undefined, + undefined, + false, + false + ); + }); + + it("forwards --branches when provided", async () => { + await runCli(["config", "list", "--branches", "--json"]); + + expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( + true, + undefined, + "", + undefined, + undefined, + undefined, + undefined, + true, + false + ); + }); + + describe("--staging incompatibility", () => { + it("forwards --staging --branches without other filters", async () => { + await runCli(["config", "list", "--branches", "--staging", "--json"]); + + expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( + true, + undefined, + "", + undefined, + undefined, + undefined, + undefined, + true, + true + ); + }); + + it("rejects --staging combined with --packageKeys", async () => { + await runCli([ + "config", "list", + "--staging", + "--packageKeys", "package1", "package2", + ]); + + expectErrorLogged( + "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" + ); + }); + + it("rejects --staging combined with --withDependencies", async () => { + await runCli(["config", "list", "--staging", "--withDependencies"]); + + expectErrorLogged( + "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" + ); + }); + + it("rejects --staging combined with --keysByVersion", async () => { + await runCli([ + "config", "list", + "--staging", + "--keysByVersion", "package3.1.0.0", "package4.1.0.0", + ]); + + expectErrorLogged( + "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" + ); + }); + + it("rejects --staging combined with --variableValue", async () => { + await runCli(["config", "list", "--staging", "--variableValue", "myValue"]); + + expectErrorLogged( + "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" + ); + }); + + it("rejects --staging combined with --variableType", async () => { + await runCli(["config", "list", "--staging", "--variableType", "myType"]); + + expectErrorLogged( + "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" + ); + }); + }); + }); + + describe("config package list (listStagingPackages)", () => { + it("forwards --json and --flavors", async () => { + await runCli(["config", "package", "list", "--json", "--flavors", "APP"]); + + expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith( + ["APP"], + false, + true + ); + }); + + it("defaults flavors to an empty list and json to '' when not provided", async () => { + await runCli(["config", "package", "list"]); + + expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith([], false, ""); + }); + + it("forwards multiple --flavors values", async () => { + await runCli(["config", "package", "list", "--flavors", "APP", "ANALYSIS"]); + + expect(mockStagingPackageService.listStagingPackages).toHaveBeenCalledWith( + ["APP", "ANALYSIS"], + false, + "" + ); + expect(mockT2tcCommandService.listPackages).not.toHaveBeenCalled(); + }); + }); + + describe("config export (deprecated batchExportPackages)", () => { + it("rejects when both --packageKeys and --keysByVersion are provided", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", "package2", + "--keysByVersion", "package3:v1", "package4:v2", + ]); + + expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); + }); + + it("rejects when neither --packageKeys nor --keysByVersion are provided", async () => { + await runCli(["config", "export"]); + + expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); + }); + + it("forwards only --packageKeys when provided", async () => { + await runCli(["config", "export", "--packageKeys", "package1", "package2"]); + + expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( + ["package1", "package2"], + undefined, + "", + undefined, + "" + ); + }); + + it("forwards only --keysByVersion when provided", async () => { + await runCli(["config", "export", "--keysByVersion", "package3:v1", "package4:v2"]); + + expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( + undefined, + ["package3:v1", "package4:v2"], + "", + undefined, + "" + ); + }); + + it("rejects when --gitProfile is provided without --gitBranch", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", + "--gitProfile", "myProfile", + ]); + + expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); + }); + + it("forwards --gitProfile + --gitBranch", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", + "--gitProfile", "myProfile", + "--gitBranch", "main", + ]); + + expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( + ["package1"], + undefined, + "", + "main", + "" + ); + }); + + it("forwards --withDependencies", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", + "--withDependencies", + ]); + + expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( + ["package1"], + undefined, + true, + undefined, + "" + ); + }); + + it("forwards --unzip", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", + "--unzip", + ]); + + expect(mockT2tcCommandService.batchExportPackages).toHaveBeenCalledWith( + ["package1"], + undefined, + "", + undefined, + true + ); + }); + + it("fails on packageKeys/keysByVersion conflict before checking --gitProfile", async () => { + await runCli([ + "config", "export", + "--packageKeys", "package1", + "--keysByVersion", "package2:v1", + "--gitProfile", "myProfile", + ]); + + expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); + }); + }); + + describe("config import (deprecated batchImportPackages)", () => { + it("rejects when --gitProfile is provided without --gitBranch", async () => { + await runCli([ + "config", "import", + "--file", "export.zip", + "--gitProfile", "myProfile", + ]); + + expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expect(mockT2tcCommandService.batchImportPackages).not.toHaveBeenCalled(); + }); + + it("forwards --gitProfile + --gitBranch with --file", async () => { + await runCli([ + "config", "import", + "--file", "export.zip", + "--gitProfile", "myProfile", + "--gitBranch", "main", + ]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + "export.zip", + undefined, + undefined, + "main", + false + ); + }); + + it("forwards --directory + --gitBranch", async () => { + await runCli([ + "config", "import", + "--directory", "./exported", + "--gitBranch", "develop", + ]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + undefined, + "./exported", + undefined, + "develop", + false + ); + }); + + it("forwards minimal --file invocation", async () => { + await runCli(["config", "import", "--file", "export.zip"]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + "export.zip", + undefined, + undefined, + undefined, + false + ); + }); + + it("forwards --directory invocation", async () => { + await runCli(["config", "import", "--directory", "./my-exports"]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + undefined, + "./my-exports", + undefined, + undefined, + false + ); + }); + + it("forwards --overwrite", async () => { + await runCli(["config", "import", "--file", "export.zip", "--overwrite"]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + "export.zip", + undefined, + true, + undefined, + false + ); + }); + + it("forwards combined options", async () => { + await runCli([ + "config", "import", + "--directory", "./exports", + "--overwrite", + "--gitBranch", "feature-branch", + ]); + + expect(mockT2tcCommandService.batchImportPackages).toHaveBeenCalledWith( + undefined, + "./exports", + true, + "feature-branch", + false + ); + }); + }); + + describe("config package import (importSinglePackage)", () => { + it("forwards --file", async () => { + await runCli(["config", "package", "import", "--file", "single-package.zip"]); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + "single-package.zip", + undefined, + undefined, + undefined + ); + }); + + it("forwards --directory", async () => { + await runCli(["config", "package", "import", "--directory", "./single-package-dir"]); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + undefined, + "./single-package-dir", + undefined, + undefined + ); + }); + + it("forwards --overwrite and --json", async () => { + await runCli([ + "config", "package", "import", + "--file", "single-package.zip", + "--overwrite", + "--json", + ]); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + "single-package.zip", + undefined, + true, + true + ); + }); + }); + + describe("config variables list (listVariables)", () => { + it("rejects when --packageKeys and --keysByVersion are both provided", async () => { + await runCli([ + "config", "variables", "list", + "--packageKeys", "pkg-a", + "--keysByVersion", "key-1:1.0.0", + ]); + + expectErrorLogged( + "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." + ); + expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); + }); + + it("rejects when --packageKeys and --keysByVersionFile are both provided", async () => { + await runCli([ + "config", "variables", "list", + "--packageKeys", "pkg-a", + "--keysByVersionFile", "mapping.json", + ]); + + expectErrorLogged( + "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." + ); + expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); + }); + + it("rejects when neither staging nor versioned inputs are provided", async () => { + await runCli(["config", "variables", "list"]); + + expectErrorLogged( + "Please provide --packageKeys for staging, or --keysByVersion / --keysByVersionFile for versioned packages." + ); + expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); + }); + + it("forwards staging-only variant when only --packageKeys is provided", async () => { + await runCli([ + "config", "variables", "list", + "--packageKeys", "pkg-a", "pkg-b", + "--json", + ]); + + expect(mockConfigCommandService.listVariables).toHaveBeenCalledWith( + true, + [], + "", + ["pkg-a", "pkg-b"] + ); + }); + + it("forwards versioned variant when only --keysByVersion is provided", async () => { + await runCli([ + "config", "variables", "list", + "--keysByVersion", "k:v", + ]); + + expect(mockConfigCommandService.listVariables).toHaveBeenCalledWith( + "", + ["k:v"], + "", + [] + ); + }); + }); + + describe("config versions create (createPackageVersion)", () => { + it("rejects when both --packageVersion and --versionBumpOption PATCH are provided", async () => { + await runCli([ + "config", "versions", "create", + "--packageKey", "my-package", + "--packageVersion", "1.2.0", + "--versionBumpOption", "PATCH", + ]); + + expectErrorLogged("Please provide either --packageVersion or --versionBumpOption, but not both."); + expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); + }); + + it("rejects when --versionBumpOption is explicit NONE without --packageVersion", async () => { + await runCli([ + "config", "versions", "create", + "--packageKey", "my-package", + "--versionBumpOption", "NONE", + ]); + + expectErrorLogged("Please provide either --packageVersion or --versionBumpOption PATCH."); + expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); + }); + + it("rejects when --packageVersion is missing and --versionBumpOption defaults to NONE", async () => { + await runCli([ + "config", "versions", "create", + "--packageKey", "my-package", + ]); + + expectErrorLogged("Please provide either --packageVersion or --versionBumpOption PATCH."); + expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); + }); + + it("forwards --packageVersion + --summaryOfChanges", async () => { + await runCli([ + "config", "versions", "create", + "--packageKey", "my-package", + "--packageVersion", "1.2.0", + "--versionBumpOption", "NONE", + "--summaryOfChanges", "New features", + ]); + + expect(mockPackageVersionCommandService.createPackageVersion).toHaveBeenCalledWith( + "my-package", + "1.2.0", + "NONE", + "New features", + undefined, + undefined + ); + }); + + it("forwards --versionBumpOption PATCH only", async () => { + await runCli([ + "config", "versions", "create", + "--packageKey", "my-package", + "--versionBumpOption", "PATCH", + "--summaryOfChanges", "Bug fixes", + ]); + + expect(mockPackageVersionCommandService.createPackageVersion).toHaveBeenCalledWith( + "my-package", + undefined, + "PATCH", + "Bug fixes", + undefined, + undefined + ); + }); + }); + + describe("config nodes dependencies list (listNodeDependencies)", () => { + it("forwards required arguments", async () => { + await runCli([ + "config", "nodes", "dependencies", "list", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--packageVersion", "1.0.0", + ]); + + expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( + "test-package", + "test-node", + "1.0.0", + undefined + ); + }); + + it("forwards --json", async () => { + await runCli([ + "config", "nodes", "dependencies", "list", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--packageVersion", "2.0.0", + "--json", + ]); + + expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( + "test-package", + "test-node", + "2.0.0", + true + ); + }); + + it("calls listNodeDependencies exactly once", async () => { + await runCli([ + "config", "nodes", "dependencies", "list", + "--packageKey", "my-package", + "--nodeKey", "my-node", + "--packageVersion", "1.2.3", + "--json", + ]); + + expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledTimes(1); + expect(mockNodeDependencyService.listNodeDependencies).toHaveBeenCalledWith( + "my-package", + "my-node", + "1.2.3", + true + ); + }); + }); + + describe("config diff (diffPackages)", () => { + it("forwards minimal --file invocation", async () => { + await runCli(["config", "diff", "--file", "package.zip"]); + + expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( + "package.zip", undefined, undefined, undefined + ); + }); + + it("forwards --json", async () => { + await runCli(["config", "diff", "--file", "package.zip", "--json"]); + + expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( + "package.zip", undefined, undefined, true + ); + }); + + it("forwards --hasChanges", async () => { + await runCli(["config", "diff", "--file", "package.zip", "--hasChanges"]); + + expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( + "package.zip", true, undefined, undefined + ); + }); + + it("forwards --baseVersion", async () => { + await runCli([ + "config", "diff", + "--file", "package.zip", + "--baseVersion", "1.0.0", + ]); + + expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( + "package.zip", undefined, "1.0.0", undefined + ); + }); + + it("forwards --hasChanges + --baseVersion together", async () => { + await runCli([ + "config", "diff", + "--file", "package.zip", + "--hasChanges", + "--baseVersion", "STAGING", + ]); + + expect(mockT2tcCommandService.diffPackages).toHaveBeenCalledWith( + "package.zip", true, "STAGING", undefined + ); + }); + }); + + describe("config nodes diff (diffNode)", () => { + it("rejects when both --file and --compareVersion are provided", async () => { + await runCli([ + "config", "nodes", "diff", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--baseVersion", "STAGING", + "--compareVersion", "1.0.0", + "--file", "./node.json", + ]); + + expectErrorLogged("Please provide either --compareVersion or --file, but not both."); + expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); + expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); + }); + + it("rejects when neither --file nor --compareVersion is provided", async () => { + await runCli([ + "config", "nodes", "diff", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--baseVersion", "STAGING", + ]); + + expectErrorLogged("Please provide either --compareVersion or --file, but not both."); + expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); + expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); + }); + + it("calls diff when only --compareVersion is provided", async () => { + await runCli([ + "config", "nodes", "diff", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--baseVersion", "STAGING", + "--compareVersion", "1.0.0", + "--json", + ]); + + expect(mockNodeDiffService.diff).toHaveBeenCalledWith( + "test-package", + "test-node", + "STAGING", + "1.0.0", + true + ); + expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); + }); + + it("calls diffWithFile when only --file is provided", async () => { + await runCli([ + "config", "nodes", "diff", + "--packageKey", "test-package", + "--nodeKey", "test-node", + "--baseVersion", "STAGING", + "--file", "./node.json", + ]); + + expect(mockNodeDiffService.diffWithFile).toHaveBeenCalledWith( + "test-package", + "test-node", + "STAGING", + "./node.json", + undefined + ); + expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utls/cli-program.ts b/tests/utls/cli-program.ts new file mode 100644 index 0000000..825fc0a --- /dev/null +++ b/tests/utls/cli-program.ts @@ -0,0 +1,8 @@ +import { Command } from "commander"; +import { createProgram } from "../../src/content-cli"; +import { IModuleConstructor } from "../../src/core/command/module-handler"; +import { testContext } from "./test-context"; + +export function buildTestProgram(modules: IModuleConstructor[]): Command { + return createProgram(testContext, { modules }); +}