Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/studio/manager/package.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/core/command/module-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
});
}
Expand Down
112 changes: 112 additions & 0 deletions tests/integration/cli-process-output.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
throw new GracefulError(GRACEFUL_MESSAGE);
});

diag.command("fatal")
.description("Throws a FatalError")
.action(async (_ctx: Context, _cmd: Command, _opts: OptionValues): Promise<void> => {
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);
});
});
});
112 changes: 112 additions & 0 deletions tests/utls/cli-runner.ts
Original file line number Diff line number Diff line change
@@ -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<CliRunResult> {
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,
};
}
Loading