diff --git a/e2e-tests/intent-based-cli.test.ts b/e2e-tests/intent-based-cli.test.ts new file mode 100644 index 0000000..99f80c2 --- /dev/null +++ b/e2e-tests/intent-based-cli.test.ts @@ -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(); + }); +}); diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts index 329ebc2..9b6de28 100644 --- a/src/commands/intent-based-actions/catalog.test.ts +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -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 ', }, - '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 }, + ); + } }); }); diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts index 8f6c27d..cf9dc81 100644 --- a/src/commands/intent-based-actions/docs.test.ts +++ b/src/commands/intent-based-actions/docs.test.ts @@ -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'); @@ -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 @@ -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(); @@ -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(); diff --git a/src/commands/intent-based-actions/intent-commands.integration.test.ts b/src/commands/intent-based-actions/intent-commands.integration.test.ts new file mode 100644 index 0000000..c815d83 --- /dev/null +++ b/src/commands/intent-based-actions/intent-commands.integration.test.ts @@ -0,0 +1,441 @@ +/** + * Mocked integration tests: real commander + helpers + format/errors, + * with only `./client` (action execution) mocked. No live RHDH required. + */ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { execAction, execActionJson } from './client'; +import { registerApiCommands } from './api'; +import { registerCatalogCommands } from './catalog'; +import { registerDocsCommands } from './docs'; +import { registerSearchCommands } from './search'; +import { registerTemplateCommands } from './template'; + +jest.mock('./client'); + +const mockExecAction = execAction as jest.MockedFunction; +const mockExecActionJson = execActionJson as jest.MockedFunction< + typeof execActionJson +>; + +const SAMPLE_COMPONENT = { + kind: 'Component', + metadata: { name: 'payments', namespace: 'default' }, + spec: { type: 'service' }, +}; + +const SAMPLE_API = { + kind: 'API', + metadata: { name: 'payments-api', namespace: 'default' }, + spec: { + type: 'openapi', + definition: 'openapi: 3.0.0\ninfo:\n title: Payments\n', + }, +}; + +const SAMPLE_TEMPLATE = { + kind: 'Template', + metadata: { name: 'react-ssr', namespace: 'default' }, + spec: { type: 'website' }, +}; + +function captureIo() { + const stdout = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderr = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const exit = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + return { + stdout, + stderr, + exit, + restore() { + stdout.mockRestore(); + stderr.mockRestore(); + exit.mockRestore(); + }, + }; +} + +async function runCli( + register: (program: Command) => void, + args: string[], +): Promise { + const program = new Command(); + // Avoid commander calling process.exit on its own parse errors. + program.exitOverride(); + register(program); + await program.parseAsync(['node', 'test', ...args]); +} + +function spyText(spy: jest.SpyInstance): string { + return spy.mock.calls.map(call => String(call[0] ?? '')).join(''); +} + +describe('intent commands (mocked client integration)', () => { + let io: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + io = captureIo(); + }); + + afterEach(() => { + io.restore(); + }); + + it('catalog list renders entities (human) and passes through json', async () => { + mockExecActionJson.mockReturnValue({ items: [SAMPLE_COMPONENT] }); + await runCli(registerCatalogCommands, [ + 'catalog', + 'list', + '--kind', + 'Component', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ kind: 'Component' }), + instance: 'staging', + fields: JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]), + }), + ); + expect(spyText(io.stdout)).toContain('payments'); + expect(spyText(io.stdout)).toContain('Component'); + + jest.clearAllMocks(); + io.stdout.mockClear(); + mockExecAction.mockReturnValue( + JSON.stringify({ items: [SAMPLE_COMPONENT] }), + ); + await runCli(registerCatalogCommands, [ + 'catalog', + 'list', + '--kind', + 'Component', + '--output', + 'json', + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ kind: 'Component' }), + fields: undefined, + }), + ); + expect(spyText(io.stdout)).toContain('"items"'); + }); + + it('catalog get uses a full ref and prints the entity', async () => { + mockExecAction.mockReturnValue(JSON.stringify(SAMPLE_COMPONENT)); + await runCli(registerCatalogCommands, [ + 'catalog', + 'get', + 'component:default/payments', + ]); + expect(mockExecAction).toHaveBeenCalledWith('catalog:get-catalog-entity', { + name: 'payments', + kind: 'component', + namespace: 'default', + instance: undefined, + }); + expect(spyText(io.stdout)).toContain('payments'); + }); + + it('catalog validate/register/unregister call catalog actions', async () => { + const entityYaml = + 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: payments\n'; + mockExecAction.mockReturnValue(JSON.stringify({ valid: true })); + await runCli(registerCatalogCommands, [ + 'catalog', + 'validate', + '--entity', + entityYaml, + '--instance', + 'staging', + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:validate-entity', + expect.objectContaining({ + entity: entityYaml, + instance: 'staging', + }), + ); + + jest.clearAllMocks(); + io.stdout.mockClear(); + const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-int-catalog-')); + const entityFile = join(dir, 'catalog-info.yaml'); + writeFileSync(entityFile, entityYaml); + mockExecAction.mockReturnValue(JSON.stringify({ valid: true })); + await runCli(registerCatalogCommands, [ + 'catalog', + 'validate', + '--entity-file', + entityFile, + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:validate-entity', + expect.objectContaining({ entity: entityYaml }), + ); + + jest.clearAllMocks(); + io.stdout.mockClear(); + mockExecAction.mockReturnValue( + JSON.stringify({ location: 'url:https://example.com/catalog-info.yaml' }), + ); + await runCli(registerCatalogCommands, [ + 'catalog', + 'register', + '--location-url', + 'https://example.com/catalog-info.yaml', + '--instance', + 'staging', + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:register-entity', + expect.objectContaining({ + locationUrl: 'https://example.com/catalog-info.yaml', + instance: 'staging', + }), + ); + expect(spyText(io.stdout)).toContain('catalog-info.yaml'); + + jest.clearAllMocks(); + io.stdout.mockClear(); + mockExecAction.mockReturnValue(JSON.stringify({ ok: true })); + await runCli(registerCatalogCommands, [ + 'catalog', + 'unregister', + '--location-id', + 'loc-123', + '--location-url', + 'https://example.com/catalog-info.yaml', + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:unregister-entity', + expect.objectContaining({ + type: JSON.stringify({ + locationId: 'loc-123', + locationUrl: 'https://example.com/catalog-info.yaml', + }), + }), + ); + }); + + it('api list and get-spec extract definitions or error when missing', async () => { + mockExecActionJson.mockReturnValue({ items: [SAMPLE_API] }); + await runCli(registerApiCommands, ['api', 'list', '--type', 'openapi']); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ kind: 'API', 'spec.type': 'openapi' }), + }), + ); + expect(spyText(io.stdout)).toContain('payments-api'); + + jest.clearAllMocks(); + io.stdout.mockClear(); + mockExecAction.mockReturnValue(JSON.stringify(SAMPLE_API)); + await runCli(registerApiCommands, [ + 'api', + 'get-spec', + 'api:default/payments-api', + ]); + expect(mockExecAction).toHaveBeenCalledWith('catalog:get-catalog-entity', { + name: 'payments-api', + kind: 'API', + namespace: 'default', + instance: undefined, + }); + expect(spyText(io.stdout)).toContain('openapi: 3.0.0'); + + jest.clearAllMocks(); + io.stderr.mockClear(); + io.exit.mockClear(); + mockExecAction.mockReturnValue( + JSON.stringify({ + kind: 'API', + metadata: { name: 'empty', namespace: 'default' }, + spec: { type: 'openapi' }, + }), + ); + await runCli(registerApiCommands, ['api', 'get-spec', 'api:default/empty']); + expect(io.exit).toHaveBeenCalledWith(1); + expect(spyText(io.stderr)).toMatch(/no spec\.definition/i); + }); + + it('search prints results and surfaces action failures', async () => { + mockExecActionJson.mockReturnValue({ + results: [ + { + document: { + title: 'Payments service', + location: '/catalog/default/component/payments', + text: 'Handles payment flows', + }, + }, + ], + }); + await runCli(registerSearchCommands, [ + 'search', + 'payment', + '--filter', + 'kind=Component', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'search:query', + expect.objectContaining({ + term: 'payment', + filters: JSON.stringify({ kind: 'Component' }), + instance: 'staging', + }), + ); + expect(spyText(io.stdout)).toContain('Payments service'); + + jest.clearAllMocks(); + io.stderr.mockClear(); + io.exit.mockClear(); + mockExecActionJson.mockImplementation(() => { + throw new Error('ECONNREFUSED'); + }); + await runCli(registerSearchCommands, ['search', 'payment']); + expect(io.exit).toHaveBeenCalledWith(1); + const err = spyText(io.stderr); + expect(err).toMatch(/Error:/); + expect(err).toMatch(/connect|ECONNREFUSED/i); + }); + + it('docs search scopes types to techdocs', async () => { + mockExecActionJson.mockReturnValue({ results: [] }); + await runCli(registerDocsCommands, ['docs', 'search', 'onboarding']); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'search:query', + expect.objectContaining({ + term: 'onboarding', + types: '["techdocs"]', + }), + ); + expect(spyText(io.stdout)).toMatch(/No results found/i); + }); + + it('docs list and get call techdocs-mcp-extras actions', async () => { + mockExecActionJson.mockReturnValue({ items: [SAMPLE_COMPONENT] }); + await runCli(registerDocsCommands, [ + 'docs', + 'list', + '--kind', + 'Component', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'techdocs-mcp-extras:fetch-techdocs', + expect.objectContaining({ + entityType: 'Component', + instance: 'staging', + }), + ); + expect(spyText(io.stdout)).toContain('payments'); + + jest.clearAllMocks(); + io.stdout.mockClear(); + // docs get always verifies the entity exists, then retrieves content. + mockExecActionJson + .mockReturnValueOnce({ items: [SAMPLE_COMPONENT] }) + .mockReturnValueOnce({ + content: '# Payments\n\nService overview', + }); + await runCli(registerDocsCommands, [ + 'docs', + 'get', + 'component:default/payments', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenNthCalledWith( + 1, + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ + 'metadata.name': 'payments', + kind: 'component', + 'metadata.namespace': 'default', + }), + instance: 'staging', + }), + ); + expect(mockExecActionJson).toHaveBeenNthCalledWith( + 2, + 'techdocs-mcp-extras:retrieve-techdocs-content', + expect.objectContaining({ + entityRef: 'Component:default/payments', + instance: 'staging', + }), + ); + expect(spyText(io.stdout)).toContain('Service overview'); + }); + + it('template list/execute/dry-run call scaffolder and catalog actions', async () => { + mockExecActionJson.mockReturnValue({ items: [SAMPLE_TEMPLATE] }); + await runCli(registerTemplateCommands, ['template', 'list']); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ kind: 'Template' }), + }), + ); + expect(spyText(io.stdout)).toContain('react-ssr'); + + jest.clearAllMocks(); + io.stdout.mockClear(); + mockExecAction.mockReturnValue(JSON.stringify({ id: 'task-1' })); + await runCli(registerTemplateCommands, [ + 'template', + 'execute', + 'template:default/react-ssr', + '--value', + 'name=demo', + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'scaffolder:execute-template', + expect.objectContaining({ + templateRef: 'template:default/react-ssr', + values: JSON.stringify({ name: 'demo' }), + }), + ); + + jest.clearAllMocks(); + mockExecAction.mockReturnValue(JSON.stringify({ ok: true })); + const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-int-tpl-')); + const templateFile = join(dir, 'template.yaml'); + const yaml = + 'apiVersion: scaffolder.backstage.io/v1beta3\nkind: Template\n'; + writeFileSync(templateFile, yaml); + await runCli(registerTemplateCommands, [ + 'template', + 'dry-run', + '--template-file', + templateFile, + ]); + expect(mockExecAction).toHaveBeenCalledWith( + 'scaffolder:dry-run-template', + expect.objectContaining({ templateYaml: yaml }), + ); + }); +}); diff --git a/src/commands/intent-based-actions/search.test.ts b/src/commands/intent-based-actions/search.test.ts new file mode 100644 index 0000000..1678b17 --- /dev/null +++ b/src/commands/intent-based-actions/search.test.ts @@ -0,0 +1,43 @@ +import { Command } from 'commander'; +import { handleCommandError } from './intent-errors'; +import { registerSearchCommands } from './search'; + +jest.mock('./helpers'); +jest.mock('./intent-errors'); + +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; + +async function parseSearch(...args: string[]) { + const program = new Command(); + registerSearchCommands(program); + await program.parseAsync(['node', 'test', 'search', ...args]); +} + +describe('search command validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects an empty term and an invalid --filter', async () => { + await parseSearch(''); + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Search term is required' }), + 'human', + { suggestion: 'rhdh-cli search "my service"' }, + ); + + jest.clearAllMocks(); + await parseSearch('rhdh', '--filter', 'kind'); + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Invalid "key=value" pair'), + }), + 'human', + { + suggestion: 'rhdh-cli search "my service" --filter kind=Component', + }, + ); + }); +}); diff --git a/src/commands/intent-based-actions/template.test.ts b/src/commands/intent-based-actions/template.test.ts index 1c969f3..3196f77 100644 --- a/src/commands/intent-based-actions/template.test.ts +++ b/src/commands/intent-based-actions/template.test.ts @@ -1,38 +1,31 @@ import { Command } from 'commander'; -import { runEntityListAction } from './helpers'; +import { handleCommandError } from './intent-errors'; import { registerTemplateCommands } from './template'; jest.mock('./helpers'); +jest.mock('./intent-errors'); -const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< - typeof runEntityListAction +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError >; -describe('template list', () => { +describe('template command validation', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('requests only the fields rendered in human output', async () => { + it('rejects dry-run without --template-file', async () => { const program = new Command(); registerTemplateCommands(program); + await program.parseAsync(['node', 'test', 'template', 'dry-run']); - await program.parseAsync(['node', 'test', 'template', 'list']); - - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ message: '--template-file is required' }), + 'human', { - query: JSON.stringify({ kind: 'Template' }), - instance: undefined, - limit: undefined, - fields: JSON.stringify([ - 'metadata.name', - 'kind', - 'metadata.namespace', - 'spec.type', - ]), + suggestion: + 'rhdh-cli template dry-run --template-file ./template.yaml --value name=my-app', }, - 'human', ); }); });