From 05a76e4b1e0aded18ebd8f21c8c75812e6ebb0b6 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Thu, 11 Jun 2026 16:27:51 +0200 Subject: [PATCH 1/9] SP-1090: assert real CLI output and exit codes; fail commands with non-zero exit --- .../studio/manager/package.manager.ts | 2 +- src/core/command/module-handler.ts | 7 +- tests/integration/cli-process-output.spec.ts | 112 ++++++++++++++++++ tests/utls/cli-runner.ts | 112 ++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 tests/integration/cli-process-output.spec.ts create mode 100644 tests/utls/cli-runner.ts diff --git a/src/commands/studio/manager/package.manager.ts b/src/commands/studio/manager/package.manager.ts index 46e5f750..a5a5f512 100644 --- a/src/commands/studio/manager/package.manager.ts +++ b/src/commands/studio/manager/package.manager.ts @@ -126,7 +126,7 @@ export class PackageManager extends BaseManager { logger.error( "You cannot overwrite a package and set a new key at the same time. Please use only one of the options." ); - process.exit(); + process.exit(1); } } diff --git a/src/core/command/module-handler.ts b/src/core/command/module-handler.ts index 5c94ad11..d4853679 100644 --- a/src/core/command/module-handler.ts +++ b/src/core/command/module-handler.ts @@ -2,7 +2,7 @@ import path = require("path"); import * as fs from "fs"; import { Command, CommandOptions, Option, OptionValues } from "commander"; import { Context } from "./cli-context"; -import { logger } from "../utils/logger"; +import { GracefulError, logger } from "../utils/logger"; import * as chalk from "chalk"; export abstract class IModule { @@ -216,7 +216,12 @@ export class CommandConfig { this.printDeprecationNoticeIfDeprecated(); await handler(this.ctx, this.cmd, this.cmd.optsWithGlobals()); } catch (error) { + if (error instanceof GracefulError) { + logger.error(error.message); + return; + } logger.error(`An unexpected error occured executing a command: ${error}`); + process.exitCode = 1; } }); } diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts new file mode 100644 index 00000000..c683c3c4 --- /dev/null +++ b/tests/integration/cli-process-output.spec.ts @@ -0,0 +1,112 @@ +import { Command, OptionValues } from "commander"; +import { runCli } from "../utls/cli-runner"; +import { mockAxiosGet, mockAxiosGetError } from "../utls/http-requests-mock"; +import { + ASSET_REGISTRY_DISABLED_ERROR, + ASSET_REGISTRY_DISABLED_USER_MESSAGE, +} from "../../src/commands/asset-registry/asset-registry-error"; +import { AssetRegistryMetadata } from "../../src/commands/asset-registry/asset-registry.interfaces"; +import { Configurator, IModule } from "../../src/core/command/module-handler"; +import { Context } from "../../src/core/command/cli-context"; +import { FatalError, GracefulError } from "../../src/core/utils/logger"; + +import AssetRegistryModule = require("../../src/commands/asset-registry/module"); + +const GRACEFUL_MESSAGE = "graceful failure - should not fail the process"; + +class DiagnosticsModule extends IModule { + public register(context: Context, configurator: Configurator): void { + const diag = configurator.command("diag").description("Diagnostics test commands"); + + diag.command("graceful") + .description("Throws a GracefulError") + .action(async (_ctx: Context, _cmd: Command, _opts: OptionValues): Promise => { + throw new GracefulError(GRACEFUL_MESSAGE); + }); + + diag.command("fatal") + .description("Throws a FatalError") + .action(async (_ctx: Context, _cmd: Command, _opts: OptionValues): Promise => { + throw new FatalError("boom"); + }); + } +} + +describe("CLI process output and exit codes", () => { + const TYPES_URL = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/types"; + + const metadata: AssetRegistryMetadata = { + types: { + BOARD_V2: { + assetType: "BOARD_V2", + displayName: "View", + description: null, + group: "DASHBOARDS", + assetSchema: { version: "2.1.0" }, + service: { basePath: "/blueprint/api" }, + endpoints: { + schema: "/schema/board_v2", + validate: "/validate/board_v2", + examples: "/examples/board_v2", + }, + contributions: { pigEntityTypes: [], dataPipelineEntityTypes: [], actionTypes: [] }, + }, + }, + }; + + describe("successful commands", () => { + it("Should print the version and exit with code 0", async () => { + const result = await runCli(["-V"], [AssetRegistryModule]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("test-version"); + }); + + it("Should print asset types to stdout and exit with code 0", async () => { + mockAxiosGet(TYPES_URL, metadata); + + const result = await runCli(["asset-registry", "list"], [AssetRegistryModule]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("BOARD_V2 - View [DASHBOARDS]"); + }); + }); + + describe("failing commands produce a non-zero exit code", () => { + it("Should exit non-zero and report the friendly message when the feature flag is disabled", async () => { + mockAxiosGetError(TYPES_URL, 403, { error: ASSET_REGISTRY_DISABLED_ERROR }); + + const result = await runCli(["asset-registry", "list"], [AssetRegistryModule]); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain(ASSET_REGISTRY_DISABLED_USER_MESSAGE); + }); + + it("Should exit non-zero for an unknown command", async () => { + const result = await runCli(["this-command-does-not-exist"], [AssetRegistryModule]); + + expect(result.exitCode).toBe(1); + }); + + it("Should exit non-zero when a required option is missing", async () => { + const result = await runCli(["asset-registry", "get"], [AssetRegistryModule]); + + expect(result.exitCode).toBe(1); + }); + }); + + describe("action-wrapper error semantics", () => { + it("Should report a GracefulError without forcing a non-zero exit code", async () => { + const result = await runCli(["diag", "graceful"], [DiagnosticsModule]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain(GRACEFUL_MESSAGE); + }); + + it("Should exit non-zero for a regular error thrown by a command", async () => { + const result = await runCli(["diag", "fatal"], [DiagnosticsModule]); + + expect(result.exitCode).toBe(1); + }); + }); +}); diff --git a/tests/utls/cli-runner.ts b/tests/utls/cli-runner.ts new file mode 100644 index 00000000..106b0a84 --- /dev/null +++ b/tests/utls/cli-runner.ts @@ -0,0 +1,112 @@ +import { Command } from "commander"; +import { IModuleConstructor, ModuleHandler } from "../../src/core/command/module-handler"; +import { Context } from "../../src/core/command/cli-context"; +import { HttpClient } from "../../src/core/http/http-client"; + +export interface CliRunResult { + stdout: string; + stderr: string; + output: string; + exitCode: number; +} + +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; +const stripAnsi = (value: string): string => value.replace(ANSI_PATTERN, ""); + +class ExitSignal extends Error { + constructor(public readonly code: number) { + super(`process.exit(${code})`); + } +} + +function buildTestContext(): Context { + const context = new Context({}); + context.profile = { + name: "test", + type: "Key", + team: "https://myTeam.celonis.cloud/", + apiToken: "test-token", + authenticationType: "Bearer", + }; + context._httpClient = new HttpClient(context); + return context; +} + +function buildProgram(context: Context, modules: IModuleConstructor[]): Command { + const program = new Command(); + program.exitOverride(); + program.version("test-version"); + 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); + moduleHandler.configurator.command("list").description("Commands to list content.").alias("ls"); + + for (const ModuleClass of modules) { + new ModuleClass().register(context, moduleHandler.configurator); + } + + return program; +} + +export async function runCli(args: string[], modules: IModuleConstructor[]): Promise { + let stdout = ""; + let stderr = ""; + let exitCode = 0; + + const stdoutSpy = jest + .spyOn(process.stdout, "write") + .mockImplementation(((chunk: any): boolean => { + stdout += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as typeof process.stdout.write); + + const stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(((chunk: any): boolean => { + stderr += typeof chunk === "string" ? chunk : chunk.toString(); + return true; + }) as typeof process.stderr.write); + + const exitSpy = jest.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitSignal(code ?? 0); + }) as never); + + const previousExitCode = process.exitCode; + process.exitCode = 0; + + try { + const context = buildTestContext(); + const program = buildProgram(context, modules); + await program.parseAsync(["node", "content-cli", ...args]); + } catch (error) { + if (error instanceof ExitSignal) { + exitCode = error.code; + } else if (error && typeof (error as { code?: unknown }).code === "string" + && (error as { code: string }).code.startsWith("commander.")) { + exitCode = (error as { exitCode?: number }).exitCode ?? 0; + } else { + throw error; + } + } finally { + if (exitCode === 0 && process.exitCode && Number(process.exitCode) !== 0) { + exitCode = Number(process.exitCode); + } + process.exitCode = previousExitCode; + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + } + + const cleanStdout = stripAnsi(stdout); + const cleanStderr = stripAnsi(stderr); + return { + stdout: cleanStdout, + stderr: cleanStderr, + output: cleanStdout + cleanStderr, + exitCode, + }; +} From e82e2a3f6b4315aae257cbe1a0c41b530befd74f Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Fri, 19 Jun 2026 10:21:53 +0200 Subject: [PATCH 2/9] Use createProgram factory in cli-runner --- tests/integration/cli-process-output.spec.ts | 115 +++++++++++++++---- tests/utls/cli-runner.ts | 44 +------ 2 files changed, 98 insertions(+), 61 deletions(-) diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts index c683c3c4..48ce614f 100644 --- a/tests/integration/cli-process-output.spec.ts +++ b/tests/integration/cli-process-output.spec.ts @@ -5,10 +5,15 @@ import { ASSET_REGISTRY_DISABLED_ERROR, ASSET_REGISTRY_DISABLED_USER_MESSAGE, } from "../../src/commands/asset-registry/asset-registry-error"; -import { AssetRegistryMetadata } from "../../src/commands/asset-registry/asset-registry.interfaces"; +import { + AgentSkillsResponse, + AssetRegistryDescriptor, + AssetRegistryMetadata, +} from "../../src/commands/asset-registry/asset-registry.interfaces"; import { Configurator, IModule } from "../../src/core/command/module-handler"; import { Context } from "../../src/core/command/cli-context"; import { FatalError, GracefulError } from "../../src/core/utils/logger"; +import { VersionUtils } from "../../src/core/utils/version"; import AssetRegistryModule = require("../../src/commands/asset-registry/module"); @@ -33,36 +38,50 @@ class DiagnosticsModule extends IModule { } describe("CLI process output and exit codes", () => { - const TYPES_URL = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/types"; - - const metadata: AssetRegistryMetadata = { - types: { - BOARD_V2: { - assetType: "BOARD_V2", - displayName: "View", - description: null, - group: "DASHBOARDS", - assetSchema: { version: "2.1.0" }, - service: { basePath: "/blueprint/api" }, - endpoints: { - schema: "/schema/board_v2", - validate: "/validate/board_v2", - examples: "/examples/board_v2", - }, - contributions: { pigEntityTypes: [], dataPipelineEntityTypes: [], actionTypes: [] }, - }, + const BASE_URL = "https://myTeam.celonis.cloud"; + const TYPES_URL = `${BASE_URL}/pacman/api/core/asset-registry/types`; + const SKILLS_URL = `${BASE_URL}/pacman/api/core/asset-registry/skills`; + const TYPE_URL = `${BASE_URL}/pacman/api/core/asset-registry/types/BOARD_V2`; + const SCHEMA_URL = `${BASE_URL}/pacman/api/core/asset-registry/schemas/BOARD_V2`; + const EXAMPLES_URL = `${BASE_URL}/pacman/api/core/asset-registry/examples/BOARD_V2`; + + const descriptor: AssetRegistryDescriptor = { + assetType: "BOARD_V2", + displayName: "View", + description: null, + group: "DASHBOARDS", + assetSchema: { version: "2.1.0" }, + service: { basePath: "/blueprint/api" }, + endpoints: { + schema: "/schema/board_v2", + validate: "/validate/board_v2", + examples: "/examples/board_v2", }, + contributions: { pigEntityTypes: [], dataPipelineEntityTypes: [], actionTypes: [] }, + }; + + const metadata: AssetRegistryMetadata = { types: { BOARD_V2: descriptor } }; + + const skills: AgentSkillsResponse = { + skills: [ + { + name: "board-create", + description: "Create a new View asset", + path: "/blueprint/api/skills/board-create", + metadata: { version: "1.0.0" }, + }, + ], }; describe("successful commands", () => { - it("Should print the version and exit with code 0", async () => { + it("Should print the CLI version and exit with code 0", async () => { const result = await runCli(["-V"], [AssetRegistryModule]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("test-version"); + expect(result.stdout).toContain(VersionUtils.getCurrentCliVersion()); }); - it("Should print asset types to stdout and exit with code 0", async () => { + it("asset-registry list: prints types to output and exits 0", async () => { mockAxiosGet(TYPES_URL, metadata); const result = await runCli(["asset-registry", "list"], [AssetRegistryModule]); @@ -70,6 +89,58 @@ describe("CLI process output and exit codes", () => { expect(result.exitCode).toBe(0); expect(result.output).toContain("BOARD_V2 - View [DASHBOARDS]"); }); + + it("asset-registry get: prints full descriptor and exits 0", async () => { + mockAxiosGet(TYPE_URL, descriptor); + + const result = await runCli( + ["asset-registry", "get", "--assetType", "BOARD_V2"], + [AssetRegistryModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Asset Type: BOARD_V2"); + expect(result.output).toContain("Display Name: View"); + expect(result.output).toContain("Group: DASHBOARDS"); + }); + + it("asset-registry schema: prints schema JSON and exits 0", async () => { + const schema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object" }; + mockAxiosGet(SCHEMA_URL, schema); + + const result = await runCli( + ["asset-registry", "schema", "--assetType", "BOARD_V2"], + [AssetRegistryModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("json-schema.org"); + }); + + it("asset-registry examples: prints example JSON and exits 0", async () => { + const example = { name: "simple-view", configuration: { title: "My View" } }; + mockAxiosGet(EXAMPLES_URL, [example]); + + const result = await runCli( + ["asset-registry", "examples", "--assetType", "BOARD_V2"], + [AssetRegistryModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("simple-view"); + }); + + it("asset-registry skills list: prints skill names and exits 0", async () => { + mockAxiosGet(SKILLS_URL, skills); + + const result = await runCli( + ["asset-registry", "skills", "list"], + [AssetRegistryModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("board-create"); + }); }); describe("failing commands produce a non-zero exit code", () => { diff --git a/tests/utls/cli-runner.ts b/tests/utls/cli-runner.ts index 106b0a84..1a369790 100644 --- a/tests/utls/cli-runner.ts +++ b/tests/utls/cli-runner.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import { IModuleConstructor, ModuleHandler } from "../../src/core/command/module-handler"; -import { Context } from "../../src/core/command/cli-context"; -import { HttpClient } from "../../src/core/http/http-client"; +import { IModuleConstructor } from "../../src/core/command/module-handler"; +import { createProgram } from "../../src/content-cli"; +import { testContext } from "./test-context"; export interface CliRunResult { stdout: string; @@ -19,39 +18,6 @@ class ExitSignal extends Error { } } -function buildTestContext(): Context { - const context = new Context({}); - context.profile = { - name: "test", - type: "Key", - team: "https://myTeam.celonis.cloud/", - apiToken: "test-token", - authenticationType: "Bearer", - }; - context._httpClient = new HttpClient(context); - return context; -} - -function buildProgram(context: Context, modules: IModuleConstructor[]): Command { - const program = new Command(); - program.exitOverride(); - program.version("test-version"); - 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); - moduleHandler.configurator.command("list").description("Commands to list content.").alias("ls"); - - for (const ModuleClass of modules) { - new ModuleClass().register(context, moduleHandler.configurator); - } - - return program; -} - export async function runCli(args: string[], modules: IModuleConstructor[]): Promise { let stdout = ""; let stderr = ""; @@ -79,8 +45,8 @@ export async function runCli(args: string[], modules: IModuleConstructor[]): Pro process.exitCode = 0; try { - const context = buildTestContext(); - const program = buildProgram(context, modules); + const program = createProgram(testContext, { modules }); + program.exitOverride(); await program.parseAsync(["node", "content-cli", ...args]); } catch (error) { if (error instanceof ExitSignal) { From eea5cbc9542b67b5af1056e9021d075f459e5727 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Fri, 19 Jun 2026 17:57:05 +0200 Subject: [PATCH 3/9] Add stdout/exit-code integration coverage for config export and config import --- tests/integration/cli-process-output.spec.ts | 52 +++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts index 48ce614f..95480b21 100644 --- a/tests/integration/cli-process-output.spec.ts +++ b/tests/integration/cli-process-output.spec.ts @@ -1,6 +1,7 @@ import { Command, OptionValues } from "commander"; +import AdmZip = require("adm-zip"); import { runCli } from "../utls/cli-runner"; -import { mockAxiosGet, mockAxiosGetError } from "../utls/http-requests-mock"; +import { mockAxiosGet, mockAxiosGetError, mockAxiosPost } from "../utls/http-requests-mock"; import { ASSET_REGISTRY_DISABLED_ERROR, ASSET_REGISTRY_DISABLED_USER_MESSAGE, @@ -14,8 +15,12 @@ import { Configurator, IModule } from "../../src/core/command/module-handler"; import { Context } from "../../src/core/command/cli-context"; import { FatalError, GracefulError } from "../../src/core/utils/logger"; import { VersionUtils } from "../../src/core/utils/version"; +import { zipToTempFolder } from "../utls/fs-utils"; +import { T2tcPackageApi } from "../../src/commands/t2tc/api/t2tc-package-api"; +import { VariableApi } from "../../src/commands/configuration-management/api/variable-api"; import AssetRegistryModule = require("../../src/commands/asset-registry/module"); +import ConfigModule = require("../../src/commands/configuration-management/module"); const GRACEFUL_MESSAGE = "graceful failure - should not fail the process"; @@ -180,4 +185,49 @@ describe("CLI process output and exit codes", () => { expect(result.exitCode).toBe(1); }); }); + + describe("config export", () => { + it("downloads zip and exits 0", async () => { + const zip = new AdmZip(); + zip.addFile("manifest.json", Buffer.from(JSON.stringify([ + { packageKey: "my-package", flavor: "AUTOMATION", activeVersion: "1.0.0", dependenciesByVersion: {} }, + ]))); + jest.spyOn(T2tcPackageApi.prototype, "exportPackages").mockResolvedValue(zip.toBuffer()); + jest.spyOn(VariableApi.prototype, "findVariablesWithValuesByPackageKeysAndVersion").mockResolvedValue([]); + + const result = await runCli( + ["config", "export", "--packageKeys", "my-package"], + [ConfigModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("File downloaded successfully"); + }); + + it("exits non-zero when no package filter is provided", async () => { + const result = await runCli(["config", "export"], [ConfigModule]); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Please provide either --packageKeys or --keysByVersion"); + }); + }); + + describe("config import", () => { + const PACKAGES_URL = `${BASE_URL}/package-manager/api/packages`; + const IMPORT_BATCH_URL = `${BASE_URL}/package-manager/api/core/packages/import/batch`; + + it("imports zip and exits 0", async () => { + const zipFilePath = zipToTempFolder(new AdmZip()); + mockAxiosGet(PACKAGES_URL, []); + mockAxiosPost(IMPORT_BATCH_URL, []); + + const result = await runCli( + ["config", "import", "--file", zipFilePath], + [ConfigModule] + ); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Config import report file:"); + }); + }); }); From 9353fbf88baa1f56ed522161584afe8bf8538ec1 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Mon, 22 Jun 2026 13:45:45 +0200 Subject: [PATCH 4/9] move stdout/exit assertions into existing integration tests for asset-registry and config commands --- tests/integration/cli-process-output.spec.ts | 51 +------------------ .../commands/asset-registry.spec.ts | 23 +++++++++ .../commands/configuration-management.spec.ts | 41 +++++++++++++++ 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts index 95480b21..f22fd8e0 100644 --- a/tests/integration/cli-process-output.spec.ts +++ b/tests/integration/cli-process-output.spec.ts @@ -1,7 +1,6 @@ import { Command, OptionValues } from "commander"; -import AdmZip = require("adm-zip"); import { runCli } from "../utls/cli-runner"; -import { mockAxiosGet, mockAxiosGetError, mockAxiosPost } from "../utls/http-requests-mock"; +import { mockAxiosGet, mockAxiosGetError } from "../utls/http-requests-mock"; import { ASSET_REGISTRY_DISABLED_ERROR, ASSET_REGISTRY_DISABLED_USER_MESSAGE, @@ -15,12 +14,8 @@ import { Configurator, IModule } from "../../src/core/command/module-handler"; import { Context } from "../../src/core/command/cli-context"; import { FatalError, GracefulError } from "../../src/core/utils/logger"; import { VersionUtils } from "../../src/core/utils/version"; -import { zipToTempFolder } from "../utls/fs-utils"; -import { T2tcPackageApi } from "../../src/commands/t2tc/api/t2tc-package-api"; -import { VariableApi } from "../../src/commands/configuration-management/api/variable-api"; import AssetRegistryModule = require("../../src/commands/asset-registry/module"); -import ConfigModule = require("../../src/commands/configuration-management/module"); const GRACEFUL_MESSAGE = "graceful failure - should not fail the process"; @@ -186,48 +181,4 @@ describe("CLI process output and exit codes", () => { }); }); - describe("config export", () => { - it("downloads zip and exits 0", async () => { - const zip = new AdmZip(); - zip.addFile("manifest.json", Buffer.from(JSON.stringify([ - { packageKey: "my-package", flavor: "AUTOMATION", activeVersion: "1.0.0", dependenciesByVersion: {} }, - ]))); - jest.spyOn(T2tcPackageApi.prototype, "exportPackages").mockResolvedValue(zip.toBuffer()); - jest.spyOn(VariableApi.prototype, "findVariablesWithValuesByPackageKeysAndVersion").mockResolvedValue([]); - - const result = await runCli( - ["config", "export", "--packageKeys", "my-package"], - [ConfigModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("File downloaded successfully"); - }); - - it("exits non-zero when no package filter is provided", async () => { - const result = await runCli(["config", "export"], [ConfigModule]); - - expect(result.exitCode).toBe(1); - expect(result.output).toContain("Please provide either --packageKeys or --keysByVersion"); - }); - }); - - describe("config import", () => { - const PACKAGES_URL = `${BASE_URL}/package-manager/api/packages`; - const IMPORT_BATCH_URL = `${BASE_URL}/package-manager/api/core/packages/import/batch`; - - it("imports zip and exits 0", async () => { - const zipFilePath = zipToTempFolder(new AdmZip()); - mockAxiosGet(PACKAGES_URL, []); - mockAxiosPost(IMPORT_BATCH_URL, []); - - const result = await runCli( - ["config", "import", "--file", zipFilePath], - [ConfigModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("Config import report file:"); - }); - }); }); diff --git a/tests/integration/commands/asset-registry.spec.ts b/tests/integration/commands/asset-registry.spec.ts index 3829b358..7760a012 100644 --- a/tests/integration/commands/asset-registry.spec.ts +++ b/tests/integration/commands/asset-registry.spec.ts @@ -2,6 +2,7 @@ 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"; +import { runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/asset-registry/asset-registry.service"); @@ -39,6 +40,17 @@ describe("asset-registry command integration", () => { await runCli(["asset-registry", "schema", "--assetType", "BOARD_V2"]); expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", false); }); + + it("writes schema output to stdout and exits with code 0", async () => { + mockService.getSchema.mockImplementationOnce(async () => { + process.stdout.write("{\"type\":\"object\"}\n"); + }); + + const result = await runCliProcess(["asset-registry", "schema", "--assetType", "BOARD_V2"], [Module]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("\"type\":\"object\""); + }); }); describe("asset-registry validate", () => { @@ -104,6 +116,17 @@ describe("asset-registry command integration", () => { await runCli(["asset-registry", "examples", "--assetType", "BOARD_V2", "--json"]); expect(mockService.getExamples).toHaveBeenCalledWith("BOARD_V2", true); }); + + it("writes examples output to stdout and exits with code 0", async () => { + mockService.getExamples.mockImplementationOnce(async () => { + process.stdout.write("[{\"name\":\"simple-view\"}]\n"); + }); + + const result = await runCliProcess(["asset-registry", "examples", "--assetType", "BOARD_V2"], [Module]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("simple-view"); + }); }); describe("asset-registry list", () => { diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index c75b7f83..4abc4e8d 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -10,6 +10,7 @@ import { NodeDiffService } from "../../../src/commands/configuration-management/ import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; import { buildTestProgram } from "../../utls/cli-program"; +import { runCli as runCliProcess } from "../../utls/cli-runner"; import { loggingTestTransport } from "../../jest.setup"; jest.mock("../../../src/commands/configuration-management/config-command.service"); @@ -274,6 +275,17 @@ describe("configuration-management command integration", () => { }); describe("config export (deprecated batchExportPackages)", () => { + it("writes export output to stdout and exits with code 0", async () => { + mockT2tcCommandService.batchExportPackages.mockImplementationOnce(async () => { + process.stdout.write("File downloaded successfully. New filename: export_test.zip\n"); + }); + + const result = await runCliProcess(["config", "export", "--packageKeys", "package1"], [Module]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("File downloaded successfully. New filename: export_test.zip"); + }); + it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([ "config", "export", @@ -285,6 +297,13 @@ describe("configuration-management command integration", () => { expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); }); + it("logs validation error to stdout and exits non-zero when package filters are missing", async () => { + const result = await runCliProcess(["config", "export"], [Module]); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Please provide either --packageKeys or --keysByVersion, but not both."); + }); + it("rejects when neither --packageKeys nor --keysByVersion are provided", async () => { await runCli(["config", "export"]); @@ -390,6 +409,17 @@ describe("configuration-management command integration", () => { }); describe("config import (deprecated batchImportPackages)", () => { + it("writes import output to stdout and exits with code 0", async () => { + mockT2tcCommandService.batchImportPackages.mockImplementationOnce(async () => { + process.stdout.write("Config import report file: config_import_report_test.json\n"); + }); + + const result = await runCliProcess(["config", "import", "--file", "export.zip"], [Module]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Config import report file: config_import_report_test.json"); + }); + it("rejects when --gitProfile is provided without --gitBranch", async () => { await runCli([ "config", "import", @@ -486,6 +516,17 @@ describe("configuration-management command integration", () => { false ); }); + + it("logs validation error to stdout and exits non-zero when --gitProfile has no --gitBranch", async () => { + const result = await runCliProcess([ + "config", "import", + "--file", "export.zip", + "--gitProfile", "myProfile", + ], [Module]); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Please specify a branch using --gitBranch when using a Git profile."); + }); }); describe("config package import (importSinglePackage)", () => { From d1a99e77f2c1b4957b8218783c73498cf30927e5 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Tue, 23 Jun 2026 11:45:09 +0200 Subject: [PATCH 5/9] align integration suites with runCliProcess and remove synthetic diagnostics tests --- tests/integration/cli-process-output.spec.ts | 40 +------------------ .../commands/asset-registry.spec.ts | 9 +---- .../commands/configuration-management.spec.ts | 9 +---- 3 files changed, 5 insertions(+), 53 deletions(-) diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts index f22fd8e0..8865233b 100644 --- a/tests/integration/cli-process-output.spec.ts +++ b/tests/integration/cli-process-output.spec.ts @@ -1,4 +1,3 @@ -import { Command, OptionValues } from "commander"; import { runCli } from "../utls/cli-runner"; import { mockAxiosGet, mockAxiosGetError } from "../utls/http-requests-mock"; import { @@ -10,33 +9,11 @@ import { AssetRegistryDescriptor, AssetRegistryMetadata, } from "../../src/commands/asset-registry/asset-registry.interfaces"; -import { Configurator, IModule } from "../../src/core/command/module-handler"; -import { Context } from "../../src/core/command/cli-context"; -import { FatalError, GracefulError } from "../../src/core/utils/logger"; +import { FatalError } from "../../src/core/utils/logger"; import { VersionUtils } from "../../src/core/utils/version"; import AssetRegistryModule = require("../../src/commands/asset-registry/module"); -const GRACEFUL_MESSAGE = "graceful failure - should not fail the process"; - -class DiagnosticsModule extends IModule { - public register(context: Context, configurator: Configurator): void { - const diag = configurator.command("diag").description("Diagnostics test commands"); - - diag.command("graceful") - .description("Throws a GracefulError") - .action(async (_ctx: Context, _cmd: Command, _opts: OptionValues): Promise => { - throw new GracefulError(GRACEFUL_MESSAGE); - }); - - diag.command("fatal") - .description("Throws a FatalError") - .action(async (_ctx: Context, _cmd: Command, _opts: OptionValues): Promise => { - throw new FatalError("boom"); - }); - } -} - describe("CLI process output and exit codes", () => { const BASE_URL = "https://myTeam.celonis.cloud"; const TYPES_URL = `${BASE_URL}/pacman/api/core/asset-registry/types`; @@ -166,19 +143,4 @@ describe("CLI process output and exit codes", () => { }); }); - describe("action-wrapper error semantics", () => { - it("Should report a GracefulError without forcing a non-zero exit code", async () => { - const result = await runCli(["diag", "graceful"], [DiagnosticsModule]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain(GRACEFUL_MESSAGE); - }); - - it("Should exit non-zero for a regular error thrown by a command", async () => { - const result = await runCli(["diag", "fatal"], [DiagnosticsModule]); - - expect(result.exitCode).toBe(1); - }); - }); - }); diff --git a/tests/integration/commands/asset-registry.spec.ts b/tests/integration/commands/asset-registry.spec.ts index 7760a012..934f687f 100644 --- a/tests/integration/commands/asset-registry.spec.ts +++ b/tests/integration/commands/asset-registry.spec.ts @@ -1,13 +1,10 @@ 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"; import { runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/asset-registry/asset-registry.service"); describe("asset-registry command integration", () => { - let program: Command; let mockService: jest.Mocked; beforeEach(() => { @@ -22,12 +19,10 @@ describe("asset-registry command integration", () => { (AssetRegistryService as jest.MockedClass) .mockImplementation(() => mockService); - - program = buildTestProgram([Module]); }); - function runCli(args: string[]): Promise { - return program.parseAsync(["node", "content-cli", ...args]); + async function runCli(args: string[]): Promise { + await runCliProcess(args, [Module]); } describe("asset-registry schema", () => { diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index 4abc4e8d..bcb4fd19 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -1,5 +1,4 @@ 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"; @@ -9,7 +8,6 @@ import { PackageVersionCommandService } from "../../../src/commands/configuratio import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; -import { buildTestProgram } from "../../utls/cli-program"; import { runCli as runCliProcess } from "../../utls/cli-runner"; import { loggingTestTransport } from "../../jest.setup"; @@ -24,7 +22,6 @@ jest.mock("../../../src/commands/configuration-management/single-package-import. jest.mock("../../../src/commands/configuration-management/single-package-export.service"); describe("configuration-management command integration", () => { - let program: Command; let mockConfigCommandService: jest.Mocked; let mockStagingPackageService: jest.Mocked; let mockMetadataService: jest.Mocked; @@ -86,12 +83,10 @@ describe("configuration-management command integration", () => { (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); - - program = buildTestProgram([Module]); }); - function runCli(args: string[]): Promise { - return program.parseAsync(["node", "content-cli", ...args]); + async function runCli(args: string[]): Promise { + await runCliProcess(args, [Module]); } /** From 27cc211736da37c9ec02fcacf913b8e24127efde Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Tue, 23 Jun 2026 14:41:47 +0200 Subject: [PATCH 6/9] add coverage for module-handler errors and PackageManager option validation --- tests/commands/studio/package.manager.spec.ts | 34 ++++++++++ tests/core/command/module-handler.spec.ts | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/commands/studio/package.manager.spec.ts create mode 100644 tests/core/command/module-handler.spec.ts diff --git a/tests/commands/studio/package.manager.spec.ts b/tests/commands/studio/package.manager.spec.ts new file mode 100644 index 00000000..2c46acdf --- /dev/null +++ b/tests/commands/studio/package.manager.spec.ts @@ -0,0 +1,34 @@ +import { PackageManager } from "../../../src/commands/studio/manager/package.manager"; +import { loggingTestTransport } from "../../jest.setup"; +import { testContext } from "../../utls/test-context"; + +describe("PackageManager", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("exits with code 1 when overwrite and new key are used together", () => { + const exitSignal = new Error("process.exit(1)"); + const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => { + throw exitSignal; + }) as never); + loggingTestTransport.logMessages = []; + + const manager = new PackageManager(testContext); + manager.spaceKey = "space-id"; + manager.key = "my-package"; + manager.newKey = "renamed-package"; + manager.overwrite = true; + manager.store = false; + manager.draft = false; + + expect(() => manager.getConfig()).toThrow(exitSignal); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect( + loggingTestTransport.logMessages.some(entry => + String(entry.message).includes("You cannot overwrite a package and set a new key at the same time") + ) + ).toBe(true); + }); +}); diff --git a/tests/core/command/module-handler.spec.ts b/tests/core/command/module-handler.spec.ts new file mode 100644 index 00000000..956851d9 --- /dev/null +++ b/tests/core/command/module-handler.spec.ts @@ -0,0 +1,62 @@ +import { Command } from "commander"; +import { Configurator } from "../../../src/core/command/module-handler"; +import { GracefulError } from "../../../src/core/utils/logger"; +import { loggingTestTransport } from "../../jest.setup"; +import { testContext } from "../../utls/test-context"; + +describe("CommandConfig action error handling", () => { + let previousExitCode: number | undefined; + + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = 0; + loggingTestTransport.logMessages = []; + }); + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + async function runCommand(handler: () => Promise): Promise { + const program = new Command(); + const configurator = new Configurator(program, testContext); + + configurator.command("test-command").action(async () => { + await handler(); + }); + + program.exitOverride(); + await program.parseAsync(["node", "content-cli", "test-command"]); + } + + it("logs a graceful error and keeps exitCode at zero", async () => { + await runCommand(async () => { + throw new GracefulError("graceful failure"); + }); + + expect(process.exitCode ?? 0).toBe(0); + expect( + loggingTestTransport.logMessages.some(entry => + String(entry.message).includes("graceful failure") + ) + ).toBe(true); + expect( + loggingTestTransport.logMessages.some(entry => + String(entry.message).includes("An unexpected error occured executing a command") + ) + ).toBe(false); + }); + + it("logs unexpected errors and marks the process as failed", async () => { + await runCommand(async () => { + throw new Error("boom"); + }); + + expect(process.exitCode).toBe(1); + expect( + loggingTestTransport.logMessages.some(entry => + String(entry.message).includes("An unexpected error occured executing a command") + ) + ).toBe(true); + }); +}); From f7beccf4b650f3e31783b6895603edd23b9afa04 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Tue, 23 Jun 2026 16:18:16 +0200 Subject: [PATCH 7/9] assert on real CLI output and exit codes in integration tests --- tests/integration/cli-process-output.spec.ts | 146 ------------------ .../commands/asset-registry.spec.ts | 59 ++++--- .../commands/configuration-management.spec.ts | 113 +++++--------- tests/utls/cli-program.ts | 8 - 4 files changed, 70 insertions(+), 256 deletions(-) delete mode 100644 tests/integration/cli-process-output.spec.ts delete mode 100644 tests/utls/cli-program.ts diff --git a/tests/integration/cli-process-output.spec.ts b/tests/integration/cli-process-output.spec.ts deleted file mode 100644 index 8865233b..00000000 --- a/tests/integration/cli-process-output.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { runCli } from "../utls/cli-runner"; -import { mockAxiosGet, mockAxiosGetError } from "../utls/http-requests-mock"; -import { - ASSET_REGISTRY_DISABLED_ERROR, - ASSET_REGISTRY_DISABLED_USER_MESSAGE, -} from "../../src/commands/asset-registry/asset-registry-error"; -import { - AgentSkillsResponse, - AssetRegistryDescriptor, - AssetRegistryMetadata, -} from "../../src/commands/asset-registry/asset-registry.interfaces"; -import { FatalError } from "../../src/core/utils/logger"; -import { VersionUtils } from "../../src/core/utils/version"; - -import AssetRegistryModule = require("../../src/commands/asset-registry/module"); - -describe("CLI process output and exit codes", () => { - const BASE_URL = "https://myTeam.celonis.cloud"; - const TYPES_URL = `${BASE_URL}/pacman/api/core/asset-registry/types`; - const SKILLS_URL = `${BASE_URL}/pacman/api/core/asset-registry/skills`; - const TYPE_URL = `${BASE_URL}/pacman/api/core/asset-registry/types/BOARD_V2`; - const SCHEMA_URL = `${BASE_URL}/pacman/api/core/asset-registry/schemas/BOARD_V2`; - const EXAMPLES_URL = `${BASE_URL}/pacman/api/core/asset-registry/examples/BOARD_V2`; - - const descriptor: AssetRegistryDescriptor = { - assetType: "BOARD_V2", - displayName: "View", - description: null, - group: "DASHBOARDS", - assetSchema: { version: "2.1.0" }, - service: { basePath: "/blueprint/api" }, - endpoints: { - schema: "/schema/board_v2", - validate: "/validate/board_v2", - examples: "/examples/board_v2", - }, - contributions: { pigEntityTypes: [], dataPipelineEntityTypes: [], actionTypes: [] }, - }; - - const metadata: AssetRegistryMetadata = { types: { BOARD_V2: descriptor } }; - - const skills: AgentSkillsResponse = { - skills: [ - { - name: "board-create", - description: "Create a new View asset", - path: "/blueprint/api/skills/board-create", - metadata: { version: "1.0.0" }, - }, - ], - }; - - describe("successful commands", () => { - it("Should print the CLI version and exit with code 0", async () => { - const result = await runCli(["-V"], [AssetRegistryModule]); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(VersionUtils.getCurrentCliVersion()); - }); - - it("asset-registry list: prints types to output and exits 0", async () => { - mockAxiosGet(TYPES_URL, metadata); - - const result = await runCli(["asset-registry", "list"], [AssetRegistryModule]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("BOARD_V2 - View [DASHBOARDS]"); - }); - - it("asset-registry get: prints full descriptor and exits 0", async () => { - mockAxiosGet(TYPE_URL, descriptor); - - const result = await runCli( - ["asset-registry", "get", "--assetType", "BOARD_V2"], - [AssetRegistryModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("Asset Type: BOARD_V2"); - expect(result.output).toContain("Display Name: View"); - expect(result.output).toContain("Group: DASHBOARDS"); - }); - - it("asset-registry schema: prints schema JSON and exits 0", async () => { - const schema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object" }; - mockAxiosGet(SCHEMA_URL, schema); - - const result = await runCli( - ["asset-registry", "schema", "--assetType", "BOARD_V2"], - [AssetRegistryModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("json-schema.org"); - }); - - it("asset-registry examples: prints example JSON and exits 0", async () => { - const example = { name: "simple-view", configuration: { title: "My View" } }; - mockAxiosGet(EXAMPLES_URL, [example]); - - const result = await runCli( - ["asset-registry", "examples", "--assetType", "BOARD_V2"], - [AssetRegistryModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("simple-view"); - }); - - it("asset-registry skills list: prints skill names and exits 0", async () => { - mockAxiosGet(SKILLS_URL, skills); - - const result = await runCli( - ["asset-registry", "skills", "list"], - [AssetRegistryModule] - ); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("board-create"); - }); - }); - - describe("failing commands produce a non-zero exit code", () => { - it("Should exit non-zero and report the friendly message when the feature flag is disabled", async () => { - mockAxiosGetError(TYPES_URL, 403, { error: ASSET_REGISTRY_DISABLED_ERROR }); - - const result = await runCli(["asset-registry", "list"], [AssetRegistryModule]); - - expect(result.exitCode).toBe(1); - expect(result.output).toContain(ASSET_REGISTRY_DISABLED_USER_MESSAGE); - }); - - it("Should exit non-zero for an unknown command", async () => { - const result = await runCli(["this-command-does-not-exist"], [AssetRegistryModule]); - - expect(result.exitCode).toBe(1); - }); - - it("Should exit non-zero when a required option is missing", async () => { - const result = await runCli(["asset-registry", "get"], [AssetRegistryModule]); - - expect(result.exitCode).toBe(1); - }); - }); - -}); diff --git a/tests/integration/commands/asset-registry.spec.ts b/tests/integration/commands/asset-registry.spec.ts index 934f687f..59c49671 100644 --- a/tests/integration/commands/asset-registry.spec.ts +++ b/tests/integration/commands/asset-registry.spec.ts @@ -1,6 +1,6 @@ import Module = require("../../../src/commands/asset-registry/module"); import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; -import { runCli as runCliProcess } from "../../utls/cli-runner"; +import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/asset-registry/asset-registry.service"); @@ -21,8 +21,8 @@ describe("asset-registry command integration", () => { .mockImplementation(() => mockService); }); - async function runCli(args: string[]): Promise { - await runCliProcess(args, [Module]); + async function runCli(args: string[]): Promise { + return runCliProcess(args, [Module]); } describe("asset-registry schema", () => { @@ -35,17 +35,6 @@ describe("asset-registry command integration", () => { await runCli(["asset-registry", "schema", "--assetType", "BOARD_V2"]); expect(mockService.getSchema).toHaveBeenCalledWith("BOARD_V2", false); }); - - it("writes schema output to stdout and exits with code 0", async () => { - mockService.getSchema.mockImplementationOnce(async () => { - process.stdout.write("{\"type\":\"object\"}\n"); - }); - - const result = await runCliProcess(["asset-registry", "schema", "--assetType", "BOARD_V2"], [Module]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("\"type\":\"object\""); - }); }); describe("asset-registry validate", () => { @@ -111,17 +100,6 @@ describe("asset-registry command integration", () => { await runCli(["asset-registry", "examples", "--assetType", "BOARD_V2", "--json"]); expect(mockService.getExamples).toHaveBeenCalledWith("BOARD_V2", true); }); - - it("writes examples output to stdout and exits with code 0", async () => { - mockService.getExamples.mockImplementationOnce(async () => { - process.stdout.write("[{\"name\":\"simple-view\"}]\n"); - }); - - const result = await runCliProcess(["asset-registry", "examples", "--assetType", "BOARD_V2"], [Module]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("simple-view"); - }); }); describe("asset-registry list", () => { @@ -159,4 +137,35 @@ describe("asset-registry command integration", () => { expect(mockService.getType).toHaveBeenCalledWith("BOARD_V2", true); }); }); + + describe("exit codes", () => { + it("exits with code 0 on a successful command", async () => { + const result = await runCli(["asset-registry", "list"]); + + expect(result.exitCode).toBe(0); + }); + + it("exits non-zero and reports the error when the service fails", async () => { + mockService.listTypes.mockRejectedValueOnce(new Error("Asset registry feature is disabled")); + + const result = await runCli(["asset-registry", "list"]); + + expect(result.exitCode).toBe(1); + expect(result.output).toContain("Asset registry feature is disabled"); + }); + + it("exits non-zero for an unknown command", async () => { + const result = await runCli(["this-command-does-not-exist"]); + + expect(result.exitCode).not.toBe(0); + expect(mockService.listTypes).not.toHaveBeenCalled(); + }); + + it("exits non-zero when a required option is missing", async () => { + const result = await runCli(["asset-registry", "get"]); + + expect(result.exitCode).not.toBe(0); + expect(mockService.getType).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index cff008a5..7864e492 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -8,8 +8,7 @@ import { PackageVersionCommandService } from "../../../src/commands/configuratio import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; -import { runCli as runCliProcess } from "../../utls/cli-runner"; -import { loggingTestTransport } from "../../jest.setup"; +import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/configuration-management/config-command.service"); jest.mock("../../../src/commands/configuration-management/staging-package.service"); @@ -85,24 +84,23 @@ describe("configuration-management command integration", () => { (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); }); - async function runCli(args: string[]): Promise { - await runCliProcess(args, [Module]); + let lastResult: CliRunResult; + + async function runCli(args: string[]): Promise { + lastResult = await runCliProcess(args, [Module]); + return lastResult; } /** * 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. + * Configurator.action, surfaced on the real process output and reflected + * in a non-zero exit code. We assert on the actual captured output and + * exit code of the last run rather than inspecting an injected logger + * transport. */ - function expectErrorLogged(message: string): void { - expect(loggingTestTransport.logMessages).toEqual(expect.arrayContaining([ - expect.objectContaining({ - level: expect.stringContaining("error"), - message: expect.stringContaining(message), - }), - ])); + function expectError(message: string): void { + expect(lastResult.exitCode).toBe(1); + expect(lastResult.output).toContain(message); } describe("config list (deprecated listPackages)", () => { @@ -113,17 +111,18 @@ describe("configuration-management command integration", () => { "--keysByVersion", "package3.1.0.0", "package4.1.0.0", ]); - expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expectError("Please provide either --packageKeys or --keysByVersion, but not both."); expect(mockT2tcCommandService.listPackages).not.toHaveBeenCalled(); }); it("forwards only --packageKeys when provided", async () => { - await runCli([ + const result = await runCli([ "config", "list", "--packageKeys", "package1", "package2", "--json", ]); + expect(result.exitCode).toBe(0); expect(mockT2tcCommandService.listPackages).toHaveBeenCalledWith( true, undefined, @@ -197,7 +196,7 @@ describe("configuration-management command integration", () => { "--packageKeys", "package1", "package2", ]); - expectErrorLogged( + expectError( "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" ); }); @@ -205,7 +204,7 @@ describe("configuration-management command integration", () => { it("rejects --staging combined with --withDependencies", async () => { await runCli(["config", "list", "--staging", "--withDependencies"]); - expectErrorLogged( + expectError( "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" ); }); @@ -217,7 +216,7 @@ describe("configuration-management command integration", () => { "--keysByVersion", "package3.1.0.0", "package4.1.0.0", ]); - expectErrorLogged( + expectError( "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" ); }); @@ -225,7 +224,7 @@ describe("configuration-management command integration", () => { it("rejects --staging combined with --variableValue", async () => { await runCli(["config", "list", "--staging", "--variableValue", "myValue"]); - expectErrorLogged( + expectError( "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" ); }); @@ -233,7 +232,7 @@ describe("configuration-management command integration", () => { it("rejects --staging combined with --variableType", async () => { await runCli(["config", "list", "--staging", "--variableType", "myType"]); - expectErrorLogged( + expectError( "Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType" ); }); @@ -270,17 +269,6 @@ describe("configuration-management command integration", () => { }); describe("config export (deprecated batchExportPackages)", () => { - it("writes export output to stdout and exits with code 0", async () => { - mockT2tcCommandService.batchExportPackages.mockImplementationOnce(async () => { - process.stdout.write("File downloaded successfully. New filename: export_test.zip\n"); - }); - - const result = await runCliProcess(["config", "export", "--packageKeys", "package1"], [Module]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("File downloaded successfully. New filename: export_test.zip"); - }); - it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([ "config", "export", @@ -288,21 +276,14 @@ describe("configuration-management command integration", () => { "--keysByVersion", "package3:v1", "package4:v2", ]); - expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expectError("Please provide either --packageKeys or --keysByVersion, but not both."); expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); }); - it("logs validation error to stdout and exits non-zero when package filters are missing", async () => { - const result = await runCliProcess(["config", "export"], [Module]); - - expect(result.exitCode).toBe(1); - expect(result.output).toContain("Please provide either --packageKeys or --keysByVersion, but not both."); - }); - it("rejects when neither --packageKeys nor --keysByVersion are provided", async () => { await runCli(["config", "export"]); - expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expectError("Please provide either --packageKeys or --keysByVersion, but not both."); expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); }); @@ -337,7 +318,7 @@ describe("configuration-management command integration", () => { "--gitProfile", "myProfile", ]); - expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expectError("Please specify a branch using --gitBranch when using a Git profile."); expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); }); @@ -398,23 +379,12 @@ describe("configuration-management command integration", () => { "--gitProfile", "myProfile", ]); - expectErrorLogged("Please provide either --packageKeys or --keysByVersion, but not both."); + expectError("Please provide either --packageKeys or --keysByVersion, but not both."); expect(mockT2tcCommandService.batchExportPackages).not.toHaveBeenCalled(); }); }); describe("config import (deprecated batchImportPackages)", () => { - it("writes import output to stdout and exits with code 0", async () => { - mockT2tcCommandService.batchImportPackages.mockImplementationOnce(async () => { - process.stdout.write("Config import report file: config_import_report_test.json\n"); - }); - - const result = await runCliProcess(["config", "import", "--file", "export.zip"], [Module]); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain("Config import report file: config_import_report_test.json"); - }); - it("rejects when --gitProfile is provided without --gitBranch", async () => { await runCli([ "config", "import", @@ -422,7 +392,7 @@ describe("configuration-management command integration", () => { "--gitProfile", "myProfile", ]); - expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expectError("Please specify a branch using --gitBranch when using a Git profile."); expect(mockT2tcCommandService.batchImportPackages).not.toHaveBeenCalled(); }); @@ -511,17 +481,6 @@ describe("configuration-management command integration", () => { false ); }); - - it("logs validation error to stdout and exits non-zero when --gitProfile has no --gitBranch", async () => { - const result = await runCliProcess([ - "config", "import", - "--file", "export.zip", - "--gitProfile", "myProfile", - ], [Module]); - - expect(result.exitCode).toBe(1); - expect(result.output).toContain("Please specify a branch using --gitBranch when using a Git profile."); - }); }); describe("config package import (importSinglePackage)", () => { @@ -589,7 +548,7 @@ describe("configuration-management command integration", () => { "--gitProfile", "myProfile", ]); - expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expectError("Please specify a branch using --gitBranch when using a Git profile."); expect(mockSinglePackageImportService.importPackage).not.toHaveBeenCalled(); }); }); @@ -637,7 +596,7 @@ describe("configuration-management command integration", () => { "--gitProfile", "myProfile", ]); - expectErrorLogged("Please specify a branch using --gitBranch when using a Git profile."); + expectError("Please specify a branch using --gitBranch when using a Git profile."); expect(mockSinglePackageExportService.exportPackage).not.toHaveBeenCalled(); }); }); @@ -650,7 +609,7 @@ describe("configuration-management command integration", () => { "--keysByVersion", "key-1:1.0.0", ]); - expectErrorLogged( + expectError( "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." ); expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); @@ -663,7 +622,7 @@ describe("configuration-management command integration", () => { "--keysByVersionFile", "mapping.json", ]); - expectErrorLogged( + expectError( "Please provide either --packageKeys or --keysByVersion/--keysByVersionFile, but not both." ); expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); @@ -672,7 +631,7 @@ describe("configuration-management command integration", () => { it("rejects when neither staging nor versioned inputs are provided", async () => { await runCli(["config", "variables", "list"]); - expectErrorLogged( + expectError( "Please provide --packageKeys for staging, or --keysByVersion / --keysByVersionFile for versioned packages." ); expect(mockConfigCommandService.listVariables).not.toHaveBeenCalled(); @@ -717,7 +676,7 @@ describe("configuration-management command integration", () => { "--versionBumpOption", "PATCH", ]); - expectErrorLogged("Please provide either --packageVersion or --versionBumpOption, but not both."); + expectError("Please provide either --packageVersion or --versionBumpOption, but not both."); expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); }); @@ -728,7 +687,7 @@ describe("configuration-management command integration", () => { "--versionBumpOption", "NONE", ]); - expectErrorLogged("Please provide either --packageVersion or --versionBumpOption PATCH."); + expectError("Please provide either --packageVersion or --versionBumpOption PATCH."); expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); }); @@ -738,7 +697,7 @@ describe("configuration-management command integration", () => { "--packageKey", "my-package", ]); - expectErrorLogged("Please provide either --packageVersion or --versionBumpOption PATCH."); + expectError("Please provide either --packageVersion or --versionBumpOption PATCH."); expect(mockPackageVersionCommandService.createPackageVersion).not.toHaveBeenCalled(); }); @@ -895,7 +854,7 @@ describe("configuration-management command integration", () => { "--file", "./node.json", ]); - expectErrorLogged("Please provide either --compareVersion or --file, but not both."); + expectError("Please provide either --compareVersion or --file, but not both."); expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); }); @@ -908,7 +867,7 @@ describe("configuration-management command integration", () => { "--baseVersion", "STAGING", ]); - expectErrorLogged("Please provide either --compareVersion or --file, but not both."); + expectError("Please provide either --compareVersion or --file, but not both."); expect(mockNodeDiffService.diff).not.toHaveBeenCalled(); expect(mockNodeDiffService.diffWithFile).not.toHaveBeenCalled(); }); diff --git a/tests/utls/cli-program.ts b/tests/utls/cli-program.ts deleted file mode 100644 index 825fc0a5..00000000 --- a/tests/utls/cli-program.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 }); -} From cd747e30a92a2a79f66ab3faa6ef195112ded8c9 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Tue, 23 Jun 2026 17:02:29 +0200 Subject: [PATCH 8/9] reuse buildTestProgram in runCli --- tests/utls/cli-program.ts | 8 ++++++++ tests/utls/cli-runner.ts | 5 ++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 tests/utls/cli-program.ts diff --git a/tests/utls/cli-program.ts b/tests/utls/cli-program.ts new file mode 100644 index 00000000..825fc0a5 --- /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 }); +} diff --git a/tests/utls/cli-runner.ts b/tests/utls/cli-runner.ts index 1a369790..241d00f9 100644 --- a/tests/utls/cli-runner.ts +++ b/tests/utls/cli-runner.ts @@ -1,6 +1,5 @@ import { IModuleConstructor } from "../../src/core/command/module-handler"; -import { createProgram } from "../../src/content-cli"; -import { testContext } from "./test-context"; +import { buildTestProgram } from "./cli-program"; export interface CliRunResult { stdout: string; @@ -45,7 +44,7 @@ export async function runCli(args: string[], modules: IModuleConstructor[]): Pro process.exitCode = 0; try { - const program = createProgram(testContext, { modules }); + const program = buildTestProgram(modules); program.exitOverride(); await program.parseAsync(["node", "content-cli", ...args]); } catch (error) { From b910515f00c9d5fe2d3d4624d1881a6c849b2df3 Mon Sep 17 00:00:00 2001 From: "a.jashari" Date: Tue, 23 Jun 2026 21:42:23 +0200 Subject: [PATCH 9/9] cover GracefulError exit path via integration test --- tests/integration/commands/asset-registry.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/integration/commands/asset-registry.spec.ts b/tests/integration/commands/asset-registry.spec.ts index 59c49671..0ab40c5e 100644 --- a/tests/integration/commands/asset-registry.spec.ts +++ b/tests/integration/commands/asset-registry.spec.ts @@ -1,6 +1,7 @@ import Module = require("../../../src/commands/asset-registry/module"); import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service"; import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; +import { GracefulError } from "../../../src/core/utils/logger"; jest.mock("../../../src/commands/asset-registry/asset-registry.service"); @@ -154,6 +155,15 @@ describe("asset-registry command integration", () => { expect(result.output).toContain("Asset registry feature is disabled"); }); + it("exits with code 0 when the service raises a GracefulError", async () => { + mockService.listTypes.mockRejectedValueOnce(new GracefulError("Nothing to list")); + + const result = await runCli(["asset-registry", "list"]); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Nothing to list"); + }); + it("exits non-zero for an unknown command", async () => { const result = await runCli(["this-command-does-not-exist"]);