Skip to content

Commit 082dcc3

Browse files
committed
feat: add topic and sub-command info to help output
1 parent 31ab441 commit 082dcc3

File tree

17 files changed

+392
-51
lines changed

17 files changed

+392
-51
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ smartthings edge:channels:create --help
196196
| devices:rename [id] [new-label] | rename a device |
197197
| devices:status [id-or-index] | get the current status of all of a device's component's attributes |
198198
| devices:update [id] | update a device's label and room |
199+
| edge | edge-specific commands |
199200
| edge:channels [id-or-index] | list all channels owned by you or retrieve a single channel |
200201
| edge:channels:assign [driver-id] [driver-version] | assign a driver to a channel |
201202
| edge:channels:create | create a channel |

src/__tests__/commands/apps.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type SmartThingsClient,
1212
} from '@smartthings/core-sdk'
1313

14+
import type { buildEpilog } from '../../lib/help.js'
1415
import type { APICommand, APICommandFlags } from '../../lib/command/api-command.js'
1516
import type { outputItemOrList, outputItemOrListBuilder } from '../../lib/command/listing-io.js'
1617
import type { CommandArgs } from '../../commands/apps.js'
@@ -22,7 +23,12 @@ import { apiCommandMocks } from '../test-lib/api-command-mock.js'
2223
import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js'
2324

2425

25-
const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../..')
26+
const buildEpilogMock = jest.fn<typeof buildEpilog>()
27+
jest.unstable_mockModule('../../lib/help.js', () => ({
28+
buildEpilog: buildEpilogMock,
29+
}))
30+
31+
const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks('../..')
2632

2733
const outputItemOrListMock = jest.fn<typeof outputItemOrList<PagedApp | AppResponse>>()
2834
const outputItemOrListBuilderMock = jest.fn<typeof outputItemOrListBuilder>()
@@ -69,7 +75,7 @@ describe('builder', () => {
6975
expect(positionalMock).toHaveBeenCalledTimes(1)
7076
expect(optionMock).toHaveBeenCalledTimes(3)
7177
expect(exampleMock).toHaveBeenCalledTimes(1)
72-
expect(apiDocsURLMock).toHaveBeenCalledTimes(1)
78+
expect(buildEpilogMock).toHaveBeenCalledTimes(1)
7379
expect(epilogMock).toHaveBeenCalledTimes(1)
7480
})
7581

src/__tests__/commands/edge/channels.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Channel, ChannelsEndpoint } from '@smartthings/core-sdk'
66

77
import type { CommandArgs } from '../../../commands/edge/channels.js'
88
import type { WithOrganization } from '../../../lib/api-helpers.js'
9+
import type { buildEpilog } from '../../../lib/help.js'
910
import type {
1011
APIOrganizationCommand,
1112
APIOrganizationCommandFlags,
@@ -18,11 +19,13 @@ import type {
1819
} from '../../../lib/command/common-flags.js'
1920
import type { outputItemOrList, outputItemOrListBuilder } from '../../../lib/command/listing-io.js'
2021
import { listChannels } from '../../../lib/command/util/edge/channels.js'
21-
import { apiCommandMocks } from '../../test-lib/api-command-mock.js'
2222
import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js'
2323

2424

25-
const { apiDocsURLMock } = apiCommandMocks('../../..')
25+
const buildEpilogMock = jest.fn<typeof buildEpilog>()
26+
jest.unstable_mockModule('../../../lib/help.js', () => ({
27+
buildEpilog: buildEpilogMock,
28+
}))
2629

2730
const apiOrganizationCommandMock = jest.fn<typeof apiOrganizationCommand>()
2831
const apiOrganizationCommandBuilderMock = jest.fn<typeof apiOrganizationCommandBuilder>()
@@ -83,7 +86,7 @@ describe('builder', () => {
8386
expect(optionMock).toHaveBeenCalledTimes(3)
8487
expect(positionalMock).toHaveBeenCalledTimes(2)
8588
expect(exampleMock).toHaveBeenCalledTimes(1)
86-
expect(apiDocsURLMock).toHaveBeenCalledTimes(1)
89+
expect(buildEpilogMock).toHaveBeenCalledTimes(1)
8790
expect(epilogMock).toHaveBeenCalledTimes(1)
8891
})
8992

src/__tests__/commands/locations.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ import type { ArgumentsCamelCase, Argv } from 'yargs'
44

55
import type { Location, LocationsEndpoint, SmartThingsClient } from '@smartthings/core-sdk'
66

7+
import type { buildEpilog } from '../../lib/help.js'
78
import type { APICommand, APICommandFlags } from '../../lib/command/api-command.js'
89
import type { outputItemOrList, outputItemOrListBuilder } from '../../lib/command/listing-io.js'
910
import type { CommandArgs } from '../../commands/locations.js'
1011
import { apiCommandMocks } from '../test-lib/api-command-mock.js'
1112
import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js'
1213

1314

14-
const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../..')
15+
const buildEpilogMock = jest.fn<typeof buildEpilog>()
16+
jest.unstable_mockModule('../../lib/help.js', () => ({
17+
buildEpilog: buildEpilogMock,
18+
}))
19+
20+
const { apiCommandMock, apiCommandBuilderMock } = apiCommandMocks('../..')
1521

1622
const outputItemOrListMock = jest.fn<typeof outputItemOrList>()
1723
const outputItemOrListBuilderMock = jest.fn<typeof outputItemOrListBuilder>()
@@ -47,7 +53,7 @@ test('builder', () => {
4753

4854
expect(positionalMock).toHaveBeenCalledTimes(1)
4955
expect(exampleMock).toHaveBeenCalledTimes(1)
50-
expect(apiDocsURLMock).toHaveBeenCalledTimes(1)
56+
expect(buildEpilogMock).toHaveBeenCalledTimes(1)
5157
expect(epilogMock).toHaveBeenCalledTimes(1)
5258
})
5359

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { jest } from '@jest/globals'
2+
3+
import { type CommandModule } from 'yargs'
4+
5+
6+
// Single consolidated mock command set covering all test scenarios
7+
const noop = (): void => { /* unused */ }
8+
const devicesStatusCommand = { command: 'devices:status', describe: 'device status', handler: noop }
9+
const devicesUpdateCommand = { command: 'devices:update', describe: 'update device', handler: noop }
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
const mockCommands: CommandModule<object, any>[] = [
12+
{ command: 'other:thing', describe: 'other thing', handler: noop },
13+
{ command: 'unrelated', describe: 'unrelated', handler: noop },
14+
devicesStatusCommand,
15+
{ command: 'devices:history:list', describe: 'device history list', handler: noop },
16+
devicesUpdateCommand,
17+
{ command: 'devices:history:list:detail', describe: 'history detail', handler: noop },
18+
{ describe: 'a command without a command', handler: noop },
19+
{ command: ['aliased', 'alias'], describe: 'a command with more than one name', handler: noop },
20+
]
21+
jest.unstable_mockModule('../../commands/index.js', () => ({
22+
commands: mockCommands,
23+
}))
24+
25+
const { findTopicsAndSubcommands } = await import('../../lib/command-util.js')
26+
27+
describe('findTopicsAndSubcommands', () => {
28+
it('returns no topics or sub-commands for a leaf command', () => {
29+
expect(findTopicsAndSubcommands('devices:update')).toStrictEqual({ topics: [], subCommands: [] })
30+
})
31+
32+
it('returns direct, and only direct, sub-commands and topics for devices', () => {
33+
const result = findTopicsAndSubcommands('devices')
34+
expect(result).toStrictEqual({
35+
topics: ['devices::history'],
36+
subCommands: [
37+
{ relatedName: 'devices:status', command: devicesStatusCommand },
38+
{ relatedName: 'devices:update', command: devicesUpdateCommand },
39+
],
40+
})
41+
})
42+
})

src/__tests__/lib/command/api-command.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,13 @@ const {
100100
describe('apiDocsURL', () => {
101101
it('produces URL', () => {
102102
expect(apiDocsURL('getDevice'))
103-
.toBe('For API information, see:\n\n' +
103+
.toBe('For API information, see:\n' +
104104
' https://developer.smartthings.com/docs/api/public/#operation/getDevice')
105105
})
106106

107107
it('joins multiple pages with line breaks', () => {
108108
expect(apiDocsURL('getDevice', 'getDevices'))
109-
.toBe('For API information, see:\n\n' +
109+
.toBe('For API information, see:\n' +
110110
' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' +
111111
' https://developer.smartthings.com/docs/api/public/#operation/getDevices')
112112
})

src/__tests__/lib/help.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { jest } from '@jest/globals'
2+
3+
import type { BorderConfig, getBorderCharacters, table } from 'table'
4+
import type { findTopicsAndSubcommands } from '../../lib/command-util.js'
5+
6+
7+
const borderConfig = { topLeft: 'top-left' } as BorderConfig
8+
const getBorderCharactersMock = jest.fn<typeof getBorderCharacters>()
9+
.mockReturnValue(borderConfig)
10+
const tableMock = jest.fn<typeof table>()
11+
jest.unstable_mockModule('table', () => ({
12+
getBorderCharacters: getBorderCharactersMock,
13+
table: tableMock,
14+
}))
15+
16+
const findTopicsAndSubcommandsMock = jest.fn<typeof findTopicsAndSubcommands>()
17+
.mockReturnValue({ topics: [], subCommands: [] })
18+
jest.unstable_mockModule('../../lib/command-util.js', () => ({
19+
findTopicsAndSubcommands: findTopicsAndSubcommandsMock,
20+
}))
21+
22+
23+
const { buildEpilog, apiDocsURL, itemInputHelpText } = await import('../../lib/help.js')
24+
25+
26+
describe('apiDocsURL', () => {
27+
it('builds URL stanza for single api name', () => {
28+
const result = apiDocsURL('getDevice')
29+
expect(result).toBe('For API information, see:\n' +
30+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice')
31+
})
32+
33+
it('builds URL stanza for multiple api names', () => {
34+
const result = apiDocsURL(['getDevice', 'listDevices'])
35+
expect(result).toBe(
36+
'For API information, see:\n' +
37+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' +
38+
' https://developer.smartthings.com/docs/api/public/#operation/listDevices',
39+
)
40+
})
41+
42+
it('passes through existing URLs', () => {
43+
const result = apiDocsURL(['http://example.com/doc', 'https://example.com/ssl-doc', 'getDevice'])
44+
expect(result).toBe(
45+
'For API information, see:\n' +
46+
' http://example.com/doc\n' +
47+
' https://example.com/ssl-doc\n' +
48+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice',
49+
)
50+
})
51+
})
52+
53+
describe('itemInputHelpText', () => {
54+
it('builds help text for a single name', () => {
55+
const result = itemInputHelpText('getDevice')
56+
expect(result).toBe('More information can be found at:\n' +
57+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice')
58+
})
59+
60+
it('builds help text for multiple names and URLs', () => {
61+
const result = itemInputHelpText('getDevice', 'http://example.com/doc')
62+
expect(result).toBe(
63+
'More information can be found at:\n' +
64+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' +
65+
' http://example.com/doc',
66+
)
67+
})
68+
})
69+
70+
describe('buildEpilog', () => {
71+
it('returns empty string when no options provided', () => {
72+
expect(buildEpilog({ command: 'test' })).toBe('')
73+
})
74+
75+
it('includes note from note provided via `notes`', () => {
76+
const epilog = buildEpilog({ command: 'test', notes: 'Single note' })
77+
expect(epilog).toBe('Notes:\n Single note')
78+
})
79+
80+
it('includes note from formattedNotes', () => {
81+
expect(buildEpilog({ command: 'test', formattedNotes: 'formatted note' })).toBe('Notes:\nformatted note')
82+
})
83+
84+
it('includes all notes from multiple notes provided via `notes`', () => {
85+
expect(buildEpilog({ command: 'test', notes: ['First note', 'Second note', 'Third note'] }))
86+
.toBe('Notes:\n First note\n Second note\n Third note')
87+
})
88+
89+
it('includes notes from both notes and formattedNotes', () => {
90+
expect(buildEpilog({ command: 'test', notes: ['note 1', 'note 2'], formattedNotes: 'formatted notes' }))
91+
.toBe('Notes:\n note 1\n note 2\nformatted notes')
92+
})
93+
94+
it('includes topics section when topics found', () => {
95+
findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['test::topic'], subCommands: [] })
96+
expect(buildEpilog({ command: 'test' })).toBe('Topics:\n test::topic')
97+
98+
findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['topic1', 'topic2'], subCommands: [] })
99+
expect(buildEpilog({ command: 'test' })).toBe('Topics:\n topic1\n topic2')
100+
})
101+
102+
it('includes apiDocs section when apiDocs provided', () => {
103+
expect(buildEpilog({ command: 'devices', apiDocs: ['getDevice', 'listDevices'] }))
104+
.toBe(
105+
'For API information, see:\n' +
106+
' https://developer.smartthings.com/docs/api/public/#operation/getDevice\n' +
107+
' https://developer.smartthings.com/docs/api/public/#operation/listDevices',
108+
)
109+
})
110+
111+
it('includes sub-commands section when sub-commands found', () => {
112+
tableMock.mockReturnValueOnce('sub-command table output')
113+
const subCommands = [
114+
{
115+
relatedName: 'test:sub1',
116+
command: {
117+
describe: 'sub 1 description',
118+
handler: () => { /* noop */ },
119+
},
120+
},
121+
{
122+
relatedName: 'test:sub2',
123+
command: {
124+
describe: 'sub 2 description',
125+
handler: () => { /* noop */ },
126+
},
127+
},
128+
]
129+
findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: [], subCommands })
130+
131+
expect(buildEpilog({ command: 'test' })).toBe(('Sub-Commands:\nsub-command table output'))
132+
133+
expect(getBorderCharactersMock).toHaveBeenCalledExactlyOnceWith('void')
134+
expect(tableMock).toHaveBeenCalledExactlyOnceWith([
135+
[' test:sub1', 'sub 1 description' ],
136+
[' test:sub2', 'sub 2 description' ],
137+
], expect.objectContaining({ border: borderConfig }))
138+
139+
// Call this trivial function to fulfill test coverage. :-)
140+
expect(tableMock.mock.calls[0][1]?.drawHorizontalLine?.(0, 0)).toBe(false)
141+
})
142+
143+
it('joins sections correctly when multiple present', () => {
144+
findTopicsAndSubcommandsMock.mockReturnValueOnce({ topics: ['test::topic'], subCommands: [] })
145+
146+
expect(buildEpilog({ command: 'test', formattedNotes: 'formatted note' }))
147+
.toBe('Notes:\nformatted note\n\nTopics:\n test::topic')
148+
})
149+
})

src/commands/apps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import {
88
type AppResponse,
99
} from '@smartthings/core-sdk'
1010

11+
import { buildEpilog } from '../lib/help.js'
1112
import { type TableFieldDefinition } from '../lib/table-generator.js'
1213
import {
1314
type APICommandFlags,
1415
apiCommand,
1516
apiCommandBuilder,
16-
apiDocsURL,
1717
} from '../lib/command/api-command.js'
1818
import {
1919
type OutputItemOrListConfig,
@@ -61,7 +61,7 @@ const builder = (yargs: Argv): Argv<CommandArgs> =>
6161
['$0 apps --classification SERVICE', 'list SERVICE classification apps'],
6262
['$0 apps --type API_ONLY', 'list API-only apps'],
6363
])
64-
.epilog(apiDocsURL('listApps', 'getApp'))
64+
.epilog(buildEpilog({ command, apiDocs: ['listApps', 'getApp'] }))
6565

6666
const handler = async (argv: ArgumentsCamelCase<CommandArgs>): Promise<void> => {
6767
const command = await apiCommand(argv)

0 commit comments

Comments
 (0)