From c9cad26344f64429099e89f6a77f438debd6348c Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 23 Sep 2026 11:23:06 +0530 Subject: [PATCH 1/7] test(RHIDP-14570): add intent-based CLI unit gaps and e2e scaffold Fill search/api unit coverage, expand catalog/template command tests, and add help/error contract e2e that skips live RHDH unless configured. Co-authored-by: Cursor --- e2e-tests/intent-based-cli.test.ts | 139 ++++++++++++++ src/commands/intent-based-actions/api.test.ts | 180 ++++++++++++++++++ .../intent-based-actions/catalog.test.ts | 159 +++++++++++++++- .../intent-based-actions/search.test.ts | 108 +++++++++++ .../intent-based-actions/template.test.ts | 150 ++++++++++++++- 5 files changed, 734 insertions(+), 2 deletions(-) create mode 100644 e2e-tests/intent-based-cli.test.ts create mode 100644 src/commands/intent-based-actions/api.test.ts create mode 100644 src/commands/intent-based-actions/search.test.ts diff --git a/e2e-tests/intent-based-cli.test.ts b/e2e-tests/intent-based-cli.test.ts new file mode 100644 index 0000000..617116b --- /dev/null +++ b/e2e-tests/intent-based-cli.test.ts @@ -0,0 +1,139 @@ +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, runCommand } 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('rhdh-cli --help mentions catalog/api/search/docs/template', async () => { + logSection('rhdh-cli --help'); + const { stdout } = await runCommand(`"${rhdhCli}" --help`); + log(stdout); + + for (const command of ['catalog', 'api', 'search', 'docs', 'template']) { + expect(stdout).toContain(command); + } + }); + + it('catalog --help exposes subcommands', async () => { + const { stdout } = await runCommand(`"${rhdhCli}" catalog --help`); + for (const sub of ['list', 'get', 'validate', 'register', 'unregister']) { + expect(stdout).toContain(sub); + } + }); + + it('search --help exposes filter/types flags', async () => { + const { stdout } = await runCommand(`"${rhdhCli}" search --help`); + expect(stdout).toMatch(/--types/); + expect(stdout).toMatch(/--filter/); + }); + + it('template --help exposes execute and dry-run', async () => { + const { stdout } = await runCommand(`"${rhdhCli}" template --help`); + expect(stdout).toContain('execute'); + expect(stdout).toContain('dry-run'); + }); + + it('catalog register without --location-url exits non-zero with Error on stderr', async () => { + const result = await runCli('catalog register'); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/Error:/); + expect(result.stderr).toMatch(/location-url/i); + }); + + it('unknown command exits non-zero with Error on stderr', async () => { + const result = await runCli('not-a-real-command'); + expect(result.code).not.toBe(0); + expect(`${result.stderr}${result.stdout}`).toMatch( + /error|unknown|invalid command/i, + ); + }); + + it('help still 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); + } + }); +}); + +describe('intent-based CLI live RHDH (optional)', () => { + jest.setTimeout(TEST_TIMEOUT); + + const maybeIt = RHDH_CLI_E2E_URL ? it : it.skip; + + maybeIt( + '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', + { + env: { + // Prefer existing auth/instance config; URL documents the target. + RHDH_CLI_E2E_URL, + }, + }, + ); + + expect(result.code).toBe(0); + expect(() => JSON.parse(result.stdout)).not.toThrow(); + }, + ); +}); diff --git a/src/commands/intent-based-actions/api.test.ts b/src/commands/intent-based-actions/api.test.ts new file mode 100644 index 0000000..d501c12 --- /dev/null +++ b/src/commands/intent-based-actions/api.test.ts @@ -0,0 +1,180 @@ +import { Command } from 'commander'; +import { registerApiCommands } from './api'; +import { execAction } from './client'; +import { + resolveEntityWithAmbiguityCheck, + runEntityListAction, +} from './helpers'; +import { handleCommandError } from './intent-errors'; + +jest.mock('./helpers'); +jest.mock('./client'); +jest.mock('./intent-errors'); + +const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< + typeof runEntityListAction +>; +const mockResolveEntityWithAmbiguityCheck = + resolveEntityWithAmbiguityCheck as jest.MockedFunction< + typeof resolveEntityWithAmbiguityCheck + >; +const mockExecAction = execAction as jest.MockedFunction; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; + +function captureStdout() { + return jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +} + +describe('api list', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('queries kind API', async () => { + const program = new Command(); + registerApiCommands(program); + + await program.parseAsync(['node', 'test', 'api', 'list']); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ kind: 'API' }), + instance: undefined, + limit: undefined, + }, + 'human', + 'rhdh-cli api list', + ); + }); + + it('includes optional --type in the query', async () => { + const program = new Command(); + registerApiCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'api', + 'list', + '--type', + 'openapi', + ]); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ kind: 'API', 'spec.type': 'openapi' }), + instance: undefined, + limit: undefined, + }, + 'human', + 'rhdh-cli api list', + ); + }); +}); + +describe('api get-spec', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves the entity and writes the human definition', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'api:default/petstore', + kind: 'API', + namespace: 'default', + name: 'petstore', + }); + mockExecAction.mockReturnValue( + JSON.stringify({ + spec: { type: 'openapi', definition: 'openapi: 3.0.0' }, + }), + ); + const writeSpy = captureStdout(); + const program = new Command(); + registerApiCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'api', + 'get-spec', + 'petstore', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'petstore', + expect.objectContaining({ defaultKind: 'api' }), + ); + expect(mockExecAction).toHaveBeenCalledWith('catalog:get-catalog-entity', { + name: 'petstore', + kind: 'API', + namespace: 'default', + instance: undefined, + }); + expect(writeSpy).toHaveBeenCalledWith('openapi: 3.0.0\n'); + writeSpy.mockRestore(); + }); + + it('writes structured json when --output json', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'api:default/petstore', + kind: 'API', + namespace: 'default', + name: 'petstore', + }); + mockExecAction.mockReturnValue( + JSON.stringify({ + spec: { type: 'openapi', definition: 'openapi: 3.0.0' }, + }), + ); + const writeSpy = captureStdout(); + const program = new Command(); + registerApiCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'api', + 'get-spec', + 'petstore', + '--output', + 'json', + ]); + + const output = writeSpy.mock.calls[0][0] as string; + expect(JSON.parse(output)).toEqual({ + name: 'petstore', + type: 'openapi', + definition: 'openapi: 3.0.0', + }); + writeSpy.mockRestore(); + }); + + it('calls handleCommandError when spec.definition is missing', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'api:default/empty', + kind: 'API', + namespace: 'default', + name: 'empty', + }); + mockExecAction.mockReturnValue( + JSON.stringify({ spec: { type: 'openapi' } }), + ); + const program = new Command(); + registerApiCommands(program); + + await program.parseAsync(['node', 'test', 'api', 'get-spec', 'empty']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'API "empty" has no spec.definition', + }), + 'human', + { suggestion: 'rhdh-cli api list' }, + ); + }); +}); diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts index 329ebc2..e6f51b1 100644 --- a/src/commands/intent-based-actions/catalog.test.ts +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -1,12 +1,28 @@ import { Command } from 'commander'; import { registerCatalogCommands } from './catalog'; -import { runEntityListAction } from './helpers'; +import { + resolveEntityWithAmbiguityCheck, + runEntityListAction, + runRawAction, +} from './helpers'; +import { handleCommandError } from './intent-errors'; jest.mock('./helpers'); +jest.mock('./intent-errors'); const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< typeof runEntityListAction >; +const mockRunRawAction = runRawAction as jest.MockedFunction< + typeof runRawAction +>; +const mockResolveEntityWithAmbiguityCheck = + resolveEntityWithAmbiguityCheck as jest.MockedFunction< + typeof resolveEntityWithAmbiguityCheck + >; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; describe('catalog list', () => { beforeEach(() => { @@ -44,4 +60,145 @@ describe('catalog list', () => { undefined, ); }); + + it('omits default human fields for --output json', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'catalog', + 'list', + '--kind', + 'Component', + '--output', + 'json', + ]); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + instance: undefined, + limit: undefined, + query: JSON.stringify({ kind: 'Component' }), + fields: undefined, + }, + 'json', + 'rhdh-cli catalog list --kind Component', + undefined, + ); + }); +}); + +describe('catalog get', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves the entity and calls runRawAction', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'component:default/my-service', + kind: 'Component', + namespace: 'default', + name: 'my-service', + }); + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'catalog', + 'get', + 'my-service', + '--kind', + 'Component', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'my-service', + expect.objectContaining({ + kindFlag: 'Component', + }), + ); + expect(mockRunRawAction).toHaveBeenCalledWith( + 'catalog:get-catalog-entity', + { + name: 'my-service', + kind: 'Component', + namespace: 'default', + instance: undefined, + }, + 'human', + 'rhdh-cli catalog get my-service', + ); + }); +}); + +describe('catalog validate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleCommandError without entity or entity-file', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync(['node', 'test', 'catalog', 'validate']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: '--entity or --entity-file is required', + }), + 'human', + { + suggestion: + 'rhdh-cli catalog validate --entity-file ./catalog-info.yaml', + }, + ); + }); +}); + +describe('catalog register', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleCommandError without --location-url', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync(['node', 'test', 'catalog', 'register']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ message: '--location-url is required' }), + 'human', + { + suggestion: + 'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml', + }, + ); + }); +}); + +describe('catalog unregister', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleCommandError without location', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync(['node', 'test', 'catalog', 'unregister']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: '--location-id or --location-url is required', + }), + 'human', + { suggestion: 'rhdh-cli catalog unregister --location-id ' }, + ); + }); }); 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..ce5d12f --- /dev/null +++ b/src/commands/intent-based-actions/search.test.ts @@ -0,0 +1,108 @@ +import { Command } from 'commander'; +import { runSearchAction } from './helpers'; +import { handleCommandError } from './intent-errors'; +import { registerSearchCommands } from './search'; + +jest.mock('./helpers'); +jest.mock('./intent-errors'); + +const mockRunSearchAction = runSearchAction as jest.MockedFunction< + typeof runSearchAction +>; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; + +describe('search', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('passes term + filter/types to runSearchAction', async () => { + const program = new Command(); + registerSearchCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'search', + 'my', + 'service', + '--types', + 'techdocs,software-catalog', + '--filter', + 'kind=Component', + ]); + + expect(mockRunSearchAction).toHaveBeenCalledWith( + 'my service', + { + types: JSON.stringify(['techdocs', 'software-catalog']), + filters: JSON.stringify({ kind: 'Component' }), + pageLimit: undefined, + pageCursor: undefined, + instance: undefined, + }, + 'human', + 'rhdh-cli search "deployment guide" --filter kind=Component', + ); + }); + + it('calls handleCommandError when the search term is empty', async () => { + const program = new Command(); + registerSearchCommands(program); + + await program.parseAsync(['node', 'test', 'search', '']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Search term is required' }), + 'human', + { suggestion: 'rhdh-cli search "my service"' }, + ); + }); + + it('uses json mode for --output json', async () => { + const program = new Command(); + registerSearchCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'search', + 'rhdh', + '--output', + 'json', + ]); + + expect(mockRunSearchAction).toHaveBeenCalledWith( + 'rhdh', + expect.any(Object), + 'json', + 'rhdh-cli search "deployment guide" --filter kind=Component', + ); + }); + + it('calls handleCommandError for an invalid --filter missing =', async () => { + const program = new Command(); + registerSearchCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'search', + '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..4af3e12 100644 --- a/src/commands/intent-based-actions/template.test.ts +++ b/src/commands/intent-based-actions/template.test.ts @@ -1,12 +1,31 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Command } from 'commander'; -import { runEntityListAction } from './helpers'; +import { + resolveEntityWithAmbiguityCheck, + runEntityListAction, + runRawAction, +} 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 mockRunRawAction = runRawAction as jest.MockedFunction< + typeof runRawAction +>; +const mockResolveEntityWithAmbiguityCheck = + resolveEntityWithAmbiguityCheck as jest.MockedFunction< + typeof resolveEntityWithAmbiguityCheck + >; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; describe('template list', () => { beforeEach(() => { @@ -35,4 +54,133 @@ describe('template list', () => { 'human', ); }); + + it('does not set fields for --output json', async () => { + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'template', + 'list', + '--output', + 'json', + ]); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ kind: 'Template' }), + instance: undefined, + limit: undefined, + fields: undefined, + }, + 'json', + ); + }); +}); + +describe('template execute', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves the ref and calls runRawAction with scaffolder:execute-template', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'template:default/react-ssr', + kind: 'Template', + namespace: 'default', + name: 'react-ssr', + }); + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'template', + 'execute', + 'react-ssr', + '--value', + 'name=my-app', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'react-ssr', + expect.objectContaining({ defaultKind: 'template' }), + ); + expect(mockRunRawAction).toHaveBeenCalledWith( + 'scaffolder:execute-template', + { + templateRef: 'template:default/react-ssr', + values: JSON.stringify({ name: 'my-app' }), + secrets: undefined, + instance: undefined, + }, + 'human', + 'rhdh-cli template list', + ); + }); +}); + +describe('template dry-run', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls handleCommandError without --template-file', async () => { + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync(['node', 'test', 'template', 'dry-run']); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ message: '--template-file is required' }), + 'human', + { + suggestion: + 'rhdh-cli template dry-run --template-file ./template.yaml --value name=my-app', + }, + ); + }); + + it('reads YAML from --template-file and calls scaffolder:dry-run-template', async () => { + const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-template-')); + const templateFile = join(dir, 'template.yaml'); + const yaml = [ + 'apiVersion: scaffolder.backstage.io/v1beta3', + 'kind: Template', + 'metadata:', + ' name: demo', + 'spec:', + ' steps: []', + ].join('\n'); + writeFileSync(templateFile, yaml); + + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'template', + 'dry-run', + '--template-file', + templateFile, + '--value', + 'name=demo', + ]); + + expect(mockRunRawAction).toHaveBeenCalledWith( + 'scaffolder:dry-run-template', + { + templateYaml: yaml, + values: JSON.stringify({ name: 'demo' }), + instance: undefined, + }, + 'human', + 'rhdh-cli template list', + ); + }); }); From 6efa4a186bb173f43c6e795d6c349dd87fbcb3a5 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 23 Sep 2026 11:35:51 +0530 Subject: [PATCH 2/7] fix(RHIDP-14570): resolve lint and trim redundant intent CLI tests Use describe.skip for optional live RHDH e2e to satisfy jest/no-standalone-expect, collapse duplicated help/error and command cases to cut Sonar duplication, and keep unique failure coverage. Co-authored-by: Cursor --- e2e-tests/intent-based-cli.test.ts | 100 +++++------ src/commands/intent-based-actions/api.test.ts | 95 ++--------- .../intent-based-actions/catalog.test.ts | 160 +++++------------- .../intent-based-actions/search.test.ts | 67 ++------ .../intent-based-actions/template.test.ts | 80 ++------- 5 files changed, 140 insertions(+), 362 deletions(-) diff --git a/e2e-tests/intent-based-cli.test.ts b/e2e-tests/intent-based-cli.test.ts index 617116b..99f80c2 100644 --- a/e2e-tests/intent-based-cli.test.ts +++ b/e2e-tests/intent-based-cli.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; -import { log, logSection, runCommand } from './support/plugin-export-build'; +import { log, logSection } from './support/plugin-export-build'; const exec = promisify(execCallback); @@ -49,51 +49,46 @@ async function runCli( describe('intent-based CLI help and error contracts', () => { jest.setTimeout(TEST_TIMEOUT); - it('rhdh-cli --help mentions catalog/api/search/docs/template', async () => { - logSection('rhdh-cli --help'); - const { stdout } = await runCommand(`"${rhdhCli}" --help`); - log(stdout); + 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(stdout).toContain(command); + expect(root.stdout).toContain(command); } - }); - it('catalog --help exposes subcommands', async () => { - const { stdout } = await runCommand(`"${rhdhCli}" catalog --help`); + const catalog = await runCli('catalog --help'); + expect(catalog.code).toBe(0); for (const sub of ['list', 'get', 'validate', 'register', 'unregister']) { - expect(stdout).toContain(sub); + expect(catalog.stdout).toContain(sub); } - }); - it('search --help exposes filter/types flags', async () => { - const { stdout } = await runCommand(`"${rhdhCli}" search --help`); - expect(stdout).toMatch(/--types/); - expect(stdout).toMatch(/--filter/); - }); + const search = await runCli('search --help'); + expect(search.code).toBe(0); + expect(search.stdout).toMatch(/--types/); + expect(search.stdout).toMatch(/--filter/); - it('template --help exposes execute and dry-run', async () => { - const { stdout } = await runCommand(`"${rhdhCli}" template --help`); - expect(stdout).toContain('execute'); - expect(stdout).toContain('dry-run'); + const template = await runCli('template --help'); + expect(template.code).toBe(0); + expect(template.stdout).toContain('execute'); + expect(template.stdout).toContain('dry-run'); }); - it('catalog register without --location-url exits non-zero with Error on stderr', async () => { - const result = await runCli('catalog register'); - expect(result.code).not.toBe(0); - expect(result.stderr).toMatch(/Error:/); - expect(result.stderr).toMatch(/location-url/i); - }); + 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); - it('unknown command exits non-zero with Error on stderr', async () => { - const result = await runCli('not-a-real-command'); - expect(result.code).not.toBe(0); - expect(`${result.stderr}${result.stdout}`).toMatch( + 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 still works from an empty temp dir with no package.json', async () => { + 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-'), ); @@ -107,33 +102,22 @@ describe('intent-based CLI help and error contracts', () => { }); }); -describe('intent-based CLI live RHDH (optional)', () => { +const describeLive = RHDH_CLI_E2E_URL ? describe : describe.skip; + +describeLive('intent-based CLI live RHDH (optional)', () => { jest.setTimeout(TEST_TIMEOUT); - const maybeIt = RHDH_CLI_E2E_URL ? it : it.skip; - - maybeIt( - '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', - { - env: { - // Prefer existing auth/instance config; URL documents the target. - RHDH_CLI_E2E_URL, - }, - }, - ); - - expect(result.code).toBe(0); - expect(() => JSON.parse(result.stdout)).not.toThrow(); - }, - ); + 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/api.test.ts b/src/commands/intent-based-actions/api.test.ts index d501c12..ae50655 100644 --- a/src/commands/intent-based-actions/api.test.ts +++ b/src/commands/intent-based-actions/api.test.ts @@ -23,21 +23,19 @@ const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -function captureStdout() { - return jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +async function parseApi(...args: string[]) { + const program = new Command(); + registerApiCommands(program); + await program.parseAsync(['node', 'test', 'api', ...args]); } -describe('api list', () => { +describe('api', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('queries kind API', async () => { - const program = new Command(); - registerApiCommands(program); - - await program.parseAsync(['node', 'test', 'api', 'list']); - + it('lists API entities and applies optional --type', async () => { + await parseApi('list'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { @@ -48,21 +46,9 @@ describe('api list', () => { 'human', 'rhdh-cli api list', ); - }); - - it('includes optional --type in the query', async () => { - const program = new Command(); - registerApiCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'api', - 'list', - '--type', - 'openapi', - ]); + jest.clearAllMocks(); + await parseApi('list', '--type', 'openapi'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { @@ -74,14 +60,8 @@ describe('api list', () => { 'rhdh-cli api list', ); }); -}); - -describe('api get-spec', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('resolves the entity and writes the human definition', async () => { + it('get-spec resolves the entity and writes definition or errors', async () => { mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ entityRef: 'api:default/petstore', kind: 'API', @@ -93,18 +73,12 @@ describe('api get-spec', () => { spec: { type: 'openapi', definition: 'openapi: 3.0.0' }, }), ); - const writeSpy = captureStdout(); - const program = new Command(); - registerApiCommands(program); - await program.parseAsync([ - 'node', - 'test', - 'api', - 'get-spec', - 'petstore', - ]); + const writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + await parseApi('get-spec', 'petstore'); expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( 'petstore', expect.objectContaining({ defaultKind: 'api' }), @@ -116,45 +90,16 @@ describe('api get-spec', () => { instance: undefined, }); expect(writeSpy).toHaveBeenCalledWith('openapi: 3.0.0\n'); - writeSpy.mockRestore(); - }); - it('writes structured json when --output json', async () => { - mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ - entityRef: 'api:default/petstore', - kind: 'API', - namespace: 'default', - name: 'petstore', - }); - mockExecAction.mockReturnValue( - JSON.stringify({ - spec: { type: 'openapi', definition: 'openapi: 3.0.0' }, - }), - ); - const writeSpy = captureStdout(); - const program = new Command(); - registerApiCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'api', - 'get-spec', - 'petstore', - '--output', - 'json', - ]); - - const output = writeSpy.mock.calls[0][0] as string; - expect(JSON.parse(output)).toEqual({ + writeSpy.mockClear(); + await parseApi('get-spec', 'petstore', '--output', 'json'); + expect(JSON.parse(writeSpy.mock.calls[0][0] as string)).toEqual({ name: 'petstore', type: 'openapi', definition: 'openapi: 3.0.0', }); writeSpy.mockRestore(); - }); - it('calls handleCommandError when spec.definition is missing', async () => { mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ entityRef: 'api:default/empty', kind: 'API', @@ -164,11 +109,7 @@ describe('api get-spec', () => { mockExecAction.mockReturnValue( JSON.stringify({ spec: { type: 'openapi' } }), ); - const program = new Command(); - registerApiCommands(program); - - await program.parseAsync(['node', 'test', 'api', 'get-spec', 'empty']); - + await parseApi('get-spec', 'empty'); expect(mockHandleCommandError).toHaveBeenCalledWith( expect.objectContaining({ message: 'API "empty" has no spec.definition', diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts index e6f51b1..e29281a 100644 --- a/src/commands/intent-based-actions/catalog.test.ts +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -24,58 +24,41 @@ const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -describe('catalog list', () => { +const HUMAN_FIELDS = JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', +]); + +async function parseCatalog(...args: string[]) { + const program = new Command(); + registerCatalogCommands(program); + await program.parseAsync(['node', 'test', 'catalog', ...args]); +} + +describe('catalog', () => { 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', - ]); - + it('list selects default fields for human output and omits them for json', async () => { + await parseCatalog('list', '--kind', 'template'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { instance: undefined, limit: undefined, query: JSON.stringify({ kind: 'template' }), - fields: JSON.stringify([ - 'metadata.name', - 'kind', - 'metadata.namespace', - 'spec.type', - ]), + fields: HUMAN_FIELDS, }, 'human', 'rhdh-cli catalog list --kind Component', undefined, ); - }); - - it('omits default human fields for --output json', async () => { - const program = new Command(); - registerCatalogCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'catalog', - 'list', - '--kind', - 'Component', - '--output', - 'json', - ]); + jest.clearAllMocks(); + await parseCatalog('list', '--kind', 'Component', '--output', 'json'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { @@ -89,38 +72,20 @@ describe('catalog list', () => { undefined, ); }); -}); - -describe('catalog get', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('resolves the entity and calls runRawAction', async () => { + it('get resolves the entity and calls runRawAction', async () => { mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ entityRef: 'component:default/my-service', kind: 'Component', namespace: 'default', name: 'my-service', }); - const program = new Command(); - registerCatalogCommands(program); - await program.parseAsync([ - 'node', - 'test', - 'catalog', - 'get', - 'my-service', - '--kind', - 'Component', - ]); + await parseCatalog('get', 'my-service', '--kind', 'Component'); expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( 'my-service', - expect.objectContaining({ - kindFlag: 'Component', - }), + expect.objectContaining({ kindFlag: 'Component' }), ); expect(mockRunRawAction).toHaveBeenCalledWith( 'catalog:get-catalog-entity', @@ -134,71 +99,40 @@ describe('catalog get', () => { 'rhdh-cli catalog get my-service', ); }); -}); - -describe('catalog validate', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('calls handleCommandError without entity or entity-file', async () => { - const program = new Command(); - registerCatalogCommands(program); - - await program.parseAsync(['node', 'test', 'catalog', 'validate']); - - expect(mockHandleCommandError).toHaveBeenCalledWith( - expect.objectContaining({ - message: '--entity or --entity-file is required', - }), - 'human', + 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', }, - ); - }); -}); - -describe('catalog register', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('calls handleCommandError without --location-url', async () => { - const program = new Command(); - registerCatalogCommands(program); - - await program.parseAsync(['node', 'test', 'catalog', 'register']); - - expect(mockHandleCommandError).toHaveBeenCalledWith( - expect.objectContaining({ message: '--location-url is required' }), - 'human', { + args: ['register'], + message: '--location-url is required', suggestion: 'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml', }, - ); - }); -}); - -describe('catalog unregister', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('calls handleCommandError without location', async () => { - const program = new Command(); - registerCatalogCommands(program); - - await program.parseAsync(['node', 'test', 'catalog', 'unregister']); - - expect(mockHandleCommandError).toHaveBeenCalledWith( - expect.objectContaining({ + { + args: ['unregister'], message: '--location-id or --location-url is required', - }), - 'human', - { suggestion: 'rhdh-cli catalog unregister --location-id ' }, - ); + suggestion: 'rhdh-cli catalog unregister --location-id ', + }, + ]; + + 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/search.test.ts b/src/commands/intent-based-actions/search.test.ts index ce5d12f..7189106 100644 --- a/src/commands/intent-based-actions/search.test.ts +++ b/src/commands/intent-based-actions/search.test.ts @@ -13,26 +13,28 @@ 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', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('passes term + filter/types to runSearchAction', async () => { - const program = new Command(); - registerSearchCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'search', + it('passes term, filters, types, and output mode to runSearchAction', async () => { + await parseSearch( 'my', 'service', '--types', 'techdocs,software-catalog', '--filter', 'kind=Component', - ]); + '--output', + 'json', + ); expect(mockRunSearchAction).toHaveBeenCalledWith( 'my service', @@ -43,58 +45,21 @@ describe('search', () => { pageCursor: undefined, instance: undefined, }, - 'human', + 'json', 'rhdh-cli search "deployment guide" --filter kind=Component', ); }); - it('calls handleCommandError when the search term is empty', async () => { - const program = new Command(); - registerSearchCommands(program); - - await program.parseAsync(['node', 'test', 'search', '']); - + 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"' }, ); - }); - - it('uses json mode for --output json', async () => { - const program = new Command(); - registerSearchCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'search', - 'rhdh', - '--output', - 'json', - ]); - - expect(mockRunSearchAction).toHaveBeenCalledWith( - 'rhdh', - expect.any(Object), - 'json', - 'rhdh-cli search "deployment guide" --filter kind=Component', - ); - }); - - it('calls handleCommandError for an invalid --filter missing =', async () => { - const program = new Command(); - registerSearchCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'search', - 'rhdh', - '--filter', - 'kind', - ]); + jest.clearAllMocks(); + await parseSearch('rhdh', '--filter', 'kind'); expect(mockHandleCommandError).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('Invalid "key=value" pair'), diff --git a/src/commands/intent-based-actions/template.test.ts b/src/commands/intent-based-actions/template.test.ts index 4af3e12..f833b39 100644 --- a/src/commands/intent-based-actions/template.test.ts +++ b/src/commands/intent-based-actions/template.test.ts @@ -27,17 +27,19 @@ const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -describe('template list', () => { +async function parseTemplate(...args: string[]) { + const program = new Command(); + registerTemplateCommands(program); + await program.parseAsync(['node', 'test', 'template', ...args]); +} + +describe('template', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('requests only the fields rendered in human output', async () => { - const program = new Command(); - registerTemplateCommands(program); - - await program.parseAsync(['node', 'test', 'template', 'list']); - + it('list selects human table fields and omits them for json', async () => { + await parseTemplate('list'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { @@ -53,21 +55,9 @@ describe('template list', () => { }, 'human', ); - }); - - it('does not set fields for --output json', async () => { - const program = new Command(); - registerTemplateCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'template', - 'list', - '--output', - 'json', - ]); + jest.clearAllMocks(); + await parseTemplate('list', '--output', 'json'); expect(mockRunEntityListAction).toHaveBeenCalledWith( 'catalog:query-catalog-entities', { @@ -79,33 +69,16 @@ describe('template list', () => { 'json', ); }); -}); -describe('template execute', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('resolves the ref and calls runRawAction with scaffolder:execute-template', async () => { + it('execute resolves the ref and dry-run reads YAML or rejects missing file', async () => { mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ entityRef: 'template:default/react-ssr', kind: 'Template', namespace: 'default', name: 'react-ssr', }); - const program = new Command(); - registerTemplateCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'template', - 'execute', - 'react-ssr', - '--value', - 'name=my-app', - ]); + await parseTemplate('execute', 'react-ssr', '--value', 'name=my-app'); expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( 'react-ssr', expect.objectContaining({ defaultKind: 'template' }), @@ -121,20 +94,9 @@ describe('template execute', () => { 'human', 'rhdh-cli template list', ); - }); -}); -describe('template dry-run', () => { - beforeEach(() => { jest.clearAllMocks(); - }); - - it('calls handleCommandError without --template-file', async () => { - const program = new Command(); - registerTemplateCommands(program); - - await program.parseAsync(['node', 'test', 'template', 'dry-run']); - + await parseTemplate('dry-run'); expect(mockHandleCommandError).toHaveBeenCalledWith( expect.objectContaining({ message: '--template-file is required' }), 'human', @@ -143,9 +105,7 @@ describe('template dry-run', () => { 'rhdh-cli template dry-run --template-file ./template.yaml --value name=my-app', }, ); - }); - it('reads YAML from --template-file and calls scaffolder:dry-run-template', async () => { const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-template-')); const templateFile = join(dir, 'template.yaml'); const yaml = [ @@ -158,20 +118,14 @@ describe('template dry-run', () => { ].join('\n'); writeFileSync(templateFile, yaml); - const program = new Command(); - registerTemplateCommands(program); - - await program.parseAsync([ - 'node', - 'test', - 'template', + jest.clearAllMocks(); + await parseTemplate( 'dry-run', '--template-file', templateFile, '--value', 'name=demo', - ]); - + ); expect(mockRunRawAction).toHaveBeenCalledWith( 'scaffolder:dry-run-template', { From f6b5a6630edf3330bd75cdc2a8abe1b42c8d2edc Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 23 Sep 2026 16:05:37 +0530 Subject: [PATCH 3/7] test(RHIDP-14570): add mocked client integration for intent CLI Exercise real commander/helpers/format paths with client mocked so catalog/api/search/docs/template cover integration ACs without live RHDH. Slim overlapping unit happy-path tests to validation-only cases. Co-authored-by: Cursor --- src/commands/intent-based-actions/api.test.ts | 89 +----- .../intent-based-actions/catalog.test.ts | 82 +---- .../intent-commands.integration.test.ts | 279 ++++++++++++++++++ .../intent-based-actions/search.test.ts | 32 +- .../intent-based-actions/template.test.ts | 119 +------- 5 files changed, 294 insertions(+), 307 deletions(-) create mode 100644 src/commands/intent-based-actions/intent-commands.integration.test.ts diff --git a/src/commands/intent-based-actions/api.test.ts b/src/commands/intent-based-actions/api.test.ts index ae50655..cceaedf 100644 --- a/src/commands/intent-based-actions/api.test.ts +++ b/src/commands/intent-based-actions/api.test.ts @@ -1,19 +1,13 @@ import { Command } from 'commander'; import { registerApiCommands } from './api'; import { execAction } from './client'; -import { - resolveEntityWithAmbiguityCheck, - runEntityListAction, -} from './helpers'; +import { resolveEntityWithAmbiguityCheck } from './helpers'; import { handleCommandError } from './intent-errors'; jest.mock('./helpers'); jest.mock('./client'); jest.mock('./intent-errors'); -const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< - typeof runEntityListAction ->; const mockResolveEntityWithAmbiguityCheck = resolveEntityWithAmbiguityCheck as jest.MockedFunction< typeof resolveEntityWithAmbiguityCheck @@ -23,83 +17,12 @@ const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -async function parseApi(...args: string[]) { - const program = new Command(); - registerApiCommands(program); - await program.parseAsync(['node', 'test', 'api', ...args]); -} - -describe('api', () => { +describe('api get-spec validation', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('lists API entities and applies optional --type', async () => { - await parseApi('list'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - query: JSON.stringify({ kind: 'API' }), - instance: undefined, - limit: undefined, - }, - 'human', - 'rhdh-cli api list', - ); - - jest.clearAllMocks(); - await parseApi('list', '--type', 'openapi'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - query: JSON.stringify({ kind: 'API', 'spec.type': 'openapi' }), - instance: undefined, - limit: undefined, - }, - 'human', - 'rhdh-cli api list', - ); - }); - - it('get-spec resolves the entity and writes definition or errors', async () => { - mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ - entityRef: 'api:default/petstore', - kind: 'API', - namespace: 'default', - name: 'petstore', - }); - mockExecAction.mockReturnValue( - JSON.stringify({ - spec: { type: 'openapi', definition: 'openapi: 3.0.0' }, - }), - ); - - const writeSpy = jest - .spyOn(process.stdout, 'write') - .mockImplementation(() => true); - - await parseApi('get-spec', 'petstore'); - expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( - 'petstore', - expect.objectContaining({ defaultKind: 'api' }), - ); - expect(mockExecAction).toHaveBeenCalledWith('catalog:get-catalog-entity', { - name: 'petstore', - kind: 'API', - namespace: 'default', - instance: undefined, - }); - expect(writeSpy).toHaveBeenCalledWith('openapi: 3.0.0\n'); - - writeSpy.mockClear(); - await parseApi('get-spec', 'petstore', '--output', 'json'); - expect(JSON.parse(writeSpy.mock.calls[0][0] as string)).toEqual({ - name: 'petstore', - type: 'openapi', - definition: 'openapi: 3.0.0', - }); - writeSpy.mockRestore(); - + it('errors when the entity has no spec.definition', async () => { mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ entityRef: 'api:default/empty', kind: 'API', @@ -109,7 +32,11 @@ describe('api', () => { mockExecAction.mockReturnValue( JSON.stringify({ spec: { type: 'openapi' } }), ); - await parseApi('get-spec', 'empty'); + + const program = new Command(); + registerApiCommands(program); + await program.parseAsync(['node', 'test', 'api', 'get-spec', 'empty']); + expect(mockHandleCommandError).toHaveBeenCalledWith( expect.objectContaining({ message: 'API "empty" has no spec.definition', diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts index e29281a..9b6de28 100644 --- a/src/commands/intent-based-actions/catalog.test.ts +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -1,105 +1,25 @@ import { Command } from 'commander'; import { registerCatalogCommands } from './catalog'; -import { - resolveEntityWithAmbiguityCheck, - runEntityListAction, - runRawAction, -} from './helpers'; import { handleCommandError } from './intent-errors'; jest.mock('./helpers'); jest.mock('./intent-errors'); -const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< - typeof runEntityListAction ->; -const mockRunRawAction = runRawAction as jest.MockedFunction< - typeof runRawAction ->; -const mockResolveEntityWithAmbiguityCheck = - resolveEntityWithAmbiguityCheck as jest.MockedFunction< - typeof resolveEntityWithAmbiguityCheck - >; const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -const HUMAN_FIELDS = JSON.stringify([ - 'metadata.name', - 'kind', - 'metadata.namespace', - 'spec.type', -]); - async function parseCatalog(...args: string[]) { const program = new Command(); registerCatalogCommands(program); await program.parseAsync(['node', 'test', 'catalog', ...args]); } -describe('catalog', () => { +describe('catalog command validation', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('list selects default fields for human output and omits them for json', async () => { - await parseCatalog('list', '--kind', 'template'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - instance: undefined, - limit: undefined, - query: JSON.stringify({ kind: 'template' }), - fields: HUMAN_FIELDS, - }, - 'human', - 'rhdh-cli catalog list --kind Component', - undefined, - ); - - jest.clearAllMocks(); - await parseCatalog('list', '--kind', 'Component', '--output', 'json'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - instance: undefined, - limit: undefined, - query: JSON.stringify({ kind: 'Component' }), - fields: undefined, - }, - 'json', - 'rhdh-cli catalog list --kind Component', - undefined, - ); - }); - - it('get resolves the entity and calls runRawAction', async () => { - mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ - entityRef: 'component:default/my-service', - kind: 'Component', - namespace: 'default', - name: 'my-service', - }); - - await parseCatalog('get', 'my-service', '--kind', 'Component'); - - expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( - 'my-service', - expect.objectContaining({ kindFlag: 'Component' }), - ); - expect(mockRunRawAction).toHaveBeenCalledWith( - 'catalog:get-catalog-entity', - { - name: 'my-service', - kind: 'Component', - namespace: 'default', - instance: undefined, - }, - 'human', - 'rhdh-cli catalog get my-service', - ); - }); - it('rejects validate/register/unregister without required inputs', async () => { const cases: Array<{ args: string[]; 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..11ea4f4 --- /dev/null +++ b/src/commands/intent-based-actions/intent-commands.integration.test.ts @@ -0,0 +1,279 @@ +/** + * 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', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ + query: JSON.stringify({ kind: 'Component' }), + 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('api list and get-spec exercise query + definition extraction', 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'); + }); + + 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', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'search:query', + expect.objectContaining({ + term: 'payment', + filters: JSON.stringify({ kind: 'Component' }), + }), + ); + 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('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 }), + ); + }); +}); \ No newline at end of file diff --git a/src/commands/intent-based-actions/search.test.ts b/src/commands/intent-based-actions/search.test.ts index 7189106..1678b17 100644 --- a/src/commands/intent-based-actions/search.test.ts +++ b/src/commands/intent-based-actions/search.test.ts @@ -1,14 +1,10 @@ import { Command } from 'commander'; -import { runSearchAction } from './helpers'; import { handleCommandError } from './intent-errors'; import { registerSearchCommands } from './search'; jest.mock('./helpers'); jest.mock('./intent-errors'); -const mockRunSearchAction = runSearchAction as jest.MockedFunction< - typeof runSearchAction ->; const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; @@ -19,37 +15,11 @@ async function parseSearch(...args: string[]) { await program.parseAsync(['node', 'test', 'search', ...args]); } -describe('search', () => { +describe('search command validation', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('passes term, filters, types, and output mode to runSearchAction', async () => { - await parseSearch( - 'my', - 'service', - '--types', - 'techdocs,software-catalog', - '--filter', - 'kind=Component', - '--output', - 'json', - ); - - expect(mockRunSearchAction).toHaveBeenCalledWith( - 'my service', - { - types: JSON.stringify(['techdocs', 'software-catalog']), - filters: JSON.stringify({ kind: 'Component' }), - pageLimit: undefined, - pageCursor: undefined, - instance: undefined, - }, - 'json', - 'rhdh-cli search "deployment guide" --filter kind=Component', - ); - }); - it('rejects an empty term and an invalid --filter', async () => { await parseSearch(''); expect(mockHandleCommandError).toHaveBeenCalledWith( diff --git a/src/commands/intent-based-actions/template.test.ts b/src/commands/intent-based-actions/template.test.ts index f833b39..3196f77 100644 --- a/src/commands/intent-based-actions/template.test.ts +++ b/src/commands/intent-based-actions/template.test.ts @@ -1,102 +1,24 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { Command } from 'commander'; -import { - resolveEntityWithAmbiguityCheck, - runEntityListAction, - runRawAction, -} 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 mockRunRawAction = runRawAction as jest.MockedFunction< - typeof runRawAction ->; -const mockResolveEntityWithAmbiguityCheck = - resolveEntityWithAmbiguityCheck as jest.MockedFunction< - typeof resolveEntityWithAmbiguityCheck - >; const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; -async function parseTemplate(...args: string[]) { - const program = new Command(); - registerTemplateCommands(program); - await program.parseAsync(['node', 'test', 'template', ...args]); -} - -describe('template', () => { +describe('template command validation', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('list selects human table fields and omits them for json', async () => { - await parseTemplate('list'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - query: JSON.stringify({ kind: 'Template' }), - instance: undefined, - limit: undefined, - fields: JSON.stringify([ - 'metadata.name', - 'kind', - 'metadata.namespace', - 'spec.type', - ]), - }, - 'human', - ); + it('rejects dry-run without --template-file', async () => { + const program = new Command(); + registerTemplateCommands(program); + await program.parseAsync(['node', 'test', 'template', 'dry-run']); - jest.clearAllMocks(); - await parseTemplate('list', '--output', 'json'); - expect(mockRunEntityListAction).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - { - query: JSON.stringify({ kind: 'Template' }), - instance: undefined, - limit: undefined, - fields: undefined, - }, - 'json', - ); - }); - - it('execute resolves the ref and dry-run reads YAML or rejects missing file', async () => { - mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ - entityRef: 'template:default/react-ssr', - kind: 'Template', - namespace: 'default', - name: 'react-ssr', - }); - - await parseTemplate('execute', 'react-ssr', '--value', 'name=my-app'); - expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( - 'react-ssr', - expect.objectContaining({ defaultKind: 'template' }), - ); - expect(mockRunRawAction).toHaveBeenCalledWith( - 'scaffolder:execute-template', - { - templateRef: 'template:default/react-ssr', - values: JSON.stringify({ name: 'my-app' }), - secrets: undefined, - instance: undefined, - }, - 'human', - 'rhdh-cli template list', - ); - - jest.clearAllMocks(); - await parseTemplate('dry-run'); expect(mockHandleCommandError).toHaveBeenCalledWith( expect.objectContaining({ message: '--template-file is required' }), 'human', @@ -105,36 +27,5 @@ describe('template', () => { 'rhdh-cli template dry-run --template-file ./template.yaml --value name=my-app', }, ); - - const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-template-')); - const templateFile = join(dir, 'template.yaml'); - const yaml = [ - 'apiVersion: scaffolder.backstage.io/v1beta3', - 'kind: Template', - 'metadata:', - ' name: demo', - 'spec:', - ' steps: []', - ].join('\n'); - writeFileSync(templateFile, yaml); - - jest.clearAllMocks(); - await parseTemplate( - 'dry-run', - '--template-file', - templateFile, - '--value', - 'name=demo', - ); - expect(mockRunRawAction).toHaveBeenCalledWith( - 'scaffolder:dry-run-template', - { - templateYaml: yaml, - values: JSON.stringify({ name: 'demo' }), - instance: undefined, - }, - 'human', - 'rhdh-cli template list', - ); }); }); From 7f49920396b547cac1cb5932d8ee0e5bf688b653 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 23 Sep 2026 16:29:45 +0530 Subject: [PATCH 4/7] fix(RHIDP-14570): prettier integration tests and drop redundant api unit Format intent-commands.integration.test.ts for CI prettier:check, fold missing get-spec.definition coverage into the mocked integration suite, and remove the overlapping api.test.ts. Co-authored-by: Cursor --- src/commands/intent-based-actions/api.test.ts | 48 ------------------- .../intent-commands.integration.test.ts | 26 ++++++++-- 2 files changed, 22 insertions(+), 52 deletions(-) delete mode 100644 src/commands/intent-based-actions/api.test.ts diff --git a/src/commands/intent-based-actions/api.test.ts b/src/commands/intent-based-actions/api.test.ts deleted file mode 100644 index cceaedf..0000000 --- a/src/commands/intent-based-actions/api.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Command } from 'commander'; -import { registerApiCommands } from './api'; -import { execAction } from './client'; -import { resolveEntityWithAmbiguityCheck } from './helpers'; -import { handleCommandError } from './intent-errors'; - -jest.mock('./helpers'); -jest.mock('./client'); -jest.mock('./intent-errors'); - -const mockResolveEntityWithAmbiguityCheck = - resolveEntityWithAmbiguityCheck as jest.MockedFunction< - typeof resolveEntityWithAmbiguityCheck - >; -const mockExecAction = execAction as jest.MockedFunction; -const mockHandleCommandError = handleCommandError as jest.MockedFunction< - typeof handleCommandError ->; - -describe('api get-spec validation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('errors when the entity has no spec.definition', async () => { - mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ - entityRef: 'api:default/empty', - kind: 'API', - namespace: 'default', - name: 'empty', - }); - mockExecAction.mockReturnValue( - JSON.stringify({ spec: { type: 'openapi' } }), - ); - - const program = new Command(); - registerApiCommands(program); - await program.parseAsync(['node', 'test', 'api', 'get-spec', 'empty']); - - expect(mockHandleCommandError).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'API "empty" has no spec.definition', - }), - 'human', - { suggestion: 'rhdh-cli api list' }, - ); - }); -}); diff --git a/src/commands/intent-based-actions/intent-commands.integration.test.ts b/src/commands/intent-based-actions/intent-commands.integration.test.ts index 11ea4f4..f4d991a 100644 --- a/src/commands/intent-based-actions/intent-commands.integration.test.ts +++ b/src/commands/intent-based-actions/intent-commands.integration.test.ts @@ -29,7 +29,10 @@ const SAMPLE_COMPONENT = { const SAMPLE_API = { kind: 'API', metadata: { name: 'payments-api', namespace: 'default' }, - spec: { type: 'openapi', definition: 'openapi: 3.0.0\ninfo:\n title: Payments\n' }, + spec: { + type: 'openapi', + definition: 'openapi: 3.0.0\ninfo:\n title: Payments\n', + }, }; const SAMPLE_TEMPLATE = { @@ -149,7 +152,7 @@ describe('intent commands (mocked client integration)', () => { expect(spyText(io.stdout)).toContain('payments'); }); - it('api list and get-spec exercise query + definition extraction', async () => { + 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( @@ -175,6 +178,20 @@ describe('intent commands (mocked client integration)', () => { 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 () => { @@ -263,7 +280,8 @@ describe('intent commands (mocked client integration)', () => { 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'; + const yaml = + 'apiVersion: scaffolder.backstage.io/v1beta3\nkind: Template\n'; writeFileSync(templateFile, yaml); await runCli(registerTemplateCommands, [ 'template', @@ -276,4 +294,4 @@ describe('intent commands (mocked client integration)', () => { expect.objectContaining({ templateYaml: yaml }), ); }); -}); \ No newline at end of file +}); From eebc94f0ac24dba5d503aa53f108e46aa905d2e5 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Mon, 28 Sep 2026 15:29:59 +0530 Subject: [PATCH 5/7] test(RHIDP-14570): cover instance flag, catalog mutate, docs list/get Address review feedback: assert --instance forwarding, catalog validate/register/unregister action wiring, and docs list/get techdocs-mcp-extras integration paths with a mocked client. Co-authored-by: Cursor --- .../intent-commands.integration.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/src/commands/intent-based-actions/intent-commands.integration.test.ts b/src/commands/intent-based-actions/intent-commands.integration.test.ts index f4d991a..a2bfddb 100644 --- a/src/commands/intent-based-actions/intent-commands.integration.test.ts +++ b/src/commands/intent-based-actions/intent-commands.integration.test.ts @@ -152,6 +152,119 @@ describe('intent commands (mocked client integration)', () => { expect(spyText(io.stdout)).toContain('payments'); }); + it('forwards --instance on catalog list and search', async () => { + mockExecActionJson.mockReturnValue({ items: [SAMPLE_COMPONENT] }); + await runCli(registerCatalogCommands, [ + 'catalog', + 'list', + '--kind', + 'Component', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + expect.objectContaining({ instance: 'staging' }), + ); + + jest.clearAllMocks(); + mockExecActionJson.mockReturnValue({ results: [] }); + await runCli(registerSearchCommands, [ + 'search', + 'payment', + '--instance', + 'staging', + ]); + expect(mockExecActionJson).toHaveBeenCalledWith( + 'search:query', + expect.objectContaining({ + term: 'payment', + instance: 'staging', + }), + ); + }); + + 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']); @@ -247,6 +360,63 @@ describe('intent commands (mocked client integration)', () => { 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']); From 11834802fa9155f06fd696f5d33314f5f70299b6 Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Mon, 28 Sep 2026 15:33:34 +0530 Subject: [PATCH 6/7] test(RHIDP-14570): drop redundant instance and docs unit coverage Fold --instance into existing catalog/search integration cases and remove docs unit tests already covered by mocked client integration. Co-authored-by: Cursor --- .../intent-based-actions/docs.test.ts | 48 +------------------ .../intent-commands.integration.test.ts | 38 +++------------ 2 files changed, 8 insertions(+), 78 deletions(-) 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 index a2bfddb..c815d83 100644 --- a/src/commands/intent-based-actions/intent-commands.integration.test.ts +++ b/src/commands/intent-based-actions/intent-commands.integration.test.ts @@ -97,11 +97,14 @@ describe('intent commands (mocked client integration)', () => { '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', @@ -152,38 +155,6 @@ describe('intent commands (mocked client integration)', () => { expect(spyText(io.stdout)).toContain('payments'); }); - it('forwards --instance on catalog list and search', async () => { - mockExecActionJson.mockReturnValue({ items: [SAMPLE_COMPONENT] }); - await runCli(registerCatalogCommands, [ - 'catalog', - 'list', - '--kind', - 'Component', - '--instance', - 'staging', - ]); - expect(mockExecActionJson).toHaveBeenCalledWith( - 'catalog:query-catalog-entities', - expect.objectContaining({ instance: 'staging' }), - ); - - jest.clearAllMocks(); - mockExecActionJson.mockReturnValue({ results: [] }); - await runCli(registerSearchCommands, [ - 'search', - 'payment', - '--instance', - 'staging', - ]); - expect(mockExecActionJson).toHaveBeenCalledWith( - 'search:query', - expect.objectContaining({ - term: 'payment', - instance: 'staging', - }), - ); - }); - it('catalog validate/register/unregister call catalog actions', async () => { const entityYaml = 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: payments\n'; @@ -324,12 +295,15 @@ describe('intent commands (mocked client integration)', () => { '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'); From 610f75f3be6519b4cf3f789dc0b948b61b790dba Mon Sep 17 00:00:00 2001 From: HusneShabbir Date: Wed, 30 Sep 2026 13:46:54 +0530 Subject: [PATCH 7/7] chore(RHIDP-14570): retrigger CI after plugin-new e2e flake Co-authored-by: Cursor