From b7586b07c2aa6655454bf526c726802990399500 Mon Sep 17 00:00:00 2001 From: Ross Stenersen Date: Thu, 16 Jan 2025 14:36:37 -0600 Subject: [PATCH] refactor: convert capabilities:translations command to yargs --- .../capabilities/translations.test.ts | 111 ---------- .../src/commands/capabilities/translations.ts | 190 ------------------ .../capabilities/presentation.test.ts | 10 +- .../capabilities/translations.test.ts | 157 +++++++++++++++ .../lib/command/capability-flags.test.ts | 10 +- .../command/util/capabilities-choose.test.ts | 42 +++- .../capabilities-translations-table.test.ts | 85 ++++++++ src/commands/capabilities.ts | 4 +- src/commands/capabilities/presentation.ts | 6 +- .../capabilities/presentation/create.ts | 2 +- .../capabilities/presentation/update.ts | 2 +- src/commands/capabilities/translations.ts | 108 ++++++++++ src/commands/index.ts | 2 + src/lib/command/capability-flags.ts | 8 +- src/lib/command/util/capabilities-choose.ts | 47 +++-- .../util/capabilities-translations-table.ts | 46 +++++ temporary-notes.md | 4 + 17 files changed, 494 insertions(+), 340 deletions(-) delete mode 100644 packages/cli/src/__tests__/commands/capabilities/translations.test.ts delete mode 100644 packages/cli/src/commands/capabilities/translations.ts create mode 100644 src/__tests__/commands/capabilities/translations.test.ts create mode 100644 src/__tests__/lib/command/util/capabilities-translations-table.test.ts create mode 100644 src/commands/capabilities/translations.ts create mode 100644 src/lib/command/util/capabilities-translations-table.ts create mode 100644 temporary-notes.md diff --git a/packages/cli/src/__tests__/commands/capabilities/translations.test.ts b/packages/cli/src/__tests__/commands/capabilities/translations.test.ts deleted file mode 100644 index 1166ae6d..00000000 --- a/packages/cli/src/__tests__/commands/capabilities/translations.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { outputItemOrList, selectFromList } from '@smartthings/cli-lib' -import CapabilityTranslationsCommand from '../../../commands/capabilities/translations.js' -import { LocaleReference, CapabilitiesEndpoint } from '@smartthings/core-sdk' - - -describe('CapabilityTranslationsCommand', () => { - const outputItemOrListMock = jest.mocked(outputItemOrList) - const selectFromListMock = jest.mocked(selectFromList).mockResolvedValue({ id: 'switch', version: 1 }) - const locales = [{ tag: 'en' }, { tag: 'ko' }] as LocaleReference[] - const listSpy = jest.spyOn(CapabilitiesEndpoint.prototype, 'listLocales').mockResolvedValue(locales) - const tag = { tag: 'en' } - const getSpy = jest.spyOn(CapabilitiesEndpoint.prototype, 'getTranslations').mockResolvedValue(tag) - - test('list without version', async() => { - outputItemOrListMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction) => { - await listFunction() - }) - - await expect(CapabilityTranslationsCommand.run(['switch'])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(CapabilityTranslationsCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['tag'], - }), - undefined, - expect.any(Function), - expect.any(Function), - true, - ) - expect(listSpy).toHaveBeenCalledTimes(1) - expect(listSpy).toHaveBeenCalledWith('switch', 1) - }) - - test('list with version', async() => { - outputItemOrListMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction) => { - await listFunction() - }) - selectFromListMock.mockResolvedValueOnce({ id: 'switch', version: 2 }) - - await expect(CapabilityTranslationsCommand.run(['switch', '2'])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(CapabilityTranslationsCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['tag'], - }), - undefined, - expect.any(Function), - expect.any(Function), - true, - ) - expect(selectFromList).toHaveBeenCalledWith( - expect.any(CapabilityTranslationsCommand), - expect.any(Object), - expect.objectContaining({ - preselectedId: { id: 'switch', version: '2' }, - }), - ) - expect(listSpy).toHaveBeenCalledTimes(1) - expect(listSpy).toHaveBeenCalledWith('switch', 2) - }) - - test('get with version', async() => { - outputItemOrListMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction, getFunction) => { - await getFunction('en') - }) - - await expect(CapabilityTranslationsCommand.run(['switch', '1', 'en'])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(CapabilityTranslationsCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['tag'], - }), - 'en', - expect.any(Function), - expect.any(Function), - true, - ) - - expect(getSpy).toHaveBeenCalledTimes(1) - expect(getSpy).toHaveBeenCalledWith('switch', 1, 'en') - }) - - test('get without version', async() => { - outputItemOrListMock.mockImplementationOnce(async (_command, _config, _idOrIndex, listFunction, getFunction) => { - await getFunction('ko') - }) - - await expect(CapabilityTranslationsCommand.run(['switch', 'ko'])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(CapabilityTranslationsCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['tag'], - }), - 'ko', - expect.any(Function), - expect.any(Function), - true, - ) - - expect(getSpy).toHaveBeenCalledTimes(1) - expect(getSpy).toHaveBeenCalledWith('switch', 1, 'ko') - }) -}) diff --git a/packages/cli/src/commands/capabilities/translations.ts b/packages/cli/src/commands/capabilities/translations.ts deleted file mode 100644 index 685db5d9..00000000 --- a/packages/cli/src/commands/capabilities/translations.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Flags } from '@oclif/core' - -import { CapabilityLocalization, DeviceProfileTranslations, LocaleReference } from '@smartthings/core-sdk' - -import { APIOrganizationCommand, OutputItemOrListConfig, outputItemOrList, selectFromList, - SelectFromListConfig, TableGenerator, WithLocales } from '@smartthings/cli-lib' - -import { CapabilityId, capabilityIdOrIndexInputArgs, CapabilitySummaryWithNamespace, getCustomByNamespace, - getIdFromUser, translateToId } from '../../lib/commands/capabilities-util.js' - - -export function buildTableOutput(tableGenerator: TableGenerator, data: CapabilityLocalization): string { - let result = `Tag: ${data.tag}` - if (data.attributes) { - const table = tableGenerator.newOutputTable({ head: ['Name', 'Label', 'Description', 'Template'] }) - for (const name of Object.keys(data.attributes)) { - const attr = data.attributes[name] - table.push([name, attr.label, attr.description || '', attr.displayTemplate || '']) - if (attr.i18n?.value) { - for (const key of Object.keys(attr.i18n.value)) { - const entry = attr.i18n.value[key] - table.push([`${name}.${key}`, `${entry ? entry['label'] : ''}`, `${entry ? entry['description'] || '' : ''}`, '']) - } - } - } - result += '\n\nAttributes:\n' + table.toString() - } - if (data.commands) { - const table = tableGenerator.newOutputTable({ head: ['Name', 'Label', 'Description'] }) - for (const name of Object.keys(data.commands)) { - const cmd = data.commands[name] - table.push([name, cmd.label || '', cmd.description || '']) - if (cmd.arguments) { - for (const key of Object.keys(cmd.arguments)) { - const entry = cmd.arguments[key] - table.push([`${name}.${key}`, `${entry ? entry['label'] : ''}`, `${entry ? entry['description'] || '' : ''}`]) - } - } - } - result += '\n\nCommands:\n' + table.toString() - } - return result -} - -export type CapabilitySummaryWithLocales = CapabilitySummaryWithNamespace & WithLocales - -export default class CapabilityTranslationsCommand extends APIOrganizationCommand { - static description = 'get list of locales supported by the capability' + - this.apiDocsURL('listCapabilityLocalizations', 'getCapabilityLocalization') - - static flags = { - ...APIOrganizationCommand.flags, - ...outputItemOrList.flags, - namespace: Flags.string({ - char: 'n', - description: 'a specific namespace to query; will use all by default', - }), - verbose: Flags.boolean({ - description: 'include list of supported locales in table output', - char: 'v', - }), - } - - static args = [ - ...capabilityIdOrIndexInputArgs, - { name: 'tag', description: 'the locale tag' }, - ] - - static examples = [ - '$ smartthings capabilities:translations\n' + - '┌───┬─────────────────────────────┬─────────┬──────────┐\n' + - '│ # │ Id │ Version │ Status │\n' + - '├───┼─────────────────────────────┼─────────┼──────────┤\n' + - '│ 1 │ custom1.outputModulation │ 1 │ proposed │\n' + - '│ 2 │ custom1.outputVoltage │ 1 │ proposed │\n' + - '└───┴─────────────────────────────┴─────────┴──────────┘', - '? Select a capability. 1\n' + - '┌───┬─────┐\n' + - '│ # │ Tag │\n' + - '├───┼─────┤\n' + - '│ 1 │ en │\n' + - '│ 2 │ ko │\n' + - '└───┴─────┘', - '$ st capabilities:translations -v\n' + - '┌───┬─────────────────────────────┬─────────┬──────────┬────────────┐\n' + - '│ # │ Id │ Version │ Status │ Locales │\n' + - '├───┼─────────────────────────────┼─────────┼──────────┼────────────┤\n' + - '│ 1 │ custom1.outputModulation │ 1 │ proposed │ ko, en, es │\n' + - '│ 2 │ custom1.outputVoltage │ 1 │ proposed │ en │\n' + - '└───┴─────────────────────────────┴─────────┴──────────┴────────────┘', - '? Select a capability. 1\n' + - '┌───┬─────┐\n' + - '│ # │ Tag │\n' + - '├───┼─────┤\n' + - '│ 1 │ en │\n' + - '│ 1 │ es │\n' + - '│ 2 │ ko │\n' + - '└───┴─────┘', - '$ smartthings capabilities:translations 1\n' + - '$ smartthings capabilities:translations custom1.outputModulation\n' + - '┌───┬─────┐\n' + - '│ # │ Tag │\n' + - '├───┼─────┤\n' + - '│ 1 │ en │\n' + - '│ 2 │ ko │\n' + - '└───┴─────┘', - '$ smartthings capabilities:translations 1 1\n' + - '$ smartthings capabilities:translations 1 en\n' + - '$ smartthings capabilities:translations custom1.outputModulation 1 1\n' + - '$ smartthings capabilities:translations custom1.outputModulation 1 en\n' + - '$ smartthings capabilities:translations custom1.outputModulation en', - 'Tag: en', - 'Attributes:\n' + - '┌────────────────────────┬───────────────────┬────────────────────────────────┬────────────────────────────────────────────────────┐\n' + - '│ Name │ Label │ Description │ Template │\n' + - '├────────────────────────┼───────────────────┼────────────────────────────────┼────────────────────────────────────────────────────┤\n' + - '│ outputModulation │ Output Modulation │ Power supply output modulation │ The {{attribute}} of {{device.label}} is {{value}} │\n' + - '│ outputModulation.50hz │ 50 Hz │ │ │\n' + - '│ outputModulation.60hz │ 60 Hz │ │ │\n' + - '└────────────────────────┴───────────────────┴────────────────────────────────┴────────────────────────────────────────────────────┘', - 'Commands\n' + - '┌──────────────────────────────────────┬───────────────────────┬──────────────────────────────────────────────────┐\n' + - '│ Name │ Label │ Description │\n' + - '├──────────────────────────────────────┼───────────────────────┼──────────────────────────────────────────────────┤\n' + - '│ setOutputModulation │ Set Output Modulation │ Set the output modulation to the specified value │\n' + - '│ setOutputModulation.outputModulation │ Output Modulation │ The desired output modulation │\n' + - '└──────────────────────────────────────┴───────────────────────┴──────────────────────────────────────────────────┘', - ] - - async run(): Promise { - const primaryKeyName = 'id' - const capConfig: SelectFromListConfig = { - primaryKeyName: 'id', - listTableFieldDefinitions: ['id', 'version', 'status'], - } - if (this.flags.verbose) { - capConfig.listTableFieldDefinitions.splice(3, 0, 'locales') - } - const listItems = async (): Promise => { - const capabilities = await getCustomByNamespace(this.client, this.flags.namespace) - if (this.flags.verbose) { - const ops = capabilities.map(it => this.client.capabilities.listLocales(it.id, it.version)) - const locales = await Promise.all(ops) - return capabilities.map((it, index) => { - return { ...it, locales: locales[index].map(it => it.tag).sort().join(', ') } - }) - } - return capabilities - } - - let preselectedId: CapabilityId | undefined = undefined - let preselectedTag: string | undefined = undefined - if (this.args.tag) { - // capabilityId, capabilityVersion, tag - preselectedId = { id: this.args.id, version: this.args.version } - preselectedTag = this.args.tag - } else if (this.args.version) { - if (isNaN(this.args.id) && !isNaN(this.args.version)) { - // capabilityId, capabilityVersion, no tag specified - preselectedId = { id: this.args.id, version: this.args.version } - } else { - // capability id or index, no capability version specified, tag specified - preselectedId = await translateToId(primaryKeyName, this.args.id, listItems) - preselectedTag = this.args.version - } - } else { - // capability id or index, no tag specified - preselectedId = await translateToId(primaryKeyName, this.args.id, listItems) - } - - const capabilityId = await selectFromList(this, capConfig, { - preselectedId, - listItems, - getIdFromUser, - promptMessage: 'Select a capability.', - }) - - const config: OutputItemOrListConfig = { - primaryKeyName: 'tag', - sortKeyName: 'tag', - listTableFieldDefinitions: ['tag'], - buildTableOutput: data => buildTableOutput(this.tableGenerator, data), - } - - await outputItemOrList(this, config, preselectedTag, - () => this.client.capabilities.listLocales(capabilityId.id, capabilityId.version), - tag => this.client.capabilities.getTranslations(capabilityId.id, capabilityId.version, tag), - true) - } -} diff --git a/src/__tests__/commands/capabilities/presentation.test.ts b/src/__tests__/commands/capabilities/presentation.test.ts index 6c778d16..ba717eb7 100644 --- a/src/__tests__/commands/capabilities/presentation.test.ts +++ b/src/__tests__/commands/capabilities/presentation.test.ts @@ -112,7 +112,7 @@ describe('handler', () => { expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(defaultInputArgv) expect(chooseCapabilityMock) - .toHaveBeenCalledExactlyOnceWith(command, undefined, undefined, undefined, undefined, { allowIndex: true }) + .toHaveBeenCalledExactlyOnceWith(command, undefined, undefined, { allowIndex: true }) expect(apiCapabilitiesGetPresentationMock).toHaveBeenCalledExactlyOnceWith('chosen-id', 3) expect(formatAndWriteItemMock).toHaveBeenCalledExactlyOnceWith( command, @@ -139,7 +139,11 @@ describe('handler', () => { namespace: 'namespace', })).resolves.not.toThrow() - expect(chooseCapabilityMock) - .toHaveBeenCalledExactlyOnceWith(command, 'cmd-line-id', 13, undefined, 'namespace', { allowIndex: true }) + expect(chooseCapabilityMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-id', + 13, + { namespace: 'namespace', allowIndex: true }, + ) }) }) diff --git a/src/__tests__/commands/capabilities/translations.test.ts b/src/__tests__/commands/capabilities/translations.test.ts new file mode 100644 index 00000000..4c4b8188 --- /dev/null +++ b/src/__tests__/commands/capabilities/translations.test.ts @@ -0,0 +1,157 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type { CapabilitiesEndpoint, DeviceProfileTranslations, LocaleReference } from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../commands/capabilities/translations.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../../lib/command/api-organization-command.js' +import type { capabilityIdOrIndexBuilder } from '../../../lib/command/capability-flags.js' +import type { AllOrganizationFlags } from '../../../lib/command/common-flags.js' +import type { CustomCommonOutputProducer } from '../../../lib/command/format.js' +import type { + outputItemOrList, + outputItemOrListBuilder, +} from '../../../lib/command/listing-io.js' +import type { chooseCapability } from '../../../lib/command/util/capabilities-choose.js' +import type { buildTableOutput } from '../../../lib/command/util/capabilities-translations-table.js' +import { apiCommandMocks } from '../../test-lib/api-command-mock.js' +import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' +import { tableGeneratorMock } from '../../test-lib/table-mock.js' + + +const { apiDocsURLMock } = apiCommandMocks('../../..') + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const capabilityIdOrIndexBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/capability-flags.js', () => ({ + capabilityIdOrIndexBuilder: capabilityIdOrIndexBuilderMock, +})) + +const outputItemOrListMock = jest.fn>() +const outputItemOrListBuilderMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/listing-io.js', () => ({ + outputItemOrList: outputItemOrListMock, + outputItemOrListBuilder: outputItemOrListBuilderMock, +})) + +const chooseCapabilityMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/util/capabilities-choose.js', () => ({ + chooseCapability: chooseCapabilityMock, +})) + +const buildTableOutputMock = jest.fn() +jest.unstable_mockModule('../../../lib/command/util/capabilities-translations-table.js', () => ({ + buildTableOutput: buildTableOutputMock, +})) + + +const { default: cmd } = await import('../../../commands/capabilities/translations.js') + + +test('builder', () => { + const yargsMock = buildArgvMockStub() + const apiOrganizationCommandBuilderArgvMock = buildArgvMockStub() + const { + yargsMock: capabilityIdOrIndexBuilderArgvMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) + capabilityIdOrIndexBuilderMock.mockReturnValueOnce(capabilityIdOrIndexBuilderArgvMock) + outputItemOrListBuilderMock.mockReturnValueOnce(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(capabilityIdOrIndexBuilderMock) + .toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) + expect(outputItemOrListBuilderMock) + .toHaveBeenCalledExactlyOnceWith(capabilityIdOrIndexBuilderArgvMock) + + expect(optionMock).toHaveBeenCalledTimes(2) + expect(epilogMock).toHaveBeenCalledTimes(1) + expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) +}) + +test('handler', async () => { + const inputArgv = { + profile: 'default', + idOrIndex: 'argv-capability-id', + capabilityVersion: 11, + verbose: true, + namespace: 'argv-namespace', + tag: 'argv-tag', + } as ArgumentsCamelCase + const apiCapabilitiesGetTranslationsMock = jest.fn() + const apiCapabilitiesListLocalesMock = jest.fn() + const command = { + tableGenerator: tableGeneratorMock, + client: { + capabilities: { + getTranslations: apiCapabilitiesGetTranslationsMock, + listLocales: apiCapabilitiesListLocalesMock, + }, + }, + } as unknown as APIOrganizationCommand> + + apiOrganizationCommandMock.mockResolvedValueOnce(command) + chooseCapabilityMock.mockResolvedValueOnce({ id: 'chosen-id', version: 13 }) + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseCapabilityMock).toHaveBeenCalledExactlyOnceWith( + command, + 'argv-capability-id', + 11, + { namespace: 'argv-namespace', allowIndex: true, verbose: true }, + ) + expect(outputItemOrListMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({}), + 'argv-tag', + expect.any(Function), + expect.any(Function), + true, + ) + + const translations = { tag: 'es_MX' } as DeviceProfileTranslations + + buildTableOutputMock.mockReturnValueOnce('table output') + const config = outputItemOrListMock.mock.calls[0][1] as CustomCommonOutputProducer + + expect(config.buildTableOutput(translations)).toBe('table output') + + expect(buildTableOutputMock).toHaveBeenCalledExactlyOnceWith(tableGeneratorMock, translations) + + const listFunction = outputItemOrListMock.mock.calls[0][3] + const locales: LocaleReference[] = [{ tag: 'en_US' }, { tag: 'fr_FR' }] + apiCapabilitiesListLocalesMock.mockResolvedValueOnce(locales) + + expect(await listFunction()).toBe(locales) + + const getFunction = outputItemOrListMock.mock.calls[0][4] + apiCapabilitiesGetTranslationsMock.mockResolvedValueOnce(translations) + + expect(await getFunction('no_NO')).toBe(translations) + + expect(apiCapabilitiesGetTranslationsMock).toHaveBeenCalledExactlyOnceWith('chosen-id', 13, 'no_NO') +}) diff --git a/src/__tests__/lib/command/capability-flags.test.ts b/src/__tests__/lib/command/capability-flags.test.ts index ce17daf3..8a36f9bd 100644 --- a/src/__tests__/lib/command/capability-flags.test.ts +++ b/src/__tests__/lib/command/capability-flags.test.ts @@ -8,17 +8,19 @@ const { test('lambdaAuthBuilder', () => { - const { argvMock, positionalMock } = buildArgvMock() + const { argvMock, optionMock, positionalMock } = buildArgvMock() expect(capabilityIdBuilder(argvMock)).toBe(argvMock) - expect(positionalMock).toHaveBeenCalledTimes(2) + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(1) }) test('allOrganizationsBuilder', () => { - const { argvMock, positionalMock } = buildArgvMock() + const { argvMock, optionMock, positionalMock } = buildArgvMock() expect(capabilityIdOrIndexBuilder(argvMock)).toBe(argvMock) - expect(positionalMock).toHaveBeenCalledTimes(2) + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(1) }) diff --git a/src/__tests__/lib/command/util/capabilities-choose.test.ts b/src/__tests__/lib/command/util/capabilities-choose.test.ts index f8321cae..b9b82114 100644 --- a/src/__tests__/lib/command/util/capabilities-choose.test.ts +++ b/src/__tests__/lib/command/util/capabilities-choose.test.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals' import type inquirer from 'inquirer' -import type { SmartThingsClient } from '@smartthings/core-sdk' +import { CapabilitiesEndpoint, type SmartThingsClient } from '@smartthings/core-sdk' import type { APICommand } from '../../../../lib/command/api-command.js' import type { selectFromList, SelectFromListFlags } from '../../../../lib/command/select.js' @@ -129,7 +129,12 @@ describe('getIdFromUser', () => { const selectedCapabilityId = { id: 'selected-capability-id', version: 1 } selectFromListMock.mockResolvedValue(selectedCapabilityId) -const client = {} as unknown as SmartThingsClient +const apiCapabilitiesListLocalesMock = jest.fn() +const client = { + capabilities: { + listLocales: apiCapabilitiesListLocalesMock, + }, +} as unknown as SmartThingsClient const command = { client } as APICommand describe('chooseCapability', () => { @@ -151,7 +156,7 @@ describe('chooseCapability', () => { it('translates id from index if `allowIndex` is specified', async () => { translateToIdMock.mockResolvedValueOnce({ id: 'translated-id', version: 13 }) - expect(await chooseCapability(command, '13', undefined, undefined, undefined, { allowIndex: true })) + expect(await chooseCapability(command, '13', undefined, { allowIndex: true })) .toBe(selectedCapabilityId) expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( @@ -197,7 +202,8 @@ describe('chooseCapability', () => { }) it('passes on promptMessage', async () => { - expect(await chooseCapability(command, undefined, undefined, 'user prompt')).toBe(selectedCapabilityId) + expect(await chooseCapability(command, undefined, undefined, { promptMessage: 'user prompt' })) + .toBe(selectedCapabilityId) expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( command, @@ -212,7 +218,8 @@ describe('chooseCapability', () => { }) it('passes on namespace', async () => { - expect(await chooseCapability(command, undefined, undefined, undefined, 'namespace')).toBe(selectedCapabilityId) + expect(await chooseCapability(command, undefined, undefined, { namespace: 'namespace' })) + .toBe(selectedCapabilityId) expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( command, @@ -250,4 +257,29 @@ describe('chooseCapability', () => { expect(getCustomByNamespaceMock).toHaveBeenCalledExactlyOnceWith(client, undefined) }) + + it('includes list of locales in verbose mode', async () => { + expect(await chooseCapability(command, undefined, undefined, { verbose: true })).toBe(selectedCapabilityId) + + expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ listTableFieldDefinitions: expect.arrayContaining(['locales']) }), + expect.objectContaining({}), + ) + + getCustomByNamespaceMock.mockResolvedValueOnce(capabilities) + const listItems = selectFromListMock.mock.calls[0][2].listItems + apiCapabilitiesListLocalesMock.mockResolvedValueOnce([{ tag: 'es' }, { tag: 'en' }]) + apiCapabilitiesListLocalesMock.mockResolvedValueOnce([]) + const capabilitiesWithLocales = [ + { ...capabilities[0], locales: 'en, es' }, + { ...capabilities[1], locales: '' }, + ] + + expect(await listItems()).toStrictEqual(capabilitiesWithLocales) + + expect(apiCapabilitiesListLocalesMock).toHaveBeenCalledTimes(2) + expect(apiCapabilitiesListLocalesMock).toHaveBeenCalledWith('capability-1', 1) + expect(apiCapabilitiesListLocalesMock).toHaveBeenCalledWith('capability-2', 1) + }) }) diff --git a/src/__tests__/lib/command/util/capabilities-translations-table.test.ts b/src/__tests__/lib/command/util/capabilities-translations-table.test.ts new file mode 100644 index 00000000..a90f21e5 --- /dev/null +++ b/src/__tests__/lib/command/util/capabilities-translations-table.test.ts @@ -0,0 +1,85 @@ +import type { CapabilityLocalization } from '@smartthings/core-sdk' + +import { mockedTableOutput, newOutputTableMock, tableGeneratorMock, tablePushMock } from '../../../test-lib/table-mock.js' + + +const { buildTableOutput } = await import('../../../../lib/command/util/capabilities-translations-table.js') + +describe('buildTableGenerator', () => { + it('includes tag name', () => { + expect(buildTableOutput(tableGeneratorMock, { tag: 'es_US' })).toBe('Tag: es_US') + + expect(newOutputTableMock).not.toHaveBeenCalled() + }) + + it('includes attributes', () => { + const data: CapabilityLocalization = { tag: 'es_US', attributes: { + key1: { label: 'label1', i18n: {} }, + key2: { label: 'label2', description: 'description 2', displayTemplate: 'display template 2' }, + key3: { description: 'description 3' }, + } } + + expect(buildTableOutput(tableGeneratorMock, data)).toBe(`Tag: es_US\n\nAttributes:\n${mockedTableOutput}`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith({ head: expect.arrayContaining(['Name']) }) + expect(tablePushMock).toHaveBeenCalledTimes(3) + expect(tablePushMock).toHaveBeenCalledWith(['key1', 'label1', undefined, undefined]) + expect(tablePushMock).toHaveBeenCalledWith(['key2', 'label2', 'description 2', 'display template 2']) + expect(tablePushMock).toHaveBeenCalledWith(['key3', undefined, 'description 3', undefined]) + }) + + it('includes attribute entries', () => { + const data: CapabilityLocalization = { tag: 'es_US', attributes: { + key1: { + label: 'label1', + i18n: { value: { + entry1: { label: 'entry1 label' }, + entry2: { label: 'entry2 label', description: 'entry2 description' }, + } }, + }, + } } + + expect(buildTableOutput(tableGeneratorMock, data)).toBe(`Tag: es_US\n\nAttributes:\n${mockedTableOutput}`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith({ head: expect.arrayContaining(['Name']) }) + expect(tablePushMock).toHaveBeenCalledTimes(3) + expect(tablePushMock).toHaveBeenCalledWith(['key1', 'label1', undefined, undefined]) + expect(tablePushMock).toHaveBeenCalledWith(['key1.entry1', 'entry1 label', undefined, '']) + expect(tablePushMock).toHaveBeenCalledWith(['key1.entry2', 'entry2 label', 'entry2 description', '']) + }) + + it('includes commands', () => { + const data: CapabilityLocalization = { tag: 'es_US', commands: { + key1: { label: 'label1' }, + key2: { label: 'label2', description: 'description 2' }, + key3: { description: 'description 3' }, + } } + + expect(buildTableOutput(tableGeneratorMock, data)).toBe(`Tag: es_US\n\nCommands:\n${mockedTableOutput}`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith({ head: expect.arrayContaining(['Name']) }) + expect(tablePushMock).toHaveBeenCalledTimes(3) + expect(tablePushMock).toHaveBeenCalledWith(['key1', 'label1', undefined]) + expect(tablePushMock).toHaveBeenCalledWith(['key2', 'label2', 'description 2']) + expect(tablePushMock).toHaveBeenCalledWith(['key3', undefined, 'description 3']) + }) + + it('includes command arguments', () => { + const data: CapabilityLocalization = { tag: 'es_US', commands: { + key1: { label: 'label1', arguments: { + arg1: { label: 'label1', description: 'description 1' }, + arg2: { label: 'label2' }, + arg3: { description: 'description 3' }, + } }, + } } + + expect(buildTableOutput(tableGeneratorMock, data)).toBe(`Tag: es_US\n\nCommands:\n${mockedTableOutput}`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith({ head: expect.arrayContaining(['Name']) }) + expect(tablePushMock).toHaveBeenCalledTimes(4) + expect(tablePushMock).toHaveBeenCalledWith(['key1', 'label1', undefined]) + expect(tablePushMock).toHaveBeenCalledWith(['key1.arg1', 'label1', 'description 1']) + expect(tablePushMock).toHaveBeenCalledWith(['key1.arg2', 'label2', undefined]) + expect(tablePushMock).toHaveBeenCalledWith(['key1.arg3', undefined, 'description 3']) + }) +}) diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 5fb3c934..c4619463 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -40,7 +40,7 @@ export type CommandArgs = standard: boolean } -const command = 'capabilities [id-or-index] [capability-version]' +const command = 'capabilities [id-or-index]' const describe = 'get a specific capability or a list of capabilities' @@ -67,7 +67,7 @@ export const builder = (yargs: Argv): Argv => ' "smartthings capabilities"', ], [ - '$0 capabilities 5dfd6626-ab1d-42da-bb76-90def3153998', + '$0 capabilities cathappy12345.myCapability', 'display details for a capability by id', ], [ diff --git a/src/commands/capabilities/presentation.ts b/src/commands/capabilities/presentation.ts index eac54339..deba8e42 100644 --- a/src/commands/capabilities/presentation.ts +++ b/src/commands/capabilities/presentation.ts @@ -27,7 +27,7 @@ export type CommandArgs = namespace?: string } -const command = 'capabilities:presentation [id-or-index] [capability-version]' +const command = 'capabilities:presentation [id-or-index]' const describe = 'get presentation information for a specific capability' @@ -67,9 +67,7 @@ const handler = async (argv: ArgumentsCamelCase): Promise => command, argv.idOrIndex, argv.capabilityVersion, - undefined, - argv.namespace, - { allowIndex: true }, + { namespace: argv.namespace, allowIndex: true }, ) const presentation = await command.client.capabilities.getPresentation(capabilityId.id, capabilityId.version) diff --git a/src/commands/capabilities/presentation/create.ts b/src/commands/capabilities/presentation/create.ts index 9d49cc0c..7e822e8c 100644 --- a/src/commands/capabilities/presentation/create.ts +++ b/src/commands/capabilities/presentation/create.ts @@ -23,7 +23,7 @@ export type CommandArgs = & CapabilityIdInputFlags & InputAndOutputItemFlags -const command = 'capabilities:presentation:create [id] [capability-version]' +const command = 'capabilities:presentation:create [id]' const describe = 'create presentation model for a capability' diff --git a/src/commands/capabilities/presentation/update.ts b/src/commands/capabilities/presentation/update.ts index 99cb9080..83b8a4ee 100644 --- a/src/commands/capabilities/presentation/update.ts +++ b/src/commands/capabilities/presentation/update.ts @@ -23,7 +23,7 @@ export type CommandArgs = & InputAndOutputItemFlags & CapabilityIdInputFlags -const command = 'capabilities:presentation:update [id] [capability-version]' +const command = 'capabilities:presentation:update [id]' const describe = 'update presentation information of a capability' diff --git a/src/commands/capabilities/translations.ts b/src/commands/capabilities/translations.ts new file mode 100644 index 00000000..366927a8 --- /dev/null +++ b/src/commands/capabilities/translations.ts @@ -0,0 +1,108 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { + type DeviceProfileTranslations, + type LocaleReference, +} from '@smartthings/core-sdk' + +import { type WithLocales } from '../../lib/api-helpers.js' +import { apiDocsURL } from '../../lib/command/api-command.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../../lib/command/api-organization-command.js' +import { capabilityIdOrIndexBuilder, type CapabilityIdOrIndexInputFlags } from '../../lib/command/capability-flags.js' +import { + outputItemOrList, + outputItemOrListBuilder, + type OutputItemOrListConfig, + type OutputItemOrListFlags, +} from '../../lib/command/listing-io.js' +import { chooseCapability } from '../../lib/command/util/capabilities-choose.js' +import { buildTableOutput } from '../../lib/command/util/capabilities-translations-table.js' +import { type CapabilitySummaryWithNamespace } from '../../lib/command/util/capabilities-util.js' + + +export type CommandArgs = + & APIOrganizationCommandFlags + & OutputItemOrListFlags + & CapabilityIdOrIndexInputFlags + & { + namespace?: string + verbose: boolean + tag?: string + } + +const command = 'capabilities:translations [id-or-index] [tag]' + +const describe = 'get list of locales supported by the capability' + +export const builder = (yargs: Argv): Argv => + outputItemOrListBuilder(capabilityIdOrIndexBuilder(apiOrganizationCommandBuilder(yargs))) + .option('namespace', { + alias: 'n', + description: 'a specific namespace to query; will use all by default', + type: 'string', + }) + .option('verbose', { + alias: 'v', + description: 'include list of supported locales in table output', + type: 'boolean', + default: false, + }) + .positional('tag', { desc: 'the locale tag', type: 'string' }) + .example([ + ['$0 capabilities:translations', 'prompt for a capability and list its translations'], + [ + '$0 capabilities:translations --verbose', + 'prompt for a capability and list its translations, includes supported locales in capability list', + ], + [ + '$0 capabilities:translations 1', + 'list translations for the first capability in the list retrieved by running' + + ' "smartthings capabilities"', + ], + [ + '$0 capabilities:translations cathappy12345.myCapability', + 'list translations for the specified capability', + ], + [ + '$0 capabilities:translations 12 es', + 'display details of Spanish translation for twelfth capability retrieved by running' + + ' "smartthings capabilities"', + ], + ]) + .epilog(apiDocsURL('listCapabilityLocalizations', 'getCapabilityLocalization')) + +export type CapabilitySummaryWithLocales = CapabilitySummaryWithNamespace & WithLocales + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const capabilityId = await chooseCapability( + command, + argv.idOrIndex, + argv.capabilityVersion, + { namespace: argv.namespace, allowIndex: true, verbose: argv.verbose }, + ) + + const config: OutputItemOrListConfig = { + primaryKeyName: 'tag', + sortKeyName: 'tag', + listTableFieldDefinitions: ['tag'], + buildTableOutput: data => buildTableOutput(command.tableGenerator, data), + } + + await outputItemOrList( + command, + config, + argv.tag, + () => command.client.capabilities.listLocales(capabilityId.id, capabilityId.version), + tag => command.client.capabilities.getTranslations(capabilityId.id, capabilityId.version, tag), + true, + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index 88f2aab6..6639e7a8 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -15,6 +15,7 @@ import capabilitiesCommand from './capabilities.js' import capabilitiesPresentationCommand from './capabilities/presentation.js' import capabilitiesPresentationCreateCommand from './capabilities/presentation/create.js' import capabilitiesPresentationUpdateCommand from './capabilities/presentation/update.js' +import capabilitiesTranslationsCommand from './capabilities/translations.js' import configCommand from './config.js' import configResetCommand from './config/reset.js' import devicepreferencesCommand from './devicepreferences.js' @@ -53,6 +54,7 @@ export const commands: CommandModule[] = [ capabilitiesPresentationCommand, capabilitiesPresentationCreateCommand, capabilitiesPresentationUpdateCommand, + capabilitiesTranslationsCommand, configCommand, configResetCommand, devicepreferencesCommand, diff --git a/src/lib/command/capability-flags.ts b/src/lib/command/capability-flags.ts index e07bb588..190543c4 100644 --- a/src/lib/command/capability-flags.ts +++ b/src/lib/command/capability-flags.ts @@ -11,9 +11,9 @@ export const capabilityIdBuilder = ( ): Argv => yargs .positional('id', { desc: 'the capability id', type: 'string' }) - .positional( + .option( 'capability-version', - { desc: 'the capability version', type: 'number', demandOption: 'id' }, + { desc: 'the capability version', type: 'number' }, ) export type CapabilityIdOrIndexInputFlags = Omit & { @@ -25,7 +25,7 @@ export const capabilityIdOrIndexBuilder = ( ): Argv => yargs .positional('idOrIndex', { desc: 'the capability id or number in list', type: 'string' }) - .positional( + .option( 'capability-version', - { desc: 'the capability version', type: 'number', demandOption: 'idOrIndex' }, + { desc: 'the capability version', type: 'number' }, ) diff --git a/src/lib/command/util/capabilities-choose.ts b/src/lib/command/util/capabilities-choose.ts index 9f90f528..4407ade6 100644 --- a/src/lib/command/util/capabilities-choose.ts +++ b/src/lib/command/util/capabilities-choose.ts @@ -1,5 +1,6 @@ import inquirer from 'inquirer' +import { type WithLocales } from '../../api-helpers.js' import { type APICommand } from '../api-command.js' import { type Sorting } from '../io-defs.js' import { selectFromList, type SelectFromListConfig, type SelectFromListFlags } from '../select.js' @@ -49,33 +50,49 @@ export const chooseCapability = async ( command: APICommand, idOrIndexFromArgs?: string, versionFromArgs?: number, - promptMessage?: string, - namespace?: string, - options?: { allowIndex: boolean }, + options?: { + promptMessage?: string + namespace?: string + allowIndex?: boolean + verbose?: boolean + }, ): Promise => { const sortKeyName = 'id' - let capabilities: CapabilitySummaryWithNamespace[] - const listItems = async (): Promise => { + let capabilities: (CapabilitySummaryWithNamespace & WithLocales)[] + const listItems = async (): Promise<(CapabilitySummaryWithNamespace & WithLocales)[]> => { if (!capabilities) { - capabilities = await getCustomByNamespace(command.client, namespace) + capabilities = await getCustomByNamespace(command.client, options?.namespace) + if (options?.verbose) { + const ops = capabilities.map(it => command.client.capabilities.listLocales(it.id, it.version)) + const locales = await Promise.all(ops) + capabilities = capabilities.map((it, index) => { + return { ...it, locales: locales[index].map(it => it.tag).sort().join(', ') } + }) + } } - return capabilities } - const preselectedId = options?.allowIndex + const preselectedId = options?.allowIndex && idOrIndexFromArgs?.match(/^\d+$/) ? await translateToId(sortKeyName, idOrIndexFromArgs, listItems) : (idOrIndexFromArgs ? { id: idOrIndexFromArgs, version: versionFromArgs ?? 1 } : undefined) - const config: SelectFromListConfig = { + const config: SelectFromListConfig = { itemName: 'capability', pluralItemName: 'capabilities', primaryKeyName: 'id', sortKeyName, listTableFieldDefinitions: ['id', 'version', 'status'], } - return selectFromList(command, config, { - preselectedId, - listItems, - getIdFromUser, - promptMessage, - }) + if (options?.verbose) { + config.listTableFieldDefinitions.splice(3, 0, 'locales') + } + return selectFromList( + command, + config, + { + preselectedId, + listItems, + getIdFromUser, + promptMessage: options?.promptMessage, + }, + ) } diff --git a/src/lib/command/util/capabilities-translations-table.ts b/src/lib/command/util/capabilities-translations-table.ts new file mode 100644 index 00000000..c1eeb316 --- /dev/null +++ b/src/lib/command/util/capabilities-translations-table.ts @@ -0,0 +1,46 @@ +import { type CapabilityLocalization } from '@smartthings/core-sdk' + +import { type TableGenerator } from '../../table-generator.js' + + +export function buildTableOutput(tableGenerator: TableGenerator, data: CapabilityLocalization): string { + let result = `Tag: ${data.tag}` + if (data.attributes) { + const table = tableGenerator.newOutputTable({ head: ['Name', 'Label', 'Description', 'Template'] }) + for (const name of Object.keys(data.attributes)) { + const attr = data.attributes[name] + table.push([name, attr.label, attr.description, attr.displayTemplate]) + if (attr.i18n?.value) { + for (const key of Object.keys(attr.i18n.value)) { + const entry = attr.i18n.value[key] + table.push([ + `${name}.${key}`, + entry?.label, + entry?.description, + '', + ]) + } + } + } + result += '\n\nAttributes:\n' + table.toString() + } + if (data.commands) { + const table = tableGenerator.newOutputTable({ head: ['Name', 'Label', 'Description'] }) + for (const name of Object.keys(data.commands)) { + const cmd = data.commands[name] + table.push([name, cmd.label, cmd.description]) + if (cmd.arguments) { + for (const key of Object.keys(cmd.arguments)) { + const entry = cmd.arguments[key] + table.push([ + `${name}.${key}`, + entry?.label, + entry?.description, + ]) + } + } + } + result += '\n\nCommands:\n' + table.toString() + } + return result +} diff --git a/temporary-notes.md b/temporary-notes.md new file mode 100644 index 00000000..93f96a54 --- /dev/null +++ b/temporary-notes.md @@ -0,0 +1,4 @@ +Collected notes to eventually include in the yargs release notes. + +* capability version command line arguments have been changed from (in yargs-speak) positionals to + flags (options in yargs-speak)