diff --git a/README.md b/README.md index be59395..068a189 100644 --- a/README.md +++ b/README.md @@ -653,18 +653,19 @@ stream logs from installed drivers ``` USAGE $ smartthings edge:drivers:logcat [DRIVERID] [-h] [-p ] [-t ] [--language ] [-a] [--hub-address - ] + ] [--connect-timeout ] ARGUMENTS DRIVERID a specific driver to stream logs from FLAGS - -a, --all stream from all installed drivers - -h, --help Show CLI help. - -p, --profile= [default: default] configuration profile - -t, --token= the auth token to use - --hub-address= IPv4 address of hub with optionally appended port number - --language= ISO language code or "NONE" to not specify a language. Defaults to the OS locale + -a, --all stream from all installed drivers + -h, --help Show CLI help. + -p, --profile= [default: default] configuration profile + -t, --token= the auth token to use + --connect-timeout= [default: 30000] max time allowed when connecting to hub + --hub-address= IPv4 address of hub with optionally appended port number + --language= ISO language code or "NONE" to not specify a language. Defaults to the OS locale DESCRIPTION stream logs from installed drivers diff --git a/package.json b/package.json index 142c1f9..760d6dd 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@semantic-release/git": "^10.0.1", "@smartthings/cli-testlib": "0.0.0-pre.39", "@types/cli-table": "^0.3.0", + "@types/eventsource": "^1.1.8", "@types/inquirer": "^8.2.0", "@types/jest": "^27.4.0", "@types/js-yaml": "^4.0.5", diff --git a/src/commands/edge/drivers/logcat.ts b/src/commands/edge/drivers/logcat.ts index 4122f75..bcb3988 100644 --- a/src/commands/edge/drivers/logcat.ts +++ b/src/commands/edge/drivers/logcat.ts @@ -6,6 +6,7 @@ import { DriverInfo, handleConnectionErrors, LiveLogClient, + LiveLogClientConfig, LiveLogMessage, liveLogMessageFormatter, parseIpAndPort, @@ -25,6 +26,7 @@ import { inspect } from 'util' const DEFAULT_ALL_TEXT = 'all' const DEFAULT_LIVE_LOG_PORT = 9495 +const DEFAULT_LIVE_LOG_TIMEOUT = 30_000 // milliseconds /** * Define labels to stay consistent with other driver commands @@ -84,6 +86,11 @@ export default class LogCatCommand extends SseCommand { 'hub-address': Flags.string({ description: 'IPv4 address of hub with optionally appended port number', }), + 'connect-timeout': Flags.integer({ + description: 'max time allowed when connecting to hub', + helpValue: '', + default: DEFAULT_LIVE_LOG_TIMEOUT, + }), } static args = [ @@ -104,26 +111,28 @@ export default class LogCatCommand extends SseCommand { const known = knownHubs[this.authority] if (!known || known.fingerprint !== cert.fingerprint) { - this.warn(`The authenticity of ${this.authority} can't be established. Certificate fingerprint is ${cert.fingerprint}`) - const verified = (await inquirer.prompt({ - type: 'confirm', - name: 'connect', - message: 'Are you sure you want to continue connecting?', - default: false, - })).connect - - if (!verified) { - this.error('Hub verification failed.') - } + await CliUx.ux.action.pauseAsync(async () => { + this.warn(`The authenticity of ${this.authority} can't be established. Certificate fingerprint is ${cert.fingerprint}`) + const verified = (await inquirer.prompt({ + type: 'confirm', + name: 'connect', + message: 'Are you sure you want to continue connecting?', + default: false, + })).connect + + if (!verified) { + this.error('Hub verification failed.') + } - knownHubs[this.authority] = { hostname: this.authority, fingerprint: cert.fingerprint } - await fs.writeFile(knownHubsPath, JSON.stringify(knownHubs)) + knownHubs[this.authority] = { hostname: this.authority, fingerprint: cert.fingerprint } + await fs.writeFile(knownHubsPath, JSON.stringify(knownHubs)) - this.warn(`Permanently added ${this.authority} to the list of known hubs.`) + this.warn(`Permanently added ${this.authority} to the list of known hubs.`) + }) } } - private async chooseHubDrivers(commandLineDriverId?: string, driversList?: Promise): Promise { + private async chooseHubDrivers(commandLineDriverId?: string, driversList?: DriverInfo[]): Promise { const config = { itemName: 'driver', primaryKeyName: 'driver_id', @@ -131,7 +140,7 @@ export default class LogCatCommand extends SseCommand { listTableFieldDefinitions: driverFieldDefinitions, } - const list = driversList ?? this.logClient.getDrivers() + const list = driversList !== undefined ? Promise.resolve(driversList) : this.logClient.getDrivers() const preselectedId = await stringTranslateToId(config, commandLineDriverId, () => list) return selectGeneric(this, config, preselectedId, () => list, promptForDrivers) } @@ -147,27 +156,47 @@ export default class LogCatCommand extends SseCommand { const liveLogPort = port ?? DEFAULT_LIVE_LOG_PORT this.authority = `${ipv4}:${liveLogPort}` - this.logClient = new LiveLogClient(this.authority, this.authenticator, this.checkServerIdentity.bind(this)) + const config: LiveLogClientConfig = { + authority: this.authority, + authenticator: this.authenticator, + verifier: this.checkServerIdentity.bind(this), + timeout: flags['connect-timeout'], + } + + this.logClient = new LiveLogClient(config) } async run(): Promise { - const installedDriversPromise = this.logClient.getDrivers() + CliUx.ux.action.start('connecting') + + // ensure host verification resolves before connecting to the event source + const installedDrivers = await this.logClient.getDrivers() let sourceURL: string if (this.flags.all) { sourceURL = this.logClient.getLogSource() } else { - const driverId = await this.chooseHubDrivers(this.args.driverId, installedDriversPromise) + const driverId = await CliUx.ux.action.pauseAsync(() => this.chooseHubDrivers(this.args.driverId, installedDrivers)) sourceURL = driverId == DEFAULT_ALL_TEXT ? this.logClient.getLogSource() : this.logClient.getLogSource(driverId) } - // ensure this resolves before connecting to the event source - const installedDrivers = await installedDriversPromise - - CliUx.ux.action.start('connecting') await this.initSource(sourceURL) + const sourceTimeoutID = setTimeout(() => { + this.teardown() + CliUx.ux.action.stop('failed') + try { + handleConnectionErrors(this.authority, 'ETIMEDOUT') + } catch (error) { + if (error instanceof Error) { + Errors.handle(error) + } + } + }, this.flags['connect-timeout']).unref() // unref lets Node exit before callback is invoked + this.source.onopen = () => { + clearTimeout(sourceTimeoutID) + if (installedDrivers.length === 0) { this.warn('No drivers currently installed.') } @@ -176,11 +205,10 @@ export default class LogCatCommand extends SseCommand { } // error Event from eventsource doesn't always overlap with MessageEvent - this.source.onerror = (error: MessageEvent & Partial<{ status: number; message: string | undefined }>) => { - CliUx.ux.action.stop('failed') + this.source.onerror = (error: MessageEvent & Partial<{ status: number; message: string }>) => { this.teardown() + CliUx.ux.action.stop('failed') this.logger.debug(`Error from eventsource. URL: ${sourceURL} Error: ${inspect(error)}`) - try { if (error.status === 401 || error.status === 403) { this.error(`Unauthorized at ${this.authority}`) @@ -202,4 +230,15 @@ export default class LogCatCommand extends SseCommand { logEvent(event, liveLogMessageFormatter) } } + + async catch(error: unknown): Promise { + this.teardown() + // exit gracefully for Command.exit(0) + if (error instanceof Errors.ExitError && error.oclif.exit === 0) { + return + } + + CliUx.ux.action.stop('failed') + await super.catch(error) + } } diff --git a/src/lib/live-logging.ts b/src/lib/live-logging.ts index a4e2bba..303ea9f 100644 --- a/src/lib/live-logging.ts +++ b/src/lib/live-logging.ts @@ -190,31 +190,44 @@ function scrubAuthInfo(obj: unknown): string { */ export type HostVerifier = (cert: PeerCertificate) => Promise +export interface LiveLogClientConfig { + /** + * @example 192.168.0.1:9495 + */ + authority: string + authenticator: Authenticator + verifier?: HostVerifier + /** + * milliseconds + */ + timeout: number +} + export class LiveLogClient { - private authority: string private driversURL: URL private logsURL: URL - private authenticator: Authenticator private hostVerified: boolean - private verifier?: HostVerifier - constructor(authority: string, authenticator: Authenticator, verifier?: HostVerifier) { - const baseURL = new URL(`https://${authority}`) + constructor(private readonly config: LiveLogClientConfig) { + const baseURL = new URL(`https://${config.authority}`) - this.authority = authority this.driversURL = new URL('drivers', baseURL) this.logsURL = new URL('drivers/logs', baseURL) - this.authenticator = authenticator - this.hostVerified = verifier === undefined - this.verifier = verifier + this.hostVerified = config.verifier === undefined } private async request(url: string, method: Method = 'GET'): Promise { - const config = await this.authenticator.authenticate({ + const config = await this.config.authenticator.authenticate({ url: url, method: method, httpsAgent: new https.Agent({ rejectUnauthorized: false }), - timeout: 5000, // milliseconds + timeout: this.config.timeout, + transitional: { + silentJSONParsing: true, + forcedJSONParsing: true, + // throw ETIMEDOUT error instead of generic ECONNABORTED on request timeouts + clarifyTimeoutError: true, + }, }) let response @@ -235,8 +248,8 @@ export class LiveLogClient { throw error } - if (!this.hostVerified && this.verifier) { - await this.verifier(this.getCertificate(response)) + if (!this.hostVerified && this.config.verifier) { + await this.config.verifier(this.getCertificate(response)) this.hostVerified = true } @@ -245,13 +258,7 @@ export class LiveLogClient { private handleAxiosConnectionErrors(error: AxiosError): never | void { if (error.code) { - // hack to address https://github.com/axios/axios/issues/1543 - if (error.code === 'ECONNABORTED' && error.message.toLowerCase().includes('timeout')) { - throw new Errors.CLIError(`Connection to ${this.authority} timed out. ` + - 'Ensure hub address is correct and try again') - } - - handleConnectionErrors(this.authority, error.code) + handleConnectionErrors(this.config.authority, error.code) } } diff --git a/test/unit/commands/edge/drivers/logcat.test.ts b/test/unit/commands/edge/drivers/logcat.test.ts index 21fe3ae..5d62a32 100644 --- a/test/unit/commands/edge/drivers/logcat.test.ts +++ b/test/unit/commands/edge/drivers/logcat.test.ts @@ -1,14 +1,16 @@ import LogCatCommand from '../../../../../src/commands/edge/drivers/logcat' import { promises as fs } from 'fs' -import { Errors } from '@oclif/core' import inquirer from 'inquirer' -import { HostVerifier, LiveLogClient } from '../../../../../src/lib/live-logging' -import { Certificate, PeerCertificate } from 'tls' -import { SseCommand } from '@smartthings/cli-lib' +import { DriverInfo, handleConnectionErrors, LiveLogClient, LiveLogMessage, liveLogMessageFormatter, parseIpAndPort } from '../../../../../src/lib/live-logging' +import { PeerCertificate } from 'tls' +import { askForRequiredString, convertToId, logEvent, selectGeneric, Sorting, SseCommand, stringTranslateToId } from '@smartthings/cli-lib' +import EventSource from 'eventsource' +import { CliUx, Errors } from '@oclif/core' const MOCK_IPV4 = '192.168.0.1' const MOCK_HOSTNAME = `${MOCK_IPV4}:9495` +const MOCK_SOURCE_URL = `https://${MOCK_HOSTNAME}/drivers/logs` const MOCK_FINGERPRINT = '00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00' const MOCK_KNOWN_HUBS = JSON.stringify({ [MOCK_HOSTNAME]: { @@ -16,34 +18,11 @@ const MOCK_KNOWN_HUBS = JSON.stringify({ fingerprint: MOCK_FINGERPRINT, }, }) -const MOCK_TLS_CERT: Certificate = { - C: '', - ST: '', - L: '', - O: '', - OU: '', - CN: '', -} -const MOCK_PEER_CERT: PeerCertificate = { +const MOCK_PEER_CERT = { fingerprint: MOCK_FINGERPRINT, - subject: MOCK_TLS_CERT, - issuer: MOCK_TLS_CERT, - subjectaltname: '', - infoAccess: {}, - modulus: '', - exponent: '', - valid_from: '', - valid_to: '', - fingerprint256: '', - ext_key_usage: [], - serialNumber: '', - raw: Buffer.from(''), -} - -jest.mock('inquirer', () => ({ - // answer "yes" to every host verification prompt - prompt: jest.fn().mockResolvedValue({ connect: true }), -})) +} as PeerCertificate + +jest.mock('inquirer') jest.mock('fs', () => { // if this isn't done, something breaks with sub-dependency 'fs-extra' @@ -62,59 +41,137 @@ jest.mock('fs', () => { } }) +jest.mock('@smartthings/cli-lib', () => { + const originalLib = jest.requireActual('@smartthings/cli-lib') + + return { + ...originalLib, + stringTranslateToId: jest.fn(), + selectGeneric: jest.fn(), + askForRequiredString: jest.fn(), + logEvent: jest.fn(), + convertToId: jest.fn().mockResolvedValue('driverId'), + } +}) + +jest.mock('eventsource') + const mockLiveLogClient = { getDrivers: jest.fn().mockResolvedValue([]), - getLogSource: jest.fn().mockReturnValue(`https://${MOCK_HOSTNAME}/drivers/logs`), + getLogSource: jest.fn().mockReturnValue(MOCK_SOURCE_URL), } as unknown as LiveLogClient jest.mock('../../../../../src/lib/live-logging', () => ({ LiveLogClient: jest.fn(() => (mockLiveLogClient)), parseIpAndPort: jest.fn(() => [MOCK_IPV4, undefined]), + handleConnectionErrors: jest.fn(), })) describe('LogCatCommand', () => { - const promptMock = jest.mocked(inquirer.prompt) - const readFileMock = jest.mocked(fs.readFile) + const mockStringTranslateToId = jest.mocked(stringTranslateToId).mockResolvedValue('all') + const mockSelectGeneric = jest.mocked(selectGeneric).mockRejectedValue(new Errors.ExitError(0)) + const mockAskForRequiredString = jest.mocked(askForRequiredString).mockResolvedValue(MOCK_IPV4) + const mockPrompt = jest.mocked(inquirer.prompt) + const mockReadFile = jest.mocked(fs.readFile) + const mockGetLogSource = jest.mocked(mockLiveLogClient.getLogSource) + const mockGetDrivers = jest.mocked(mockLiveLogClient.getDrivers) + const mockParseIpAndPort = jest.mocked(parseIpAndPort) + const setTimeoutSpy = jest.spyOn(global, 'setTimeout') + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + const initSourceSpy = jest.spyOn(LogCatCommand.prototype, 'initSource').mockImplementation() + const sourceSpy = jest.spyOn(LogCatCommand.prototype, 'source', 'get').mockReturnValue({} as EventSource) + const teardownSpy = jest.spyOn(LogCatCommand.prototype, 'teardown').mockImplementation() + const errorSpy = jest.spyOn(LogCatCommand.prototype, 'error').mockImplementation() + const warnSpy = jest.spyOn(LogCatCommand.prototype, 'warn').mockImplementation() + + jest.spyOn(LogCatCommand.prototype.logger, 'debug').mockImplementation() + jest.spyOn(CliUx.ux.action, 'start').mockImplementation() + jest.spyOn(CliUx.ux.action, 'stop').mockImplementation() + jest.spyOn(CliUx.ux.action, 'pauseAsync').mockImplementation(async (fn) => { + return fn() + }) jest.spyOn(process.stdout, 'write').mockImplementation(() => true) + afterEach(() => { + jest.clearAllMocks() + }) + + describe('initialization', () => { + it('initializes SseCommand correctly', async () => { + const initSseSpy = jest.spyOn(SseCommand.prototype, 'init') + + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() + + expect(initSseSpy).toBeCalledTimes(1) + }) + + it('sets a timeout for eventsource that is cleared when connected', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + + expect(setTimeoutSpy).toBeCalledWith(expect.any(Function), 30000) + + const callback = setTimeoutSpy.mock.calls[0][0] + callback() + + expect(handleConnectionErrors).toBeCalledWith(MOCK_HOSTNAME, 'ETIMEDOUT') + + const openHandler = sourceSpy.mock.results[0].value.onopen + openHandler({} as MessageEvent) + + const timeoutID: NodeJS.Timeout = setTimeoutSpy.mock.results[0].value + + expect(clearTimeoutSpy).toBeCalledWith(timeoutID) + }) + + it('initializes a LogClient with a host verifier function and timeout', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() + + expect(LiveLogClient).toBeCalledWith( + expect.objectContaining({ + verifier: expect.any(Function), + timeout: expect.any(Number), + }), + ) + }) + + it('accepts connect-timeout flag to use with both clients', async () => { + const oneSecondTimeout = 1000 + await expect(LogCatCommand.run([`--connect-timeout=${oneSecondTimeout}`, '--all'])).resolves.not.toThrow() + + expect(LiveLogClient).toBeCalledWith( + expect.objectContaining({ + timeout: oneSecondTimeout, + }), + ) + + expect(setTimeoutSpy).toBeCalledWith(expect.any(Function), oneSecondTimeout) + }) + }) + describe('host verification', () => { const logClientMock = jest.mocked(LiveLogClient) - const warnSpy = jest.spyOn(LogCatCommand.prototype, 'warn').mockImplementation() beforeAll(() => { - logClientMock.mockImplementation((_authority, _authenticator, verifier?: HostVerifier) => ({ + logClientMock.mockImplementation((config) => ({ + ...mockLiveLogClient, getDrivers: jest.fn(async () => { - await verifier?.(MOCK_PEER_CERT) + await config.verifier?.(MOCK_PEER_CERT) return Promise.resolve([]) }), } as unknown as LiveLogClient)) + + // answer "yes" to every host verification prompt + mockPrompt.mockResolvedValue({ connect: true }) }) afterAll(() => { // reset to default mock logClientMock.mockImplementation(jest.fn(() => mockLiveLogClient)) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - it('initializes SseCommand correctly', async () => { - const initSseSpy = jest.spyOn(SseCommand.prototype, 'init') - - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) - - expect(initSseSpy).toBeCalledTimes(1) - }) - - it('initializes a LogClient with a host verifier function', async () => { - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) - - expect(LiveLogClient).toBeCalledWith(expect.any(String), expect.anything(), expect.any(Function)) + mockPrompt.mockReset() }) it('checks server identity and prompts user to validate fingerprint', async () => { - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() expect(fs.readFile).toBeCalledWith(expect.stringContaining('known_hubs.json'), 'utf-8') expect(warnSpy).toBeCalledWith(expect.stringContaining(MOCK_FINGERPRINT)) @@ -125,15 +182,17 @@ describe('LogCatCommand', () => { ) }) - it('fails when user denies connection', async () => { + it('calls command error when user denies connection', async () => { // user answers "no" this time - promptMock.mockResolvedValueOnce({ connect: false }) + mockPrompt.mockResolvedValueOnce({ connect: false }) - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow('Hub verification failed.') + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() + + expect(errorSpy).toBeCalledWith('Hub verification failed.') }) it('caches host details when user confirms connection', async () => { - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() expect(fs.writeFile).toBeCalledWith(expect.stringContaining('known_hubs.json'), MOCK_KNOWN_HUBS) }) @@ -155,9 +214,9 @@ describe('LogCatCommand', () => { ...JSON.parse(MOCK_KNOWN_HUBS), } - readFileMock.mockResolvedValueOnce(knownHubsRead) + mockReadFile.mockResolvedValueOnce(knownHubsRead) - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() expect(fs.writeFile).toBeCalledWith(expect.stringContaining('known_hubs.json'), JSON.stringify(knownHubsWrite)) }) @@ -171,9 +230,9 @@ describe('LogCatCommand', () => { }, }) - readFileMock.mockResolvedValueOnce(knownHubsRead) + mockReadFile.mockResolvedValueOnce(knownHubsRead) - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() expect(warnSpy).toBeCalledWith(expect.stringContaining(MOCK_FINGERPRINT)) expect(inquirer.prompt).toBeCalledWith( @@ -185,30 +244,191 @@ describe('LogCatCommand', () => { }) it('skips user verification on known fingerprint', async () => { - readFileMock.mockResolvedValueOnce(MOCK_KNOWN_HUBS) + mockReadFile.mockResolvedValueOnce(MOCK_KNOWN_HUBS) - await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).rejects.toThrow(new Errors.ExitError(0)) + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() expect(warnSpy).not.toBeCalled() expect(inquirer.prompt).not.toBeCalled() }) }) - it.todo('should exit gracefully when no drivers found to list') + it('should exit gracefully when no drivers found to list', async () => { + const catchSpy = jest.spyOn(LogCatCommand.prototype, 'catch') - it.todo('should warn when --all is specified and no drivers currently installed') + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`])).resolves.not.toThrow() - it.todo('happy path all with hub-address specified') + expect(catchSpy).toBeCalledWith(new Errors.ExitError(0)) + }) - it.todo('happy path all with no hub-address specified') + it('should warn after connection when --all specified and no drivers installed', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() - it.todo('happy path deviceId specified') + const openHandler = sourceSpy.mock.results[0].value.onopen + openHandler({} as MessageEvent) - it.todo('happy path deviceId not specified') + expect(warnSpy).toBeCalledWith('No drivers currently installed.') + }) + + it('uses correct source URL when --all is specified', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() - it.todo('failure to list drivers on hub') + expect(mockLiveLogClient.getLogSource).toBeCalledTimes(1) + expect(mockLiveLogClient.getLogSource).toBeCalledWith() + expect(initSourceSpy).toBeCalledWith(MOCK_SOURCE_URL) + }) - it.todo('failure connecting to SSE endpoint') + it('prompts user for hub address when not specified', async () => { + await expect(LogCatCommand.run(['--all'])).resolves.not.toThrow() - it.todo('failure parsing hub-address') + expect(mockAskForRequiredString).toBeCalledWith('Enter hub IP address with optionally appended port number:') + }) + + it('uses correct source URL when driverId is specified', async () => { + const driverId = 'driverId' + const driverLogSource = `${MOCK_SOURCE_URL}?driver_id=${driverId}` + mockStringTranslateToId.mockResolvedValueOnce(driverId) + mockSelectGeneric.mockResolvedValueOnce(driverId) + mockGetLogSource.mockReturnValueOnce(driverLogSource) + + await expect(LogCatCommand.run([driverId])).resolves.not.toThrow() + + expect(mockGetLogSource).toBeCalledTimes(1) + expect(mockGetLogSource).toBeCalledWith('driverId') + expect(initSourceSpy).toBeCalledWith(driverLogSource) + }) + + it('prompts user to select an installed driver when not specified', async () => { + const driverList = [{}] as DriverInfo[] + const driverId = 'driverId' + mockGetDrivers.mockResolvedValueOnce(driverList) + mockStringTranslateToId.mockResolvedValueOnce(undefined) + mockPrompt.mockResolvedValueOnce({ itemIdOrIndex: driverId }) + + await expect(LogCatCommand.run([])).resolves.not.toThrow() + + const expectedConfig = expect.objectContaining({ + itemName: 'driver', + primaryKeyName: 'driver_id', + sortKeyName: 'driver_name', + }) + + expect(mockStringTranslateToId).toBeCalledWith( + expectedConfig, + undefined, + expect.any(Function), + ) + + const stringTranslateListDataFunction = mockStringTranslateToId.mock.calls[0][2] + + expect(await stringTranslateListDataFunction()).toStrictEqual(driverList) + + expect(mockSelectGeneric).toBeCalledWith( + expect.any(LogCatCommand), + expectedConfig, + undefined, + expect.any(Function), + expect.any(Function), + ) + + const selectGenericListDataFunction = mockSelectGeneric.mock.calls[0][3] + + expect(await selectGenericListDataFunction()).toStrictEqual(driverList) + + const selectGenericIdRetrievalFunction = mockSelectGeneric.mock.calls[0][4] + + await selectGenericIdRetrievalFunction({ primaryKeyName: 'primaryKeyName' } as Sorting, driverList) + + expect(inquirer.prompt).toBeCalledWith( + expect.objectContaining({ + type: 'input', + message: 'Enter id or index', + default: 'all', + }), + ) + + expect(convertToId).toBeCalledWith(driverId, 'primaryKeyName', driverList) + }) + + it('uses correct source URL when "all" is specified during prompt', async () => { + mockSelectGeneric.mockResolvedValueOnce('all') + + await expect(LogCatCommand.run([])).resolves.not.toThrow() + + expect(mockLiveLogClient.getLogSource).toBeCalledTimes(1) + expect(mockLiveLogClient.getLogSource).toBeCalledWith() + expect(initSourceSpy).toBeCalledWith(MOCK_SOURCE_URL) + }) + + it('throws errors from LogClient', async () => { + const timeoutError = new Errors.CLIError('Timeout') + mockGetDrivers.mockRejectedValueOnce(timeoutError) + + await expect(LogCatCommand.run([])).rejects.toThrow(timeoutError) + }) + + it('handles messages with correct event formatter', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + + const onmessage = sourceSpy.mock.results[0].value.onmessage + const liveLogMessage = {} as MessageEvent + onmessage(liveLogMessage) + + expect(logEvent).toBeCalledTimes(1) + expect(logEvent).toBeCalledWith(liveLogMessage, liveLogMessageFormatter) + }) + + describe('eventsource onerror handler', () => { + type eventSourceError = MessageEvent & Partial<{ status: number; message: string }> + + it('calls teardown on sse command', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + const onerror = sourceSpy.mock.results[0].value.onerror + + onerror({} as MessageEvent) + + expect(teardownSpy).toBeCalled() + }) + + it('calls command error if status is 401, 403', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + const onerror = sourceSpy.mock.results[0].value.onerror + + onerror({ status: 401 } as eventSourceError) + + expect(errorSpy).toBeCalledWith(expect.stringContaining('Unauthorized')) + + errorSpy.mockClear() + onerror({ status: 403 } as eventSourceError) + + expect(errorSpy).toBeCalledWith(expect.stringContaining('Unauthorized')) + }) + + it('calls handleConnectionErrors if message is defined', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + const onerror = sourceSpy.mock.results[0].value.onerror + + onerror({ message: 'something failed' } as eventSourceError) + + expect(handleConnectionErrors).toBeCalledWith(MOCK_HOSTNAME, 'something failed') + }) + + it('calls command error with generic message if unable to handle', async () => { + await expect(LogCatCommand.run([`--hub-address=${MOCK_IPV4}`, '--all'])).resolves.not.toThrow() + const onerror = sourceSpy.mock.results[0].value.onerror + + onerror({} as eventSourceError) + + expect(errorSpy).toBeCalledWith(expect.stringContaining('Unexpected error')) + }) + }) + + it('throws errors from parseIpAndPort', async () => { + const invalidError = new Errors.CLIError('Invalid IPv4 address format.') + mockParseIpAndPort.mockImplementationOnce(() => { + throw invalidError + }) + + await expect(LogCatCommand.run([])).rejects.toThrow(invalidError) + }) }) diff --git a/test/unit/lib/live-logging.test.ts b/test/unit/lib/live-logging.test.ts index 9f7b9d1..da14bb0 100644 --- a/test/unit/lib/live-logging.test.ts +++ b/test/unit/lib/live-logging.test.ts @@ -1,4 +1,4 @@ -import { DriverInfo, DriverInfoStatus, handleConnectionErrors, LiveLogClient, LiveLogMessage, liveLogMessageFormatter, LogLevel, parseIpAndPort } from '../../../src/lib/live-logging' +import { DriverInfo, DriverInfoStatus, handleConnectionErrors, LiveLogClient, LiveLogClientConfig, LiveLogMessage, liveLogMessageFormatter, LogLevel, parseIpAndPort } from '../../../src/lib/live-logging' import stripAnsi from 'strip-ansi' import axios, { AxiosResponse } from 'axios' import { BearerTokenAuthenticator, NoOpAuthenticator } from '@smartthings/core-sdk' @@ -107,13 +107,21 @@ describe('live-logging', () => { }) describe('LiveLogClient', () => { + const axiosRequestSpy = jest.spyOn(axios, 'request') + const authority = '192.168.0.1:9495' - const token = 'token' const authenticator = new NoOpAuthenticator() + const timeout = 1000 + const token = 'token' + const config: LiveLogClientConfig = { + authority, + authenticator, + timeout, + } let testClient: LiveLogClient beforeEach(() => { - testClient = new LiveLogClient(authority, authenticator) + testClient = new LiveLogClient(config) }) afterEach(() => { @@ -134,7 +142,11 @@ describe('live-logging', () => { it('calls drivers endpoint with auth and timeout', async () => { const bearerAuthenticator = new BearerTokenAuthenticator(token) - testClient = new LiveLogClient(authority, bearerAuthenticator) + const bearerConfig = { + ...config, + authenticator: bearerAuthenticator, + } + testClient = new LiveLogClient(bearerConfig) const axiosResponse: AxiosResponse = { status: 200, @@ -157,14 +169,28 @@ describe('live-logging', () => { ], } - const axiosSpy = jest.spyOn(axios, 'request').mockResolvedValueOnce(axiosResponse) + axiosRequestSpy.mockResolvedValueOnce(axiosResponse) await testClient.getDrivers() - expect(axiosSpy).toBeCalledWith( + expect(axiosRequestSpy).toBeCalledWith( expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${token}` }), - timeout: expect.any(Number), + timeout: timeout, + }), + ) + }) + + it('calls axios with transitional options to enable ETIMEDOUT', async () => { + axiosRequestSpy.mockResolvedValueOnce({ data: [] }) + + await testClient.getDrivers() + + expect(axiosRequestSpy).toBeCalledWith( + expect.objectContaining({ + transitional: expect.objectContaining({ + clarifyTimeoutError: true, + }), }), ) }) @@ -193,7 +219,7 @@ describe('live-logging', () => { toJSON: () => ({}), } - jest.spyOn(axios, 'request').mockRejectedValueOnce(axiosError) + axiosRequestSpy.mockRejectedValueOnce(axiosError) await expect(testClient.getDrivers()).rejects.toThrow('Ensure hub address is correct and try again') }) @@ -215,8 +241,12 @@ describe('live-logging', () => { } const mockHostVerifier = jest.fn() - testClient = new LiveLogClient(authority, authenticator, mockHostVerifier) - jest.spyOn(axios, 'request').mockResolvedValue(certResponse) + const verifierConfig = { + ...config, + verifier: mockHostVerifier, + } + testClient = new LiveLogClient(verifierConfig) + axiosRequestSpy.mockResolvedValue(certResponse) await testClient.getDrivers()