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
123 changes: 123 additions & 0 deletions e2e-tests/intent-based-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { exec as execCallback } from 'node:child_process';
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';

import { log, logSection } from './support/plugin-export-build';

const exec = promisify(execCallback);

const TEST_TIMEOUT = 60 * 1000;
const rhdhCli = path.resolve(__dirname, '../bin/rhdh-cli');

/** Env var that enables live RHDH catalog checks. Auth must already be configured. */
const RHDH_CLI_E2E_URL = process.env.RHDH_CLI_E2E_URL;

async function runCli(
args: string,
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
try {
const { stdout, stderr } = await exec(`"${rhdhCli}" ${args}`, {
shell: true,
maxBuffer: 10 * 1024 * 1024,
cwd: options.cwd,
env: { ...process.env, ...options.env },
});
return { stdout, stderr, code: 0 };
} catch (err: unknown) {
const e = err as {
code?: string | number;
stdout?: string;
stderr?: string;
};
const code =
typeof e.code === 'number' ? e.code : Number.parseInt(String(e.code), 10);
return {
stdout: e.stdout ?? '',
stderr: e.stderr ?? '',
code: Number.isFinite(code) ? code : 1,
};
}
}

describe('intent-based CLI help and error contracts', () => {
jest.setTimeout(TEST_TIMEOUT);

it('exposes intent commands and subcommand help offline', async () => {
logSection('rhdh-cli --help / subcommand --help');

const root = await runCli('--help');
expect(root.code).toBe(0);
for (const command of ['catalog', 'api', 'search', 'docs', 'template']) {
expect(root.stdout).toContain(command);
}

const catalog = await runCli('catalog --help');
expect(catalog.code).toBe(0);
for (const sub of ['list', 'get', 'validate', 'register', 'unregister']) {
expect(catalog.stdout).toContain(sub);
}

const search = await runCli('search --help');
expect(search.code).toBe(0);
expect(search.stdout).toMatch(/--types/);
expect(search.stdout).toMatch(/--filter/);

const template = await runCli('template --help');
expect(template.code).toBe(0);
expect(template.stdout).toContain('execute');
expect(template.stdout).toContain('dry-run');
});

it('exits non-zero with Error for invalid usage', async () => {
const missingFlag = await runCli('catalog register');
expect(missingFlag.code).not.toBe(0);
expect(missingFlag.stderr).toMatch(/Error:/);
expect(missingFlag.stderr).toMatch(/location-url/i);

const unknown = await runCli('not-a-real-command');
expect(unknown.code).not.toBe(0);
expect(`${unknown.stderr}${unknown.stdout}`).toMatch(
/error|unknown|invalid command/i,
);
});

it('help works from an empty temp dir with no package.json', async () => {
const emptyDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'rhdh-cli-intent-empty-'),
);
try {
const { stdout, code } = await runCli('--help', { cwd: emptyDir });
expect(code).toBe(0);
expect(stdout).toContain('catalog');
} finally {
await fs.remove(emptyDir);
}
});
});

const describeLive = RHDH_CLI_E2E_URL ? describe : describe.skip;

describeLive('intent-based CLI live RHDH (optional)', () => {
jest.setTimeout(TEST_TIMEOUT);

it('catalog list --output json --kind Component returns parseable JSON', async () => {
logSection(
`Live catalog list against RHDH_CLI_E2E_URL=${RHDH_CLI_E2E_URL}`,
);
log(
'Requires auth already configured (rhdh-cli auth login). Skipped in CI when unset.',
);

const result = await runCli('catalog list --output json --kind Component');

expect(result.code).toBe(0);
expect(() => JSON.parse(result.stdout)).not.toThrow();
});
});
75 changes: 43 additions & 32 deletions src/commands/intent-based-actions/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,58 @@
import { Command } from 'commander';
import { registerCatalogCommands } from './catalog';
import { runEntityListAction } from './helpers';
import { handleCommandError } from './intent-errors';

jest.mock('./helpers');
jest.mock('./intent-errors');

const mockRunEntityListAction = runEntityListAction as jest.MockedFunction<
typeof runEntityListAction
const mockHandleCommandError = handleCommandError as jest.MockedFunction<
typeof handleCommandError
>;

describe('catalog list', () => {
async function parseCatalog(...args: string[]) {
const program = new Command();
registerCatalogCommands(program);
await program.parseAsync(['node', 'test', 'catalog', ...args]);
}

describe('catalog command validation', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('requests only the default table fields in human output', async () => {
const program = new Command();
registerCatalogCommands(program);

await program.parseAsync([
'node',
'test',
'catalog',
'list',
'--kind',
'template',
]);

expect(mockRunEntityListAction).toHaveBeenCalledWith(
'catalog:query-catalog-entities',
it('rejects validate/register/unregister without required inputs', async () => {
const cases: Array<{
args: string[];
message: string;
suggestion: string;
}> = [
{
args: ['validate'],
message: '--entity or --entity-file is required',
suggestion:
'rhdh-cli catalog validate --entity-file ./catalog-info.yaml',
},
{
args: ['register'],
message: '--location-url is required',
suggestion:
'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml',
},
{
instance: undefined,
limit: undefined,
query: JSON.stringify({ kind: 'template' }),
fields: JSON.stringify([
'metadata.name',
'kind',
'metadata.namespace',
'spec.type',
]),
args: ['unregister'],
message: '--location-id or --location-url is required',
suggestion: 'rhdh-cli catalog unregister --location-id <id>',
},
'human',
'rhdh-cli catalog list --kind Component',
undefined,
);
];

for (const { args, message, suggestion } of cases) {
jest.clearAllMocks();
await parseCatalog(...args);
expect(mockHandleCommandError).toHaveBeenCalledWith(
expect.objectContaining({ message }),
'human',
{ suggestion },
);
}
});
});
48 changes: 2 additions & 46 deletions src/commands/intent-based-actions/docs.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command } from 'commander';
import { execAction, execActionJson, triggerTechDocsBuild } from './client';
import { registerDocsCommands } from './docs';
import { resolveEntityWithAmbiguityCheck, runSearchAction } from './helpers';
import { resolveEntityWithAmbiguityCheck } from './helpers';
import { handleCommandError } from './intent-errors';

jest.mock('./client');
Expand All @@ -18,9 +18,6 @@ const mockTriggerTechDocsBuild = triggerTechDocsBuild as jest.MockedFunction<
const mockHandleCommandError = handleCommandError as jest.MockedFunction<
typeof handleCommandError
>;
const mockRunSearchAction = runSearchAction as jest.MockedFunction<
typeof runSearchAction
>;
const mockResolveEntityWithAmbiguityCheck =
resolveEntityWithAmbiguityCheck as jest.MockedFunction<
typeof resolveEntityWithAmbiguityCheck
Expand All @@ -35,28 +32,7 @@ describe('docs get', () => {
jest.clearAllMocks();
});

it('reports an unresolved entity as a catalog error', async () => {
const error = new Error('Entity not found');
mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error);
const stderrSpy = jest
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const program = new Command();
registerDocsCommands(program);

await program.parseAsync(['node', 'test', 'docs', 'get', 'missing']);

expect(stderrSpy).not.toHaveBeenCalled();
expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human');

stderrSpy.mockRestore();
exitSpy.mockRestore();
});

it('verifies that a full entity reference exists before retrieving docs', async () => {
it('requires verifyExists and reports unresolved entities', async () => {
const error = new Error('Entity not found: system:default/missing');
mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error);
const program = new Command();
Expand Down Expand Up @@ -151,26 +127,6 @@ describe('docs get', () => {
});
});

describe('docs search', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('suggests enabling the TechDocs search backend when search fails', async () => {
const program = new Command();
registerDocsCommands(program);

await program.parseAsync(['node', 'test', 'docs', 'search', 'rhdh']);

expect(mockRunSearchAction).toHaveBeenCalledWith(
'rhdh',
expect.objectContaining({ types: '["techdocs"]' }),
'human',
'Enable search-backend-module-techdocs on the RHDH instance.',
);
});
});

describe('docs list', () => {
it('rejects the unsupported --limit option', async () => {
const program = new Command();
Expand Down
Loading
Loading