diff --git a/packages/edge/src/__tests__/commands/edge/drivers/uninstall.test.ts b/packages/edge/src/__tests__/commands/edge/drivers/uninstall.test.ts deleted file mode 100644 index 68fbc44e..00000000 --- a/packages/edge/src/__tests__/commands/edge/drivers/uninstall.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { HubdevicesEndpoint } from '@smartthings/core-sdk' - -import DriversUninstallCommand from '../../../../commands/edge/drivers/uninstall.js' -import { chooseHub, chooseInstalledDriver } from '../../../../lib/commands/drivers-util.js' - - -jest.mock('../../../../../src/lib/commands/drivers-util') - -// ignore console output -jest.spyOn(process.stdout, 'write').mockImplementation(() => true) - -describe('DriversUninstallCommand', () => { - const chooseHubMock = jest.mocked(chooseHub) - const chooseInstalledDriverMock = jest.mocked(chooseInstalledDriver) - const apiUninstallDriverSpy = jest.spyOn(HubdevicesEndpoint.prototype, 'uninstallDriver') - - it('prompts user with list of installed drivers', async () => { - chooseHubMock.mockResolvedValue('chosen-hub-id') - chooseInstalledDriverMock.mockResolvedValueOnce('chosen-driver-id') - apiUninstallDriverSpy.mockImplementationOnce(() => Promise.resolve()) - - await expect(DriversUninstallCommand.run([])).resolves.not.toThrow() - - expect(chooseHubMock).toHaveBeenCalledTimes(1) - expect(chooseHubMock).toHaveBeenCalledWith(expect.any(DriversUninstallCommand), - 'Select a hub to uninstall from.', undefined, { useConfigDefault: true }) - expect(chooseInstalledDriver).toHaveBeenCalledTimes(1) - expect(chooseInstalledDriver).toHaveBeenCalledWith( expect.any(DriversUninstallCommand), - 'chosen-hub-id', 'Select a driver to uninstall.', undefined) - expect(apiUninstallDriverSpy).toHaveBeenCalledTimes(1) - expect(apiUninstallDriverSpy).toHaveBeenCalledWith('chosen-driver-id', 'chosen-hub-id') - }) -}) diff --git a/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts b/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts index 9096cdea..e5ec10e5 100644 --- a/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts +++ b/packages/edge/src/__tests__/lib/commands/drivers-util.test.ts @@ -195,33 +195,3 @@ test('chooseDriverFromChannel presents user with list of drivers with names', as expect(listAssignedDriversWithNamesSpy).toHaveBeenCalledTimes(1) expect(listAssignedDriversWithNamesSpy).toHaveBeenCalledWith(client, 'channel-id') }) - -test('chooseInstalledDriver presents user with list of drivers with names', async () => { - const command = { client } as APICommand - selectFromListMock.mockResolvedValueOnce('chosen-driver-id') - stringTranslateToIdMock.mockResolvedValueOnce('preselected-driver-id') - - expect(await driversUtil.chooseInstalledDriver(command, 'hub-id', 'prompt message', 'command-line-driver-id')) - .toBe('chosen-driver-id') - - expect(stringTranslateToIdMock).toHaveBeenCalledTimes(1) - expect(stringTranslateToIdMock).toHaveBeenCalledWith( - expect.objectContaining({ primaryKeyName: 'driverId', sortKeyName: 'name' }), - 'command-line-driver-id', expect.any(Function)) - expect(selectFromListMock).toHaveBeenCalledTimes(1) - expect(selectFromListMock).toHaveBeenCalledWith(command, - expect.objectContaining({ primaryKeyName: 'driverId', sortKeyName: 'name' }), - expect.objectContaining({ - preselectedId: 'preselected-driver-id', - promptMessage: 'prompt message', - })) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(0) - - const listItems = stringTranslateToIdMock.mock.calls[0][2] - apiHubdevicesListInstalledMock.mockResolvedValueOnce([installedDriver]) - - expect(await listItems()).toStrictEqual([installedDriver]) - - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledTimes(1) - expect(apiHubdevicesListInstalledMock).toHaveBeenCalledWith('hub-id') -}) diff --git a/packages/edge/src/commands/edge/drivers/uninstall.ts b/packages/edge/src/commands/edge/drivers/uninstall.ts deleted file mode 100644 index c87d8a1c..00000000 --- a/packages/edge/src/commands/edge/drivers/uninstall.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Flags } from '@oclif/core' - -import { EdgeCommand } from '../../../lib/edge-command.js' -import { chooseHub, chooseInstalledDriver } from '../../../lib/commands/drivers-util.js' - - -export default class DriversUninstallCommand extends EdgeCommand { - static description = 'uninstall an edge driver from a hub' + - this.apiDocsURL('uninstallDriver') - - static flags = { - ...EdgeCommand.flags, - hub: Flags.string({ - char: 'H', - description: 'hub id', - helpValue: '', - }), - } - - static args = [{ - name: 'driverId', - description: 'id of driver to uninstall', - }] - - async run(): Promise { - const hubId = await chooseHub(this, 'Select a hub to uninstall from.', this.flags.hub, - { useConfigDefault: true }) - const driverId = await chooseInstalledDriver(this, hubId, 'Select a driver to uninstall.', - this.args.driverId) - await this.client.hubdevices.uninstallDriver(driverId, hubId) - this.log(`driver ${driverId} uninstalled from hub ${hubId}`) - } -} diff --git a/packages/edge/src/lib/commands/drivers-util.ts b/packages/edge/src/lib/commands/drivers-util.ts index 99fd0d73..bb162d86 100644 --- a/packages/edge/src/lib/commands/drivers-util.ts +++ b/packages/edge/src/lib/commands/drivers-util.ts @@ -45,15 +45,3 @@ export const listAllAvailableDrivers = async (client: SmartThingsClient, deviceI export const listMatchingDrivers = async (client: SmartThingsClient, deviceId: string, hubId: string): Promise => withoutCurrentDriver(client, deviceId, await client.hubdevices.listInstalled(hubId, deviceId)) - -export const chooseInstalledDriver = async (command: APICommand, hubId: string, promptMessage: string, commandLineDriverId?: string): Promise => { - const config: SelectFromListConfig = { - itemName: 'driver', - primaryKeyName: 'driverId', - sortKeyName: 'name', - } - - const listItems = (): Promise => command.client.hubdevices.listInstalled(hubId) - const preselectedId = await stringTranslateToId(config, commandLineDriverId, listItems) - return selectFromList(command, config, { preselectedId, listItems, promptMessage }) -} diff --git a/src/__tests__/commands/config.test.ts b/src/__tests__/commands/config.test.ts index ce61922a..f0829235 100644 --- a/src/__tests__/commands/config.test.ts +++ b/src/__tests__/commands/config.test.ts @@ -134,7 +134,7 @@ describe('handler', () => { expect(outputListMock).toHaveBeenCalledTimes(1) expect(outputListMock).toHaveBeenCalledWith(command, expect.objectContaining({ primaryKeyName: 'name' }), - expect.any(Function), true) + expect.any(Function), { includeIndex: true }) const outputListConfig = outputListMock.mock.calls[0][1] as TableCommonListOutputProducer expect(outputListConfig.listTableFieldDefinitions?.length).toBe(2) diff --git a/src/__tests__/commands/edge/drivers/uninstall.test.ts b/src/__tests__/commands/edge/drivers/uninstall.test.ts new file mode 100644 index 00000000..567ec574 --- /dev/null +++ b/src/__tests__/commands/edge/drivers/uninstall.test.ts @@ -0,0 +1,111 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type{ Device, HubdevicesEndpoint, InstalledDriver } from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../../commands/edge/drivers/uninstall.js' +import type { APICommand, APICommandFlags } from '../../../../lib/command/api-command.js' +import type { chooseDriverFromChannelFn } from '../../../../lib/command/util/drivers-choose.js' +import type { DriverChannelDetailsWithName } from '../../../../lib/command/util/edge-drivers.js' +import type { chooseDriver } from '../../../../lib/command/util/drivers-choose.js' +import type { chooseHubFn } from '../../../../lib/command/util/hubs-choose.js' +import type { ChooseFunction } from '../../../../lib/command/util/util-util.js' +import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' +import { buildArgvMock } from '../../../test-lib/builder-mock.js' + + +const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../../../..') + +const chooseDriverFromChannelMock = jest.fn>() + .mockResolvedValue('driver-id-chosen-from-channel') +const chooseDriverFromChannelFnMock = jest.fn() + .mockReturnValue(chooseDriverFromChannelMock) +jest.unstable_mockModule('../../../../lib/command/util/drivers-choose.js', () => ({ + chooseDriverFromChannelFn: chooseDriverFromChannelFnMock, +})) + +const chooseDriverMock = jest.fn().mockResolvedValue('chosen-driver-id') +jest.unstable_mockModule('../../../../lib/command/util/drivers-choose.js', () => ({ + chooseDriver: chooseDriverMock, +})) + +const chooseHubMock = jest.fn>().mockResolvedValue('chosen-hub-id') +const chooseHubFnMock = jest.fn().mockReturnValue(chooseHubMock) +jest.unstable_mockModule('../../../../lib/command/util/hubs-choose.js', () => ({ + chooseHubFn: chooseHubFnMock, +})) + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /* do nothing */ }) + + +const { default: cmd } = await import('../../../../commands/edge/drivers/uninstall.js') + + +test('builder', () => { + const { + yargsMock, + positionalMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiCommandBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +test('handler', async () => { + const apiHubDevicesListInstalledMock = jest.fn() + const apiHubDevicesUninstallDriverMock = jest.fn() + const command = { + client: { + hubdevices: { + listInstalled: apiHubDevicesListInstalledMock, + uninstallDriver: apiHubDevicesUninstallDriverMock, + }, + }, + } as unknown as APICommand + apiCommandMock.mockResolvedValue(command) + + const inputArgv = { + profile: 'default', + driverId: 'cmd-line-driver-id', + hub: 'cmd-line-hub-id', + } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(chooseHubFnMock).toHaveBeenCalledExactlyOnceWith({ withInstalledDriverId: 'cmd-line-driver-id' }) + expect(chooseHubMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-hub-id', + { promptMessage: 'Select a hub to uninstall from.' }, + ) + expect(chooseDriverMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-driver-id', + { promptMessage: 'Select a driver to uninstall.', listItems: expect.any(Function) }, + ) + expect(apiHubDevicesUninstallDriverMock).toHaveBeenCalledExactlyOnceWith('chosen-driver-id', 'chosen-hub-id') + expect(consoleLogSpy).toHaveBeenCalledWith('Driver chosen-driver-id uninstalled from hub chosen-hub-id.') + + const listInstalledDrivers = chooseDriverMock.mock.calls[0][2]?.listItems + + const installedDrivers = [{ driverId: 'driver-id' } as InstalledDriver] + apiHubDevicesListInstalledMock.mockResolvedValueOnce(installedDrivers) + + expect(await listInstalledDrivers?.(command)).toBe(installedDrivers) +}) diff --git a/src/__tests__/lib/command/format.test.ts b/src/__tests__/lib/command/format.test.ts index cb78a23d..755fd61f 100644 --- a/src/__tests__/lib/command/format.test.ts +++ b/src/__tests__/lib/command/format.test.ts @@ -134,7 +134,7 @@ describe('formatAndWriteList', () => { primaryKeyName: 'num', } - await formatAndWriteList(command, config, [], true) + await formatAndWriteList(command, config, [], { includeIndex: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(0) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) @@ -156,7 +156,7 @@ describe('formatAndWriteList', () => { primaryKeyName: 'num', } - await formatAndWriteList(command, config, [], true) + await formatAndWriteList(command, config, [], { includeIndex: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(0) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) @@ -178,7 +178,7 @@ describe('formatAndWriteList', () => { primaryKeyName: 'num', } - await formatAndWriteList(command, config, [], true) + await formatAndWriteList(command, config, [], { includeIndex: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(0) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) @@ -205,7 +205,8 @@ describe('formatAndWriteList', () => { await formatAndWriteList(command, config, list) expect(listTableFormatterMock).toHaveBeenCalledTimes(1) - expect(listTableFormatterMock).toHaveBeenCalledWith(command.tableGenerator, config.listTableFieldDefinitions, false) + expect(listTableFormatterMock) + .toHaveBeenCalledWith(command.tableGenerator, config.listTableFieldDefinitions, false) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) expect(listBuildOutputFormatterMock).toHaveBeenCalledWith(flags, cliConfig, undefined, commonFormatter) expect(outputFormatterMock).toHaveBeenCalledTimes(1) @@ -220,7 +221,7 @@ describe('formatAndWriteList', () => { primaryKeyName: 'num', } - await formatAndWriteList(command, config, list, true) + await formatAndWriteList(command, config, list, { includeIndex: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(0) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) @@ -244,7 +245,7 @@ describe('formatAndWriteList', () => { primaryKeyName: 'num', } - await formatAndWriteList(command, config, list, true) + await formatAndWriteList(command, config, list, { includeIndex: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(0) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(1) @@ -282,6 +283,23 @@ describe('formatAndWriteList', () => { expect(writeOutputMock).toHaveBeenCalledWith('output', 'output.yaml') }) + it('final fallback works with only `primaryKeyName`', async () => { + const config: CommonListOutputProducer & Naming = { + primaryKeyName: 'num', + } + + const commonFormatter = jest.fn>() + listTableFormatterMock.mockReturnValue(commonFormatter) + + await formatAndWriteList(command, config, list) + + expect(listTableFormatterMock).toHaveBeenCalledExactlyOnceWith(command.tableGenerator, ['num'], false) + expect(listBuildOutputFormatterMock) + .toHaveBeenCalledExactlyOnceWith(flags, cliConfig, undefined, commonFormatter) + expect(outputFormatterMock).toHaveBeenCalledExactlyOnceWith(list) + expect(writeOutputMock).toHaveBeenCalledExactlyOnceWith('output', 'output.yaml') + }) + it('writes common formatted output to stdout when forUserQuery specified', async () => { const config: TableCommonListOutputProducer = { listTableFieldDefinitions: [], @@ -291,10 +309,11 @@ describe('formatAndWriteList', () => { const commonFormatter = jest.fn>().mockReturnValue('common output') listTableFormatterMock.mockReturnValue(commonFormatter) - await formatAndWriteList(command, config, list, false, true) + await formatAndWriteList(command, config, list, { forUserQuery: true }) expect(listTableFormatterMock).toHaveBeenCalledTimes(1) - expect(listTableFormatterMock).toHaveBeenCalledWith(command.tableGenerator, config.listTableFieldDefinitions, false) + expect(listTableFormatterMock) + .toHaveBeenCalledWith(command.tableGenerator, config.listTableFieldDefinitions, false) expect(listBuildOutputFormatterMock).toHaveBeenCalledTimes(0) expect(outputFormatterMock).toHaveBeenCalledTimes(0) expect(commonFormatter).toHaveBeenCalledTimes(1) diff --git a/src/__tests__/lib/command/listing-io.test.ts b/src/__tests__/lib/command/listing-io.test.ts index 89bb5e27..d1a58123 100644 --- a/src/__tests__/lib/command/listing-io.test.ts +++ b/src/__tests__/lib/command/listing-io.test.ts @@ -76,7 +76,7 @@ describe('outputItemOrListGeneric', () => { await outputItemOrListGeneric(command, config, undefined, listFunction, getFunction, translateToId) - expect(outputListMock).toHaveBeenCalledExactlyOnceWith(command, config, listFunction, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith(command, config, listFunction, { includeIndex: true }) expect(translateToId).not.toHaveBeenCalled() expect(getFunction).not.toHaveBeenCalled() @@ -111,7 +111,7 @@ describe('outputItemOrList', () => { await outputItemOrList(command, config, undefined, listFunction, getFunction, true) - expect(outputListMock).toHaveBeenCalledExactlyOnceWith(command, config, listFunction, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith(command, config, listFunction, { includeIndex: true }) expect(stringTranslateToIdMock).not.toHaveBeenCalled() expect(getFunction).not.toHaveBeenCalled() diff --git a/src/__tests__/lib/command/output-list.test.ts b/src/__tests__/lib/command/output-list.test.ts index 23e330c0..8e58aac4 100644 --- a/src/__tests__/lib/command/output-list.test.ts +++ b/src/__tests__/lib/command/output-list.test.ts @@ -63,23 +63,23 @@ describe('outputList', () => { expect(sortMock).toHaveBeenCalledExactlyOnceWith(list, 'str') expect(formatAndWriteListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, sorted, false, false) + .toHaveBeenCalledExactlyOnceWith(command, config, sorted, undefined) }) it('passes includeIndex value on to formatAndWriteList', async () => { - expect(await outputList(command, config, getDataMock, true)).toBe(sorted) + expect(await outputList(command, config, getDataMock, { includeIndex: true })).toBe(sorted) expect(sortMock).toHaveBeenCalledExactlyOnceWith(list, 'str') expect(formatAndWriteListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, sorted, true, false) + .toHaveBeenCalledExactlyOnceWith(command, config, sorted, { includeIndex: true }) }) it('passes forUserQuery value on to formatAndWriteList', async () => { - expect(await outputList(command, config, getDataMock, false, true)).toBe(sorted) + expect(await outputList(command, config, getDataMock, { forUserQuery: true })).toBe(sorted) expect(sortMock).toHaveBeenCalledExactlyOnceWith(list, 'str') expect(formatAndWriteListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, sorted, false, true) + .toHaveBeenCalledExactlyOnceWith(command, config, sorted, { forUserQuery: true }) }) it('skips sorting when no sort key is specified', async () => { @@ -87,10 +87,10 @@ describe('outputList', () => { listTableFieldDefinitions: [], primaryKeyName: 'num', } - expect(await outputList(command, config, getDataMock, false, true)).toBe(list) + expect(await outputList(command, config, getDataMock, { forUserQuery: true })).toBe(list) expect(sortMock).not.toHaveBeenCalled() expect(formatAndWriteListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, list, false, true) + .toHaveBeenCalledExactlyOnceWith(command, config, list, { forUserQuery: true }) }) }) diff --git a/src/__tests__/lib/command/select.test.ts b/src/__tests__/lib/command/select.test.ts index 3f4219ee..f673abc8 100644 --- a/src/__tests__/lib/command/select.test.ts +++ b/src/__tests__/lib/command/select.test.ts @@ -100,8 +100,12 @@ describe('promptUser', () => { expect(await promptUser(command, config, { listItems: listItemsMock })).toBe('chosen-id') expect(listItemsMock).toHaveBeenCalledExactlyOnceWith() - expect(outputListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, expect.any(Function), true, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.any(Function), + { includeIndex: true, forUserQuery: true }, + ) expect(stringGetIdFromUserMock).toHaveBeenCalledExactlyOnceWith(config, list, undefined) // Anonymous function passed to outputList should return same list as listItems @@ -119,8 +123,12 @@ describe('promptUser', () => { expect(await promptUser(command, config, { listItems: listItemsMock })).toBe('chosen-id') expect(listItemsMock).toHaveBeenCalledExactlyOnceWith() - expect(outputListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, expect.any(Function), true, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.any(Function), + { includeIndex: true, forUserQuery: true }, + ) expect(stringGetIdFromUserMock) .toHaveBeenCalledExactlyOnceWith(config, singleItemList, undefined) }) @@ -147,8 +155,12 @@ describe('promptUser', () => { .rejects.toThrow('should exit') expect(listItemsMock).toHaveBeenCalledTimes(1) - expect(outputListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, expect.any(Function), true, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.any(Function), + { includeIndex: true, forUserQuery: true }, + ) expect(stringGetIdFromUserMock).not.toHaveBeenCalled() }) it('calls custom getIdFromUser when specified', async () => { @@ -161,8 +173,12 @@ describe('promptUser', () => { .toBe('special-id') expect(listItemsMock).toHaveBeenCalledExactlyOnceWith() - expect(outputListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, expect.any(Function), true, true) + expect(outputListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.any(Function), + { includeIndex: true, forUserQuery: true }, + ) expect(stringGetIdFromUserMock).not.toHaveBeenCalled() expect(getIdFromUser).toHaveBeenCalledExactlyOnceWith(config, list, undefined) }) @@ -179,11 +195,9 @@ describe('promptUser', () => { command, configWithName, expect.any(Function), - true, - true, + { includeIndex: true, forUserQuery: true }, ) - expect(stringGetIdFromUserMock) - .toHaveBeenCalledExactlyOnceWith(configWithName, list, 'Select a thingamabob.') + expect(stringGetIdFromUserMock).toHaveBeenCalledExactlyOnceWith(configWithName, list, 'Select a thingamabob.') }) it('passes custom prompt on', async () => { @@ -193,10 +207,13 @@ describe('promptUser', () => { expect(await promptUser(command, config, options)).toBe('chosen-id') expect(listItemsMock).toHaveBeenCalledTimes(1) - expect(outputListMock) - .toHaveBeenCalledExactlyOnceWith(command, config, expect.any(Function), true, true) - expect(stringGetIdFromUserMock) - .toHaveBeenCalledExactlyOnceWith(config, list, 'custom prompt') + expect(outputListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.any(Function), + { includeIndex: true, forUserQuery: true }, + ) + expect(stringGetIdFromUserMock).toHaveBeenCalledExactlyOnceWith(config, list, 'custom prompt') }) }) diff --git a/src/__tests__/lib/command/util/hubs-choose.test.ts b/src/__tests__/lib/command/util/hubs-choose.test.ts index 4037a913..18b197a3 100644 --- a/src/__tests__/lib/command/util/hubs-choose.test.ts +++ b/src/__tests__/lib/command/util/hubs-choose.test.ts @@ -1,6 +1,12 @@ import { jest } from '@jest/globals' -import { DeviceIntegrationType, DevicesEndpoint, type Device } from '@smartthings/core-sdk' +import { + DeviceIntegrationType, + type DevicesEndpoint, + type HubdevicesEndpoint, + type InstalledDriver, + type Device, +} from '@smartthings/core-sdk' import type { APICommand } from '../../../../lib/command/api-command.js' import type { listOwnedHubs } from '../../../../lib/command/util/hubs.js' @@ -24,19 +30,22 @@ const { chooseHubFn } = await import('../../../../lib/command/util/hubs-choose.j describe('chooseHubFn', () => { const chooseHubMock = jest.fn>() createChooseFnMock.mockReturnValue(chooseHubMock) - const hub = { deviceId: 'hub-device-id', label: 'hub-label' } as Device - const hubs = [hub] - const apiDevicesGetMock = jest.fn() - .mockResolvedValue(hub) - const apiDevicesListMock = jest.fn() - .mockResolvedValue(hubs) + const hub1 = { deviceId: 'hub-device-id-1', label: 'hub 1' } as Device + const hub2 = { deviceId: 'hub-device-id-2', label: 'hub 2' } as Device + const hubs = [hub1, hub2] + const apiDevicesGetMock = jest.fn().mockResolvedValue(hub1) + const apiDevicesListMock = jest.fn().mockResolvedValue(hubs) listOwnedHubsMock.mockResolvedValue(hubs) + const apiHubDevicesGetInstalledMock = jest.fn() const command = { client: { devices: { get: apiDevicesGetMock, list: apiDevicesListMock, }, + hubdevices: { + getInstalled: apiHubDevicesGetInstalledMock, + }, }, } as unknown as APICommand @@ -102,12 +111,46 @@ describe('chooseHubFn', () => { const defaultValueConfig = createChooseFnMock.mock.calls[0][2]?.defaultValue const getItem = defaultValueConfig?.getItem - expect(await getItem?.(command, 'hub-id')).toBe(hub) + expect(await getItem?.(command, 'hub-id')).toBe(hub1) expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('hub-id') const userMessage = defaultValueConfig?.userMessage - expect(userMessage?.(hub)).toBe('using previously specified default hub labeled "hub-label" (hub-device-id)') + expect(userMessage?.(hub1)).toBe('using previously specified default hub labeled "hub 1" (hub-device-id-1)') + }) + + it('filters hubs with installed driver', async () => { + expect(chooseHubFn({ withInstalledDriverId: 'installed-driver-id' })).toBe(chooseHubMock) + + expect(createChooseFnMock).toHaveBeenCalledWith( + expect.objectContaining({ itemName: 'hub' }), + expect.any(Function), + expect.objectContaining({ + customNotFoundMessage: 'could not find hub with driver installed-driver-id installed' }, + ), + ) + + apiHubDevicesGetInstalledMock.mockResolvedValueOnce({} as InstalledDriver) + apiHubDevicesGetInstalledMock + .mockImplementationOnce(() => { throw { message: 'it is not currently installed on that hub' } }) + const listItems = createChooseFnMock.mock.calls[0][1] + + expect(await listItems(command)).toStrictEqual([hub1]) + + expect(apiHubDevicesGetInstalledMock).toHaveBeenCalledTimes(2) + expect(apiHubDevicesGetInstalledMock).toHaveBeenCalledWith('hub-device-id-1', 'installed-driver-id') + expect(apiHubDevicesGetInstalledMock).toHaveBeenCalledWith('hub-device-id-2', 'installed-driver-id') + }) + + it('rethrows unexpected errors from install check', async () => { + expect(chooseHubFn({ withInstalledDriverId: 'installed-driver-id' })).toBe(chooseHubMock) + + apiHubDevicesGetInstalledMock.mockImplementationOnce(() => { throw Error('unexpected') }) + const listItems = createChooseFnMock.mock.calls[0][1] + + await expect(listItems(command)).rejects.toThrow('unexpected') + + expect(apiHubDevicesGetInstalledMock).toHaveBeenCalledExactlyOnceWith('hub-device-id-1', 'installed-driver-id') }) }) diff --git a/src/__tests__/lib/command/util/util-util.test.ts b/src/__tests__/lib/command/util/util-util.test.ts index 20372d6a..f06dcbec 100644 --- a/src/__tests__/lib/command/util/util-util.test.ts +++ b/src/__tests__/lib/command/util/util-util.test.ts @@ -238,5 +238,16 @@ describe('createChooseFn', () => { expect(getItemMock).toHaveBeenCalledExactlyOnceWith(command, 'input-id') }) + + it('passes customNotFoundMessage option on to select', async () => { + const chooseSimpleType = createChooseFn(config, itemListMock, { customNotFoundMessage: 'custom not found' }) + expect(await chooseSimpleType(command, undefined)).toBe('selected-simple-type-id') + + expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith( + command, + config, + expect.objectContaining({ customNotFoundMessage: 'custom not found' }), + ) + }) }) }) diff --git a/src/commands/config.ts b/src/commands/config.ts index fa8553e2..0ea11f9e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -91,7 +91,7 @@ const handler = async (argv: ArgumentsCamelCase): Promise => if (outputFormat === 'common') { console.log('The CLI configuration file on your machine is:\n' + ` ${join(command.configDir, 'config.yaml')}\n`) - await outputList(command, outputListConfig, listConfigs, true) + await outputList(command, outputListConfig, listConfigs, { includeIndex: true }) } else { const outputFormatter = buildOutputFormatter(command.flags, command.cliConfig) await writeOutput(outputFormatter(command.cliConfig.mergedProfiles), argv.output) diff --git a/src/commands/edge/drivers/uninstall.ts b/src/commands/edge/drivers/uninstall.ts new file mode 100644 index 00000000..e364da09 --- /dev/null +++ b/src/commands/edge/drivers/uninstall.ts @@ -0,0 +1,54 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type InstalledDriver } from '@smartthings/core-sdk' + +import { apiCommand, apiCommandBuilder, type APICommandFlags, apiDocsURL } from '../../../lib/command/api-command.js' +import { chooseDriver } from '../../../lib/command/util/drivers-choose.js' +import { chooseHubFn } from '../../../lib/command/util/hubs-choose.js' + + +export type CommandArgs = + & APICommandFlags + & { + driverId?: string + hub?: string + } + +const command = 'edge:drivers:uninstall [driver-id]' + +const describe = 'uninstall an edge driver from a hub' + +const builder = (yargs: Argv): Argv => + apiCommandBuilder(yargs) + .positional('driver-id', { describe: 'id of driver to uninstall', type: 'string' }) + .option('hub', { alias: 'H', describe: 'hub id', type: 'string' }) + .example([ + ['$0 edge:drivers:uninstall', 'prompt for a hub and driver and uninstall the driver from the hub'], + [ + '$0 edge:drivers:uninstall e61b9758-dfd5-4256-8a68-e411e38572b6', + 'uninstall the specified driver', + ], + ]) + .epilog(apiDocsURL('uninstallDriver')) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiCommand(argv) + + const hubId = await chooseHubFn({ withInstalledDriverId: argv.driverId })( + command, + argv.hub, + { promptMessage: 'Select a hub to uninstall from.' }, + ) + + const listInstalledDrivers = (): Promise => command.client.hubdevices.listInstalled(hubId) + const driverId = await chooseDriver( + command, + argv.driverId, + { promptMessage: 'Select a driver to uninstall.', listItems: listInstalledDrivers }, + ) + await command.client.hubdevices.uninstallDriver(driverId, hubId) + console.log(`Driver ${driverId} uninstalled from hub ${hubId}.`) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index c57b0563..e0222fc9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -76,6 +76,7 @@ import edgeDriversInstallCommand from './edge/drivers/install.js' import edgeDriversInstalledCommand from './edge/drivers/installed.js' import edgeDriversLogcatCommand from './edge/drivers/logcat.js' import edgeDriversPackageCommand from './edge/drivers/package.js' +import edgeDriversUninstallCommand from './edge/drivers/uninstall.js' import installedappsCommand from './installedapps.js' import installedappsDeleteCommand from './installedapps/delete.js' import installedappsRenameCommand from './installedapps/rename.js' @@ -205,6 +206,7 @@ export const commands: CommandModule[] = [ edgeDriversInstalledCommand, edgeDriversLogcatCommand, edgeDriversPackageCommand, + edgeDriversUninstallCommand, installedappsCommand, installedappsDeleteCommand, installedappsRenameCommand, diff --git a/src/lib/command/format.ts b/src/lib/command/format.ts index 7acaf2f1..11bfe224 100644 --- a/src/lib/command/format.ts +++ b/src/lib/command/format.ts @@ -39,8 +39,12 @@ export type FormatAndWriteItemConfig = CommonOutputProducer * @param defaultIOFormat The default IOFormat to use. This should be used when a command also takes * input so the output can default to the input format. */ -export async function formatAndWriteItem(command: SmartThingsCommand, - config: FormatAndWriteItemConfig, item: O, defaultIOFormat?: IOFormat): Promise { +export async function formatAndWriteItem( + command: SmartThingsCommand, + config: FormatAndWriteItemConfig, + item: O, + defaultIOFormat?: IOFormat, +): Promise { const commonFormatter = 'buildTableOutput' in config ? (data: O) => config.buildTableOutput(data) : itemTableFormatter(command.tableGenerator, config.tableFieldDefinitions) @@ -51,6 +55,23 @@ export async function formatAndWriteItem(command: SmartThingsC export type FormatAndWriteListFlags = BuildOutputFormatterFlags export const formatAndWriteListBuilder = buildOutputFormatterBuilder export type FormatAndWriteListConfig = CommonListOutputProducer & Naming & Sorting +export type FormatAndWriteListOptions = { + /** + * Set this to true if you want to include an index in the output. Default is false. + */ + includeIndex?: boolean + + /** + * Set this to true if you're displaying this to the user for a question. This will force + * output to stdout and skip the JSON/YAML formatters. Default is false. + */ + forUserQuery?: boolean + + /** + * A custom error message to display when no items are found. + */ + customNotFoundMessage?: string +} /** * Format and output the given list. * @@ -59,24 +80,28 @@ export type FormatAndWriteListConfig = CommonListOutputProduce * table field definitions called `listTableFieldDefinitions` or a function to write * common-formatted output called `buildTableOutput`. * @param list The items to be written. - * @param includeIndex Set this to true if you want to include an index in the output. - * @param forUserQuery Set this to true if you're displaying this to the user for a question. This - * will force output to stdout and skip the JSON/YAML formatters. + * @param options See FormatAndWriteListOptions for details. */ -export async function formatAndWriteList(command: SmartThingsCommand, - config: FormatAndWriteListConfig, list: L[], includeIndex = false, - forUserQuery = false): Promise { +export async function formatAndWriteList( + command: SmartThingsCommand, + config: FormatAndWriteListConfig, + list: L[], + options?: FormatAndWriteListOptions, +): Promise { + const includeIndex = !!options?.includeIndex + const forUserQuery = options?.forUserQuery let commonFormatter: OutputFormatter if (list.length === 0) { const pluralName = config.pluralItemName ?? (config.itemName ? `${config.itemName}s` : 'items') - commonFormatter = () => `no ${pluralName} found` + commonFormatter = () => options?.customNotFoundMessage ?? `no ${pluralName} found` } else if ('buildListTableOutput' in config) { const buildListTableOutput = config.buildListTableOutput commonFormatter = data => buildListTableOutput(data) } else if ('listTableFieldDefinitions' in config) { commonFormatter = listTableFormatter(command.tableGenerator, config.listTableFieldDefinitions, includeIndex) } else if (config.sortKeyName) { - commonFormatter = listTableFormatter(command.tableGenerator, [config.sortKeyName, config.primaryKeyName], includeIndex) + commonFormatter = + listTableFormatter(command.tableGenerator, [config.sortKeyName, config.primaryKeyName], includeIndex) } else { commonFormatter = listTableFormatter(command.tableGenerator, [config.primaryKeyName], includeIndex) } diff --git a/src/lib/command/listing-io.ts b/src/lib/command/listing-io.ts index e4018f5c..96a29151 100644 --- a/src/lib/command/listing-io.ts +++ b/src/lib/command/listing-io.ts @@ -30,7 +30,7 @@ export async function outputItemOrListGeneric(command, config, () => getFunction(id)) } else { - await outputList(command, config, listFunction, includeIndex) + await outputList(command, config, listFunction, { includeIndex }) } } diff --git a/src/lib/command/output-list.ts b/src/lib/command/output-list.ts index 6f99bf0f..16f4b19a 100644 --- a/src/lib/command/output-list.ts +++ b/src/lib/command/output-list.ts @@ -1,4 +1,4 @@ -import { formatAndWriteList, type FormatAndWriteListConfig } from './format.js' +import { formatAndWriteList, type FormatAndWriteListOptions, type FormatAndWriteListConfig } from './format.js' import { type GetDataFunction } from './io-defs.js' import { sort } from './output.js' import { buildOutputFormatterBuilder, type BuildOutputFormatterFlags } from './output-builder.js' @@ -8,9 +8,14 @@ import { type SmartThingsCommand } from './smartthings-command.js' export type OutputListFlags = BuildOutputFormatterFlags export const outputListBuilder = buildOutputFormatterBuilder export type OutputListConfig = FormatAndWriteListConfig -export async function outputList(command: SmartThingsCommand, config: OutputListConfig, - getData: GetDataFunction, includeIndex = false, forUserQuery = false): Promise { +export type OutputListOptions = FormatAndWriteListOptions +export const outputList = async ( + command: SmartThingsCommand, + config: OutputListConfig, + getData: GetDataFunction, + options?: OutputListOptions, +): Promise => { const list = config.sortKeyName ? sort(await getData(), config.sortKeyName) : await getData() - await formatAndWriteList(command, config, list, includeIndex, forUserQuery) + await formatAndWriteList(command, config, list, options) return list } diff --git a/src/lib/command/select.ts b/src/lib/command/select.ts index d15bd215..b7bea5b3 100644 --- a/src/lib/command/select.ts +++ b/src/lib/command/select.ts @@ -55,6 +55,11 @@ export type PromptUserOptions = { * @default: false */ autoChoose?: boolean + + /** + * A custom error message to display when no items are found. + */ + customNotFoundMessage?: string } /** @@ -72,7 +77,12 @@ export async function promptUser(command: SmartTh if (options.autoChoose && items.length === 1) { return items[0][config.primaryKeyName] as unknown as ID } - const list = await outputList(command, config, async () => items, true, true) + const list = await outputList( + command, + config, + async () => items, + { includeIndex: true, forUserQuery: true, customNotFoundMessage: options.customNotFoundMessage }, + ) if (list.length === 0) { // Nothing was found; user was already notified by `outputList` above. // eslint-disable-next-line no-process-exit diff --git a/src/lib/command/util/hubs-choose.ts b/src/lib/command/util/hubs-choose.ts index 0a635251..2c3a5e83 100644 --- a/src/lib/command/util/hubs-choose.ts +++ b/src/lib/command/util/hubs-choose.ts @@ -5,12 +5,42 @@ import { listOwnedHubs } from './hubs.js' import { type ChooseFunction, createChooseFn } from './util-util.js' -export type ChooseHubFnOptions = { +export type ChooseHubFnOptions = ({ locationId?: string | string[] } | { includeOnlyOwnedHubs: true +}) & { + /** + * Limit hubs to those with the specified driver installed. + */ + withInstalledDriverId?: string } export const chooseHubFn = (options: ChooseHubFnOptions = {}): ChooseFunction => { + const listItems = async (command: APICommand): Promise => { + const unfiltered = ('includeOnlyOwnedHubs' in options) + ? await listOwnedHubs(command) + : await command.client.devices.list({ type: DeviceIntegrationType.HUB, locationId: options.locationId }) + if (!options.withInstalledDriverId) { + return unfiltered + } + + const filtered: Device[] = [] + for (const device of unfiltered) { + try { + // This command will throw an error if the driver is not installed on the hub. + await command.client.hubdevices.getInstalled(device.deviceId, options.withInstalledDriverId) + filtered.push(device) + } catch (error) { + if (!error.message?.includes('not currently installed')) { + throw error + } + } + } + return filtered + } + const customNotFoundMessage = options.withInstalledDriverId + ? `could not find hub with driver ${options.withInstalledDriverId} installed` + : undefined return createChooseFn( { itemName: 'hub', @@ -18,10 +48,7 @@ export const chooseHubFn = (options: ChooseHubFnOptions = {}): ChooseFunction - ('includeOnlyOwnedHubs' in options) - ? listOwnedHubs(command) - : command.client.devices.list({ type: DeviceIntegrationType.HUB, locationId: options.locationId }), + listItems, { defaultValue: { configKey: 'defaultHub', @@ -29,6 +56,7 @@ export const chooseHubFn = (options: ChooseHubFnOptions = {}): ChooseFunction `using previously specified default hub labeled "${hub.label}" (${hub.deviceId})`, }, + customNotFoundMessage, }, ) } diff --git a/src/lib/command/util/util-util.ts b/src/lib/command/util/util-util.ts index d582c491..f5b7ec4e 100644 --- a/src/lib/command/util/util-util.ts +++ b/src/lib/command/util/util-util.ts @@ -37,9 +37,10 @@ export const chooseOptionsWithDefaults = ( }) export type CreateChooseFunctionOptions = { - defaultValue: Omit>['defaultValue'], 'getItem'> & { + defaultValue?: Omit>['defaultValue'], 'getItem'> & { getItem: (command: APICommand, id: string) => Promise } + customNotFoundMessage?: string } export type ChooseFunction = ( @@ -74,16 +75,18 @@ export const createChooseFn = ( autoChoose: opts.autoChoose, listItems: listItemsWrapper, promptMessage: opts.promptMessage, + customNotFoundMessage: createOptions?.customNotFoundMessage, } if (opts.useConfigDefault) { - if (!createOptions?.defaultValue) { + const defaultValue = createOptions?.defaultValue + if (!defaultValue) { throw Error('invalid state, the choose function was called with "useConfigDefault"' + ' but no default configured') } selectOptions.defaultValue = { - ...createOptions.defaultValue, - getItem: (id: string): Promise => createOptions.defaultValue.getItem(command, id), + ...defaultValue, + getItem: (id: string): Promise => defaultValue.getItem(command, id), } }