diff --git a/packages/edge/src/__tests__/commands/edge/drivers/package.test.ts b/packages/edge/src/__tests__/commands/edge/drivers/package.test.ts deleted file mode 100644 index 2021a00f..00000000 --- a/packages/edge/src/__tests__/commands/edge/drivers/package.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import fs from 'fs' - -import JSZip from 'jszip' - -import { ChannelsEndpoint, DriverChannelDetails, DriversEndpoint, EdgeDriver, HubdevicesEndpoint } - from '@smartthings/core-sdk' - -import { outputItem, readFile } from '@smartthings/cli-lib' - -import PackageCommand from '../../../../commands/edge/drivers/package.js' -import { chooseChannel } from '../../../../lib/commands/channels-util.js' -import { chooseHub } from '../../../../lib/commands/drivers-util.js' -import { buildTestFileMatchers, processConfigFile, processFingerprintsFile, processProfiles, - processSrcDir, resolveProjectDirName } from '../../../../lib/commands/drivers/package-util.js' - - -jest.mock('fs', () => { - // if this isn't done, something breaks with sub-dependency 'fs-extra' - const originalLib = jest.requireActual('fs') - - return { - ...originalLib, - createWriteStream: jest.fn(), - promises: { - readFile: jest.fn(() => { - const error: NodeJS.ErrnoException = new Error() - error.code = 'ENOENT' - throw error - }), - writeFile: jest.fn(), - }, - } -}) -jest.mock('jszip') - -jest.mock('@smartthings/cli-lib', () => { - const originalLib = jest.requireActual('@smartthings/cli-lib') - - return { - ...originalLib, - outputItem: jest.fn(), - readFile: jest.fn(), - } -}) -jest.mock('../../../../../src/lib/commands/channels-util') -jest.mock('../../../../../src/lib/commands/drivers-util') -jest.mock('../../../../../src/lib/commands/drivers/package-util') - -describe('PackageCommand', () => { - const zipContents = {} as Uint8Array - const jsZipMock = jest.mocked(JSZip) - const pipeMock = jest.fn() - const readableStream = { - pipe: pipeMock, - } as unknown as NodeJS.ReadableStream - const generateNodeStreamMock = jest.fn().mockReturnValue(readableStream) - const generateAsyncMock = jest.fn().mockReturnValue(zipContents) - const mockJSZip = { - generateNodeStream: generateNodeStreamMock, - generateAsync: generateAsyncMock, - } as unknown as JSZip - - const resolveProjectDirNameMock = jest.mocked(resolveProjectDirName) - const processConfigFileMock = jest.mocked(processConfigFile) - const processFingerprintsFileMock = jest.mocked(processFingerprintsFile) - const buildTestFileMatchersMock = jest.mocked(buildTestFileMatchers) - const processSrcDirMock = jest.mocked(processSrcDir) - const processProfilesMock = jest.mocked(processProfiles) - - const driver = { driverId: 'driver id', version: 'driver version' } as EdgeDriver - const outputItemMock = jest.mocked(outputItem) - .mockImplementation(async (_command, _config, actionFunction): Promise => { - await actionFunction() - return Promise.resolve(driver) - }) - const uploadSpy = jest.spyOn(DriversEndpoint.prototype, 'upload').mockResolvedValue(driver) - - const chooseChannelMock = jest.mocked(chooseChannel) - .mockResolvedValue('channel id') - const assignDriverSpy = jest.spyOn(ChannelsEndpoint.prototype, 'assignDriver') - .mockResolvedValue({} as DriverChannelDetails) - - const chooseHubSpy = jest.mocked(chooseHub).mockResolvedValue('hub id') - const installDriverSpy = jest.spyOn(HubdevicesEndpoint.prototype, 'installDriver').mockResolvedValue() - - const logSpy = jest.spyOn(PackageCommand.prototype, 'log').mockImplementation() - - const mockProjectDirectoryProcessing = (): void => { - resolveProjectDirNameMock.mockResolvedValueOnce('project dir') - jsZipMock.mockReturnValueOnce(mockJSZip) - processConfigFileMock.mockResolvedValueOnce({}) - processFingerprintsFileMock.mockImplementation() - buildTestFileMatchersMock.mockReturnValueOnce([]) - processSrcDirMock.mockResolvedValue(true) - processProfilesMock.mockImplementation() - } - const expectProjectDirectoryProcessing = (): void => { - expect(resolveProjectDirNameMock).toHaveBeenCalledTimes(1) - expect(resolveProjectDirNameMock).toHaveBeenCalledWith('.') - expect(processConfigFileMock).toHaveBeenCalledTimes(1) - expect(processConfigFileMock).toHaveBeenCalledWith('project dir', mockJSZip) - expect(processFingerprintsFileMock).toHaveBeenCalledTimes(1) - expect(processFingerprintsFileMock).toHaveBeenCalledWith('project dir', mockJSZip) - expect(buildTestFileMatchersMock).toHaveBeenCalledTimes(1) - expect(buildTestFileMatchersMock).toHaveBeenCalledWith(['test/**', 'tests/**']) - expect(processSrcDirMock).toHaveBeenCalledTimes(1) - expect(processSrcDirMock).toHaveBeenCalledWith('project dir', mockJSZip, []) - expect(processProfilesMock).toHaveBeenCalledTimes(1) - expect(processProfilesMock).toHaveBeenCalledWith('project dir', mockJSZip) - } - - it('generates zip file with --build-only', async () => { - const writeStreamOnMock = jest.fn() - const writeStreamMock: fs.WriteStream = { - on: writeStreamOnMock, - } as unknown as fs.WriteStream - const createWriteStreamMock = jest.mocked(fs.createWriteStream) - - mockProjectDirectoryProcessing() - createWriteStreamMock.mockReturnValueOnce(writeStreamMock) - pipeMock.mockReturnValueOnce(writeStreamMock) - writeStreamOnMock.mockImplementationOnce((_event, action): void => { - action() - }) - - await expect(PackageCommand.run(['--build-only', 'driver.zip'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateNodeStreamMock).toHaveBeenCalledTimes(1) - expect(generateNodeStreamMock) - .toHaveBeenCalledWith({ type: 'nodebuffer', streamFiles: true, compression: 'DEFLATE' }) - expect(createWriteStreamMock).toHaveBeenCalledTimes(1) - expect(createWriteStreamMock).toHaveBeenCalledWith('driver.zip') - expect(pipeMock).toHaveBeenCalledTimes(1) - expect(pipeMock).toHaveBeenCalledWith(writeStreamMock) - expect(writeStreamOnMock).toHaveBeenCalledTimes(1) - expect(writeStreamOnMock).toHaveBeenCalledWith('finish', expect.any(Function)) - expect(outputItemMock).toHaveBeenCalledTimes(0) - expect(logSpy).toHaveBeenCalledWith('wrote driver.zip') - expect(uploadSpy).toHaveBeenCalledTimes(0) - }) - - it('uploads pre-built zip file', async () => { - const archiveData = {} as unknown as Buffer - const readFileMock = jest.mocked(readFile) - .mockResolvedValueOnce(archiveData) - - await expect(PackageCommand.run(['--upload', 'driver.zip'])).resolves.not.toThrow() - - expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith('driver.zip') - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(archiveData) - expect(chooseChannelMock).toHaveBeenCalledTimes(0) - expect(assignDriverSpy).toHaveBeenCalledTimes(0) - expect(chooseHubSpy).toHaveBeenCalledTimes(0) - expect(installDriverSpy).toHaveBeenCalledTimes(0) - }) - - it('displays error message when zip file missing', async () => { - const readFileMock = jest.mocked(readFile) - .mockImplementationOnce(() => { throw { code: 'ENOENT' } }) - - await expect(PackageCommand.run(['--upload', 'driver.zip'])).resolves.not.toThrow() - - expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith('driver.zip') - expect(outputItemMock).toHaveBeenCalledTimes(0) - expect(logSpy).toHaveBeenCalledWith('No file named "driver.zip" found.') - }) - - it('throws unexpected error when zipping file', async () => { - const readFileMock = jest.mocked(readFile) - .mockImplementationOnce(() => { throw Error('failure') }) - - await expect(PackageCommand.run(['--upload', 'driver.zip'])).rejects.toThrow(Error('failure')) - - expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith('driver.zip') - expect(outputItemMock).toHaveBeenCalledTimes(0) - }) - - it('generates and uploads', async () => { - mockProjectDirectoryProcessing() - - await expect(PackageCommand.run(['--token', 'bearer-token'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateAsyncMock).toHaveBeenCalledTimes(1) - expect(generateAsyncMock).toHaveBeenCalledWith({ type: 'uint8array', compression: 'DEFLATE' }) - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(zipContents) - expect(chooseChannelMock).toHaveBeenCalledTimes(0) - expect(assignDriverSpy).toHaveBeenCalledTimes(0) - expect(chooseHubSpy).toHaveBeenCalledTimes(0) - expect(installDriverSpy).toHaveBeenCalledTimes(0) - }) - - it('assigns when --assign specified', async () => { - mockProjectDirectoryProcessing() - - await expect(PackageCommand.run(['--assign'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateAsyncMock).toHaveBeenCalledTimes(1) - expect(generateAsyncMock).toHaveBeenCalledWith({ type: 'uint8array', compression: 'DEFLATE' }) - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(chooseChannelMock).toHaveBeenCalledTimes(1) - expect(chooseChannelMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a channel for the driver.', - undefined, { useConfigDefault: true }) - expect(assignDriverSpy).toHaveBeenCalledTimes(1) - expect(assignDriverSpy) - .toHaveBeenCalledWith('channel id', 'driver id', 'driver version') - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(zipContents) - expect(chooseHubSpy).toHaveBeenCalledTimes(0) - expect(installDriverSpy).toHaveBeenCalledTimes(0) - }) - - it('assigns when channel specified', async () => { - mockProjectDirectoryProcessing() - - await expect(PackageCommand.run(['--channel', 'channel id arg'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateAsyncMock).toHaveBeenCalledTimes(1) - expect(generateAsyncMock).toHaveBeenCalledWith({ type: 'uint8array', compression: 'DEFLATE' }) - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(chooseChannelMock).toHaveBeenCalledTimes(1) - expect(chooseChannelMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a channel for the driver.', - 'channel id arg', { useConfigDefault: true }) - expect(assignDriverSpy).toHaveBeenCalledTimes(1) - expect(assignDriverSpy) - .toHaveBeenCalledWith('channel id', 'driver id', 'driver version') - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(zipContents) - expect(chooseHubSpy).toHaveBeenCalledTimes(0) - expect(installDriverSpy).toHaveBeenCalledTimes(0) - }) - - it('installs when --install specified', async () => { - mockProjectDirectoryProcessing() - - await expect(PackageCommand.run(['--install'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateAsyncMock).toHaveBeenCalledTimes(1) - expect(generateAsyncMock).toHaveBeenCalledWith({ type: 'uint8array', compression: 'DEFLATE' }) - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(chooseChannelMock).toHaveBeenCalledTimes(1) - expect(chooseChannelMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a channel for the driver.', - undefined, { useConfigDefault: true }) - expect(assignDriverSpy).toHaveBeenCalledTimes(1) - expect(assignDriverSpy) - .toHaveBeenCalledWith('channel id', 'driver id', 'driver version') - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(zipContents) - expect(chooseHubSpy).toHaveBeenCalledTimes(1) - expect(chooseHubSpy) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a hub to install to.', - undefined, { useConfigDefault: true }) - expect(installDriverSpy).toHaveBeenCalledTimes(1) - expect(installDriverSpy).toHaveBeenCalledWith('driver id', 'hub id', 'channel id') - }) - - it('installs when hub specified', async () => { - mockProjectDirectoryProcessing() - - await expect(PackageCommand.run(['--hub', 'hub id arg'])).resolves.not.toThrow() - - expectProjectDirectoryProcessing() - expect(generateAsyncMock).toHaveBeenCalledTimes(1) - expect(generateAsyncMock).toHaveBeenCalledWith({ type: 'uint8array', compression: 'DEFLATE' }) - expect(outputItemMock).toHaveBeenCalledTimes(1) - expect(outputItemMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), expect.anything(), expect.any(Function)) - expect(chooseChannelMock).toHaveBeenCalledTimes(1) - expect(chooseChannelMock) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a channel for the driver.', - undefined, { useConfigDefault: true }) - expect(assignDriverSpy).toHaveBeenCalledTimes(1) - expect(assignDriverSpy) - .toHaveBeenCalledWith('channel id', 'driver id', 'driver version') - expect(uploadSpy).toHaveBeenCalledTimes(1) - expect(uploadSpy).toHaveBeenCalledWith(zipContents) - expect(chooseHubSpy).toHaveBeenCalledTimes(1) - expect(chooseHubSpy) - .toHaveBeenCalledWith(expect.any(PackageCommand), 'Select a hub to install to.', - 'hub id arg', { useConfigDefault: true }) - expect(installDriverSpy).toHaveBeenCalledTimes(1) - expect(installDriverSpy).toHaveBeenCalledWith('driver id', 'hub id', 'channel id') - }) -}) diff --git a/packages/edge/src/commands/edge/drivers/package.ts b/packages/edge/src/commands/edge/drivers/package.ts deleted file mode 100644 index e3ac3f9c..00000000 --- a/packages/edge/src/commands/edge/drivers/package.ts +++ /dev/null @@ -1,155 +0,0 @@ -import fs from 'fs' - -import { Flags } from '@oclif/core' -import JSZip from 'jszip' - -import { outputItem, OutputItemConfig, readFile } from '@smartthings/cli-lib' - -import { - buildTestFileMatchers, - processConfigFile, - processFingerprintsFile, - processProfiles, - processSearchParametersFile, - processSrcDir, - resolveProjectDirName, -} from '../../../lib/commands/drivers/package-util.js' -import { chooseChannel } from '../../../lib/commands/channels-util.js' -import { chooseHub } from '../../../lib/commands/drivers-util.js' -import { EdgeCommand } from '../../../lib/edge-command.js' -import { EdgeDriver } from '@smartthings/core-sdk' - - -export default class PackageCommand extends EdgeCommand { - static description = 'build and upload an edge package' + - this.apiDocsURL('uploadDriverPackage') - - static args = [{ - name: 'projectDirectory', - description: 'directory containing project to upload', - default: '.', - }] - - static flags = { - ...EdgeCommand.flags, - ...outputItem.flags, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'build-only': Flags.string({ - char: 'b', - description: 'save package to specified zip file but skip upload', - exclusive: ['upload'], - }), - upload: Flags.string({ - char: 'u', - description: 'upload zip file previously built with --build flag', - exclusive: ['build-only'], - }), - assign: Flags.boolean({ - char: 'a', - description: 'prompt for a channel (or use default if previously specified) to assign the driver to ' + - 'after upload', - exclusive: ['channel', 'build-only'], - }), - channel: Flags.string({ - description: 'automatically assign driver to specified channel after upload', - exclusive: ['assign', 'build-only'], - helpValue: '', - }), - install: Flags.boolean({ - char: 'I', - description: 'prompt for hub (or use default if previously specified) to install to after assigning it ' + - 'to the channel, implies --assign if --assign or --channel not included', - exclusive: ['hub', 'build-only'], - }), - hub: Flags.string({ - description: 'automatically install driver to specified hub, implies --assign if --assign or --channel ' + - 'not included', - exclusive: ['install', 'build-only'], - helpValue: '', - }), - } - - static examples = [`# build and upload driver found in current directory: -$ smartthings edge:drivers:package - -# build and upload driver found in current directory, assign it to a channel, and install it; -# user will be prompted for channel and hub -$ smartthings edge:drivers:package -I - -# build and upload driver found in current directory then assign it to the specified channel -# and install it to the specified hub -$ smartthings edge:drivers:package --channel --hub - -# build and upload driver found in the my-driver directory -$ smartthings edge:drivers:package my-driver - -# build the driver in the my-package directory and save it as driver.zip -$ smartthings edge:drivers:package -b driver.zip my-package`, - ` -# upload the previously built driver found in driver.zip -$ smartthings edge:drivers:package -u driver.zip`] - - async run(): Promise { - const uploadAndPostProcess = async (archiveData: Uint8Array): Promise => { - const config: OutputItemConfig = { - tableFieldDefinitions: ['driverId', 'name', 'packageKey', 'version'], - } - const driver = await outputItem(this, config, () => this.client.drivers.upload(archiveData)) - const doAssign = this.flags.assign || this.flags.channel || this.flags.install || this.flags.hub - const doInstall = this.flags.install || this.flags.hub - if (doAssign) { - const driverId = driver.driverId - const version = driver.version - const channelId = await chooseChannel(this, 'Select a channel for the driver.', - this.flags.channel, { useConfigDefault: true }) - await this.client.channels.assignDriver(channelId, driverId, version) - this.log(`assigned driver ${driverId} ${version} to channel ${channelId}`) - - if (doInstall) { - const hubId = await chooseHub(this, 'Select a hub to install to.', this.flags.hub, - { useConfigDefault: true }) - await this.client.hubdevices.installDriver(driverId, hubId, channelId) - this.log(`installed driver ${driverId} ${version} to hub ${hubId}`) - } - } - } - - if (this.flags.upload) { - try { - const data = await readFile(this.flags.upload) - await uploadAndPostProcess(data) - } catch (error) { - if ((error as { code?: string }).code === 'ENOENT') { - this.log(`No file named "${this.flags.upload}" found.`) - } else { - throw error - } - } - } else { - const projectDirectory = await resolveProjectDirName(this.args.projectDirectory) - - const zip = new JSZip() - await processConfigFile(projectDirectory, zip) - - await processFingerprintsFile(projectDirectory, zip) - await processSearchParametersFile(projectDirectory, zip) - const edgeDriverTestDirs = this.stringArrayConfigValue('edgeDriverTestDirs', ['test/**', 'tests/**']) - const testFileMatchers = buildTestFileMatchers(edgeDriverTestDirs) - if (!await processSrcDir(projectDirectory, zip, testFileMatchers)) { - this.exit(1) - } - - await processProfiles(projectDirectory, zip) - if (this.flags['build-only']) { - zip.generateNodeStream({ type: 'nodebuffer', streamFiles: true, compression: 'DEFLATE' }) - .pipe(fs.createWriteStream(this.flags['build-only'])) - .on('finish', () => { - this.log(`wrote ${this.flags['build-only']}`) - }) - } else { - const zipContents = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }) - await uploadAndPostProcess(zipContents) - } - } - } -} diff --git a/src/__tests__/commands/apps/settings.test.ts b/src/__tests__/commands/apps/settings.test.ts index a4db191b..28278794 100644 --- a/src/__tests__/commands/apps/settings.test.ts +++ b/src/__tests__/commands/apps/settings.test.ts @@ -6,13 +6,13 @@ import type { AppsEndpoint, AppSettingsResponse } from '@smartthings/core-sdk' import type { CommandArgs } from '../../../commands/apps/settings.js' import type { APICommand, APICommandFlags } from '../../../lib/command/api-command.js' +import type { CustomCommonOutputProducer } from '../../../lib/command/format.js' import type { OutputItemOrListFlags } from '../../../lib/command/listing-io.js' import type { outputItem, outputItemBuilder } from '../../../lib/command/output-item.js' import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js' import { buildTableOutput, type chooseApp } from '../../../lib/command/util/apps-util.js' import { apiCommandMocks } from '../../test-lib/api-command-mock.js' import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js' -import { CustomCommonOutputProducer } from '../../../lib/command/format.js' import { tableGeneratorMock } from '../../test-lib/table-mock.js' diff --git a/src/__tests__/commands/edge/drivers/package.test.ts b/src/__tests__/commands/edge/drivers/package.test.ts new file mode 100644 index 00000000..20a69047 --- /dev/null +++ b/src/__tests__/commands/edge/drivers/package.test.ts @@ -0,0 +1,375 @@ +import { jest } from '@jest/globals' + +import type { createWriteStream, WriteStream } from 'node:fs' +import type { readFile } from 'node:fs/promises' + +import type JSZip from 'jszip' +import type { Matcher } from 'picomatch' +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type { + ChannelsEndpoint, + DriversEndpoint, + EdgeDriver, + HubdevicesEndpoint, +} from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../../../commands/edge/drivers/package.js' +import type { CLIConfig } from '../../../../lib/cli-config.js' +import type { fatalError } from '../../../../lib/util.js' +import type { APICommand } from '../../../../lib/command/api-command.js' +import type { outputItem, outputItemBuilder } from '../../../../lib/command/output-item.js' +import type { SmartThingsCommandFlags } from '../../../../lib/command/smartthings-command.js' +import type { + buildTestFileMatchers, + processConfigFile, + processFingerprintsFile, + processProfiles, + processSearchParametersFile, + processSrcDir, + resolveProjectDirName, +} from '../../../../lib/command/util/edge-driver-package.js' +import type { chooseHub } from '../../../../lib/command/util/hubs-choose.js' +import type { chooseChannel } from '../../../../lib/command/util/edge/channels-choose.js' +import { apiCommandMocks } from '../../../test-lib/api-command-mock.js' +import { buildArgvMock, buildArgvMockStub } from '../../../test-lib/builder-mock.js' + + +const fatalErrorMock = jest.fn() +jest.unstable_mockModule('../../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) + +const createWriteStreamMock = jest.fn() +jest.unstable_mockModule('node:fs', () => ({ + createWriteStream: createWriteStreamMock, +})) + +const readFileMock = jest.fn().mockResolvedValue('zip file contents') +jest.unstable_mockModule('node:fs/promises', () => ({ + readFile: readFileMock, +})) + +const zipContents = {} as Uint8Array +const pipeMock = jest.fn() +const readableStream = { + pipe: pipeMock, +} as unknown as NodeJS.ReadableStream +const generateAsyncMock = jest.fn().mockResolvedValue(zipContents) +const generateNodeStreamMock = jest.fn().mockReturnValue(readableStream) +const zipMock = { + generateAsync: generateAsyncMock, + generateNodeStream: generateNodeStreamMock, +} as unknown as JSZip +// eslint-disable-next-line @typescript-eslint/naming-convention +const JSZipMock = jest.fn().mockReturnValue(zipMock) +jest.unstable_mockModule('jszip', () => ({ + default: JSZipMock, +})) + +const { apiCommandMock, apiCommandBuilderMock, apiDocsURLMock } = apiCommandMocks('../../../..') + +const outputItemMock = jest.fn() +const outputItemBuilderMock = jest.fn() +jest.unstable_mockModule('../../../../lib/command/output-item.js', () => ({ + outputItem: outputItemMock, + outputItemBuilder: outputItemBuilderMock, +})) + +const testFileMatcherMock = jest.fn() +const buildTestFileMatchersMock = jest.fn() + .mockReturnValue([testFileMatcherMock as unknown as Matcher]) +const processConfigFileMock = jest.fn() +const processFingerprintsFileMock = jest.fn() +const processProfilesMock = jest.fn() +const processSearchParametersFileMock = jest.fn() +const processSrcDirMock = jest.fn().mockResolvedValue(true) +const resolveProjectDirNameMock = jest.fn().mockResolvedValue('project dir') +jest.unstable_mockModule('../../../../lib/command/util/edge-driver-package.js', () => ({ + buildTestFileMatchers: buildTestFileMatchersMock, + processConfigFile: processConfigFileMock, + processFingerprintsFile: processFingerprintsFileMock, + processProfiles: processProfilesMock, + processSearchParametersFile: processSearchParametersFileMock, + processSrcDir: processSrcDirMock, + resolveProjectDirName: resolveProjectDirNameMock, +})) + +const chooseHubMock = jest.fn().mockResolvedValue('chosen-hub-id') +jest.unstable_mockModule('../../../../lib/command/util/hubs-choose.js', () => ({ + chooseHub: chooseHubMock, +})) + +const chooseChannelMock = jest.fn().mockResolvedValue('chosen-channel-id') +jest.unstable_mockModule('../../../../lib/command/util/edge/channels-choose.js', () => ({ + chooseChannel: chooseChannelMock, +})) + +// fake exiting with a special thrown error +const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { throw Error('exit called') }) + +const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { /* do nothing */ }) + + +const { default: cmd } = await import('../../../../commands/edge/drivers/package.js') + + +test('builder', () => { + const yargsMock = buildArgvMockStub() + const { + yargsMock: apiCommandBuilderArgvMock, + positionalMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock) + outputItemBuilderMock.mockReturnValue(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(outputItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock) + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(6) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(apiDocsURLMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +describe('handler', () => { + const apiChannelsAssignDriverMock = jest.fn() + const apiDriversUploadMock = jest.fn() + const apiHubDevicesInstallDriverMock = jest.fn() + const stringArrayConfigValueMock = jest.fn().mockReturnValue(['test dir']) + const command = { + client: { + channels: { + assignDriver: apiChannelsAssignDriverMock, + }, + drivers: { + upload: apiDriversUploadMock, + }, + hubdevices: { + installDriver: apiHubDevicesInstallDriverMock, + }, + }, + cliConfig: { + stringArrayConfigValue: stringArrayConfigValueMock, + }, + } as unknown as APICommand> + apiCommandMock.mockResolvedValue(command) + + const driver = { driverId: 'driver-id', version: 'driver version' } as EdgeDriver + apiDriversUploadMock.mockResolvedValue(driver) + outputItemMock.mockResolvedValue(driver) + + it('uploads previously generated zip file', async () => { + const inputArgv = { profile: 'default', upload: 'driver.zip' } as ArgumentsCamelCase + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv) + expect(readFileMock).toHaveBeenCalledExactlyOnceWith('driver.zip') + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(resolveProjectDirNameMock).not.toHaveBeenCalled() + expect(chooseChannelMock).not.toHaveBeenCalled() + + const getData = outputItemMock.mock.calls[0][2] + + expect(await getData()).toBe(driver) + + expect(apiDriversUploadMock).toHaveBeenCalledExactlyOnceWith('zip file contents') + }) + + const baseInputArgv = { profile: 'default' } as ArgumentsCamelCase + + it('informs user when upload file not found', async () => { + const inputArgv = { ...baseInputArgv, upload: 'missing.zip' } as ArgumentsCamelCase + readFileMock.mockImplementationOnce(() => { throw { code: 'ENOENT' } }) + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(readFileMock).toHaveBeenCalledExactlyOnceWith('missing.zip') + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('No file named "missing.zip" found.') + expect(outputItemMock).not.toHaveBeenCalled() + }) + + it('rethrows other read errors', async () => { + const inputArgv = { ...baseInputArgv, upload: 'driver.zip' } as ArgumentsCamelCase + readFileMock.mockImplementationOnce(() => { throw Error('badness happened') }) + + await expect(cmd.handler(inputArgv)).rejects.toThrow('badness happened') + + expect(readFileMock).toHaveBeenCalledExactlyOnceWith('driver.zip') + expect(fatalErrorMock).not.toHaveBeenCalled() + expect(outputItemMock).not.toHaveBeenCalled() + }) + + it('generates zip file with --build-only', async () => { + const inputArgv = { + profile: 'default', + buildOnly: 'driver.zip', + projectDirectory: 'driver-dir', + } as ArgumentsCamelCase + const writeStreamOnMock = jest.fn() + const writeStreamMock: WriteStream = { + on: writeStreamOnMock, + } as unknown as WriteStream + createWriteStreamMock.mockReturnValueOnce(writeStreamMock) + pipeMock.mockReturnValueOnce(writeStreamMock) + + await expect(cmd.handler(inputArgv)).resolves.not.toThrow() + + expect(resolveProjectDirNameMock).toHaveBeenCalledExactlyOnceWith('driver-dir') + expect(JSZipMock).toHaveBeenCalledExactlyOnceWith() + expect(processConfigFileMock).toHaveBeenCalledExactlyOnceWith('project dir', zipMock) + expect(processFingerprintsFileMock).toHaveBeenCalledExactlyOnceWith('project dir', zipMock) + expect(processSearchParametersFileMock).toHaveBeenCalledExactlyOnceWith('project dir', zipMock) + expect(stringArrayConfigValueMock).toHaveBeenCalledExactlyOnceWith('edgeDriverTestDirs', expect.any(Array)) + expect(buildTestFileMatchersMock).toHaveBeenCalledExactlyOnceWith(['test dir']) + expect(processSrcDirMock).toHaveBeenCalledExactlyOnceWith('project dir', zipMock, [testFileMatcherMock]) + expect(processProfilesMock).toHaveBeenCalledExactlyOnceWith('project dir', zipMock) + expect(generateNodeStreamMock).toHaveBeenCalledExactlyOnceWith( + { type: 'nodebuffer', streamFiles: true, compression: 'DEFLATE' }, + ) + expect(createWriteStreamMock).toHaveBeenCalledExactlyOnceWith('driver.zip') + expect(pipeMock).toHaveBeenCalledExactlyOnceWith(writeStreamMock) + expect(writeStreamOnMock).toHaveBeenCalledExactlyOnceWith('finish', expect.any(Function)) + + expect(outputItemMock).not.toHaveBeenCalled() + expect(exitSpy).not.toHaveBeenCalled() + + const onFinish = writeStreamOnMock.mock.calls[0][1] as () => void + + onFinish() + + expect(consoleLogSpy).toHaveBeenCalledWith('wrote driver.zip') + }) + + it('creates and uploads zip file', async () => { + await expect(cmd.handler(baseInputArgv)).resolves.not.toThrow() + + expect(generateAsyncMock).toHaveBeenCalledExactlyOnceWith({ type: 'uint8array', compression: 'DEFLATE' }) + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(generateNodeStreamMock).not.toHaveBeenCalled() + + const getData = outputItemMock.mock.calls[0][2] + + expect(await getData()).toBe(driver) + + expect(apiDriversUploadMock).toHaveBeenCalledExactlyOnceWith(zipContents) + }) + + it('exits when processing source directory fails', async () => { + processSrcDirMock.mockResolvedValueOnce(false) + + await expect(cmd.handler(baseInputArgv)).rejects.toThrow('exit called') + }) + + it('assigns when --assign specified', async () => { + await expect(cmd.handler({ ...baseInputArgv, assign: true })).resolves.not.toThrow() + + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(chooseChannelMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + expect.objectContaining({ useConfigDefault: true }), + ) + expect(apiChannelsAssignDriverMock) + .toHaveBeenCalledExactlyOnceWith('chosen-channel-id', 'driver-id', 'driver version') + expect(consoleLogSpy) + .toHaveBeenCalledWith('Assigned driver driver-id (version driver version) to channel chosen-channel-id.') + + expect(apiHubDevicesInstallDriverMock).not.toHaveBeenCalled() + }) + + it('assigns when --channel specified', async () => { + await expect(cmd.handler({ ...baseInputArgv, channel: 'cmd-line-channel' })).resolves.not.toThrow() + + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(chooseChannelMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-channel', + expect.objectContaining({ useConfigDefault: true }), + ) + expect(apiChannelsAssignDriverMock) + .toHaveBeenCalledExactlyOnceWith('chosen-channel-id', 'driver-id', 'driver version') + + expect(apiHubDevicesInstallDriverMock).not.toHaveBeenCalled() + }) + + it('installs when --install specified', async () => { + await expect(cmd.handler({ ...baseInputArgv, install: true })).resolves.not.toThrow() + + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(chooseChannelMock).toHaveBeenCalledExactlyOnceWith( + command, + undefined, + expect.objectContaining({ useConfigDefault: true }), + ) + expect(apiChannelsAssignDriverMock) + .toHaveBeenCalledExactlyOnceWith('chosen-channel-id', 'driver-id', 'driver version') + expect(apiHubDevicesInstallDriverMock).toHaveBeenCalledExactlyOnceWith( + 'driver-id', + 'chosen-hub-id', + 'chosen-channel-id', + ) + expect(consoleLogSpy) + .toHaveBeenCalledWith('Installed driver driver-id (version driver version) to hub chosen-hub-id.') + }) + + it('installs when hub specified', async () => { + await expect(cmd.handler({ ...baseInputArgv, hub: 'cmd-line-hub' })).resolves.not.toThrow() + + expect(outputItemMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ tableFieldDefinitions: expect.arrayContaining(['driverId', 'name']) }), + expect.any(Function), + ) + + expect(chooseHubMock).toHaveBeenCalledExactlyOnceWith( + command, + 'cmd-line-hub', + expect.objectContaining({ useConfigDefault: true }), + ) + expect(apiChannelsAssignDriverMock) + .toHaveBeenCalledExactlyOnceWith('chosen-channel-id', 'driver-id', 'driver version') + expect(apiHubDevicesInstallDriverMock).toHaveBeenCalledExactlyOnceWith( + 'driver-id', + 'chosen-hub-id', + 'chosen-channel-id', + ) + expect(consoleLogSpy) + .toHaveBeenCalledWith('Installed driver driver-id (version driver version) to hub chosen-hub-id.') + }) +}) diff --git a/packages/edge/src/__tests__/lib/commands/drivers/package-util.test.ts b/src/__tests__/lib/command/util/edge-drivers-package.test.ts similarity index 76% rename from packages/edge/src/__tests__/lib/commands/drivers/package-util.test.ts rename to src/__tests__/lib/command/util/edge-drivers-package.test.ts index 5ea91615..6455dc6b 100644 --- a/packages/edge/src/__tests__/lib/commands/drivers/package-util.test.ts +++ b/src/__tests__/lib/command/util/edge-drivers-package.test.ts @@ -1,10 +1,12 @@ -import fs from 'fs' +import { jest } from '@jest/globals' -import { CliUx, Errors } from '@oclif/core' -import JSZip from 'jszip' -import picomatch from 'picomatch' +import type { createReadStream, Dirent, readdirSync, ReadStream } from 'node:fs' -import { +import type JSZip from 'jszip' +import type picomatch from 'picomatch' +import type { Matcher, Result } from 'picomatch' + +import type { fileExists, findYAMLFilename, isDir, @@ -14,7 +16,56 @@ import { realPathForSymbolicLink, requireDir, } from '../../../../lib/file-util.js' -import { +import { fatalError } from '../../../../lib/util.js' + + +const readStreamMock = { path: '/goes/to/here' } as ReadStream +const createReadStreamMock = jest.fn().mockReturnValue(readStreamMock) +const readdirSyncMock = jest.fn() +jest.unstable_mockModule('node:fs', () => ({ + createReadStream: createReadStreamMock, + readdirSync: readdirSyncMock, +})) + +const zipFileMock = jest.fn() +const zipMock = { file: zipFileMock } as unknown as JSZip +jest.unstable_mockModule('jszip', () => ({ + default: zipMock, +})) + +const picomatchMock = jest.fn() +jest.unstable_mockModule('picomatch', () => ({ + default: picomatchMock, +})) + +const fileExistsMock = jest.fn() +const findYAMLFilenameMock = jest.fn() +const isDirMock = jest.fn() +const isFileMock = jest.fn() +const isSymbolicLinkMock = jest.fn() +const readYAMLFileMock = jest.fn() +const realPathForSymbolicLinkMock = jest.fn() +const requireDirMock = jest.fn() +jest.unstable_mockModule('../../../../lib/file-util.js', () => ({ + fileExists: fileExistsMock, + findYAMLFilename: findYAMLFilenameMock, + isDir: isDirMock, + isFile: isFileMock, + isSymbolicLink: isSymbolicLinkMock, + readYAMLFile: readYAMLFileMock, + realPathForSymbolicLink: realPathForSymbolicLinkMock, + requireDir: requireDirMock, +})) + +const fatalErrorMock = jest.fn().mockReturnValue('never return' as never) +jest.unstable_mockModule('../../../../lib/util.js', () => ({ + fatalError: fatalErrorMock, +})) + +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { /*no-op*/ }) + + +const { buildTestFileMatchers, processConfigFile, processFingerprintsFile, @@ -23,34 +74,8 @@ import { processSearchParametersFile, processSrcDir, resolveProjectDirName, -} from '../../../../lib/commands/drivers/package-util.js' - - -jest.mock('fs') -jest.mock('js-yaml') -jest.mock('picomatch') -jest.mock('../../../../../src/lib/file-util') - -// For some reason jest.mocked produces the wrong type signature for readdirSyncMock -const readdirSyncMock = fs.readdirSync as unknown as jest.Mock -const readStreamMock: fs.ReadStream = {} as fs.ReadStream -const createReadStreamMock = jest.mocked(fs.createReadStream) - -const isFileMock = jest.mocked(isFile) -const isDirMock = jest.mocked(isDir) -const findYAMLFilenameMock = jest.mocked(findYAMLFilename) -const requireDirMock = jest.mocked(requireDir) -const readYAMLFileMock = jest.mocked(readYAMLFile) -const fileExistsMock = jest.mocked(fileExists) -const isSymbolicLinkMock = jest.mocked(isSymbolicLink) -const realPathForSymbolicLinkMock = jest.mocked(realPathForSymbolicLink) +} = await import('../../../../lib/command/util/edge-driver-package.js') -const zipFileMock = jest.fn() -const zipMock = { - file: zipFileMock, -} as unknown as JSZip - -const errorSpy = jest.spyOn(CliUx.ux, 'error').mockImplementation() describe('resolveProjectDirName', () => { it('returns directory from arg if it exists', async () => { @@ -74,7 +99,9 @@ describe('resolveProjectDirName', () => { it('throws exception if directory does not exist', async () => { isDirMock.mockResolvedValueOnce(false) - await expect(resolveProjectDirName('my-bad-directory')).rejects.toThrow(Errors.CLIError) + expect(await resolveProjectDirName('my-bad-directory')).toBe('never return') + + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('my-bad-directory must exist and be a directory') }) }) @@ -100,19 +127,20 @@ describe('processConfigFile', () => { it('throws error when config file is missing', async () => { findYAMLFilenameMock.mockResolvedValueOnce(false) - await expect(processConfigFile('my-project-dir', zipMock)) - .rejects.toThrow(new Errors.CLIError('missing main config.yaml (or config.yml) file')) + expect(await processConfigFile('my-project-dir', zipMock)).toBe('never return') expect(findYAMLFilenameMock).toHaveBeenCalledTimes(1) expect(findYAMLFilenameMock).toHaveBeenCalledWith('my-project-dir/config') + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('missing main config.yaml (or config.yml) file') expect(readYAMLFileMock).toHaveBeenCalledTimes(0) expect(zipFileMock).toHaveBeenCalledTimes(0) }) }) describe('processOptionalYAMLFile', () => { + findYAMLFilenameMock.mockResolvedValue('yaml filename') + it('includes fingerprint file if found', async () => { - findYAMLFilenameMock.mockResolvedValueOnce('yaml filename') readYAMLFileMock.mockReturnValueOnce({ yaml: 'file contents' }) createReadStreamMock.mockReturnValueOnce(readStreamMock) @@ -138,32 +166,25 @@ describe('processOptionalYAMLFile', () => { expect(readYAMLFileMock).toHaveBeenCalledTimes(0) expect(zipFileMock).toHaveBeenCalledTimes(0) }) -}) - -test('processFingerprintsFile calls processOptionalYAMLFile', async () => { - const processOptionalYAMLFileSpy = jest.spyOn(packageUtilModule, 'processOptionalYAMLFile') - .mockImplementationOnce(async () => { /* do nothing */ }) - await processFingerprintsFile('project dir', zipMock) + test('processFingerprintsFile', async () => { + await processFingerprintsFile('project dir', zipMock) - expect(processOptionalYAMLFileSpy).toHaveBeenCalledTimes(1) - expect(processOptionalYAMLFileSpy).toHaveBeenCalledWith('fingerprints', 'project dir', zipMock) -}) - -test('processSearchParametersFile calls processOptionalYAMLFile', async () => { - const processOptionalYAMLFileSpy = jest.spyOn(packageUtilModule, 'processOptionalYAMLFile') - .mockImplementationOnce(async () => { /* do nothing */ }) + expect(findYAMLFilenameMock).toHaveBeenCalledExactlyOnceWith('project dir/fingerprints') + expect(zipFileMock).toHaveBeenCalledWith('fingerprints.yml', readStreamMock) + }) - await processSearchParametersFile('project dir', zipMock) + test('processSearchParametersFile', async () => { + await processSearchParametersFile('project dir', zipMock) - expect(processOptionalYAMLFileSpy).toHaveBeenCalledTimes(1) - expect(processOptionalYAMLFileSpy).toHaveBeenCalledWith('search-parameters', 'project dir', zipMock) + expect(findYAMLFilenameMock).toHaveBeenCalledExactlyOnceWith('project dir/search-parameters') + expect(zipFileMock).toHaveBeenCalledWith('search-parameters.yml', readStreamMock) + }) }) test('buildTestFileMatchers converts to matchers', () => { - const picomatchMock = jest.mocked(picomatch) - const matcher1 = (): boolean => true - const matcher2 = (): boolean => false + const matcher1 = ((): boolean => true) as unknown as Matcher + const matcher2 = ((): boolean => false) as unknown as Matcher picomatchMock.mockReturnValueOnce(matcher1) picomatchMock.mockReturnValueOnce(matcher2) @@ -182,19 +203,19 @@ describe('processSrcDir', () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(false) - await expect(() => processSrcDir('project dir', zipMock, [])) - .rejects.toThrow(Errors.CLIError) + expect(await processSrcDir('project dir', zipMock, [])).toBe('never return') expect(requireDirMock).toHaveBeenCalledTimes(1) expect(requireDirMock).toHaveBeenCalledWith('project dir/src') expect(isFileMock).toHaveBeenCalledTimes(1) expect(isFileMock).toHaveBeenCalledWith('src dir/init.lua') + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith('missing required src dir/init.lua file') }) it('includes files at top level', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['init.lua']) + readdirSyncMock.mockReturnValueOnce(['init.lua' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(true) // init.lua isSymbolicLinkMock.mockResolvedValueOnce(false) isDirMock.mockResolvedValueOnce(false) // init.lua is not a directory @@ -224,7 +245,9 @@ describe('processSrcDir', () => { it('includes nested files', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['init.lua', 'subdirectory']) + readdirSyncMock.mockReturnValueOnce( + ['init.lua', 'subdirectory'] as unknown as Dirent>[], + ) fileExistsMock.mockResolvedValueOnce(true) // init.lua isSymbolicLinkMock.mockResolvedValueOnce(false) // init.lua isDirMock.mockResolvedValueOnce(false) // init.lua is not a directory @@ -232,7 +255,7 @@ describe('processSrcDir', () => { fileExistsMock.mockResolvedValueOnce(true) // subdirectory isSymbolicLinkMock.mockResolvedValueOnce(false) // subdirectory isDirMock.mockResolvedValueOnce(true) // subdirectory is a directory - readdirSyncMock.mockReturnValueOnce(['lib.lua']) + readdirSyncMock.mockReturnValueOnce(['lib.lua' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(true) // lib.lua isDirMock.mockResolvedValueOnce(false) // lib.lua createReadStreamMock.mockReturnValueOnce(readStreamMock) // lib.lua @@ -262,7 +285,7 @@ describe('processSrcDir', () => { it('follows sym links to files', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['file link']) + readdirSyncMock.mockReturnValueOnce(['file link' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(true) isSymbolicLinkMock.mockResolvedValueOnce(true) realPathForSymbolicLinkMock.mockResolvedValueOnce('real file') @@ -288,14 +311,14 @@ describe('processSrcDir', () => { expect(createReadStreamMock).toHaveBeenCalledWith('src dir/file link') expect(zipFileMock).toHaveBeenCalledTimes(1) expect(zipFileMock).toHaveBeenCalledWith('src/file link', readStreamMock) - expect(errorSpy).toHaveBeenCalledTimes(0) + expect(fatalErrorMock).toHaveBeenCalledTimes(0) }) it('skips files that match test dir pattern', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['init.lua', 'test.lua']) + readdirSyncMock.mockReturnValueOnce(['init.lua', 'test.lua'] as unknown as Dirent>[]) fileExistsMock.mockResolvedValueOnce(true) // init.lua isSymbolicLinkMock.mockResolvedValueOnce(false) // init.lua isDirMock.mockResolvedValueOnce(false) // init.lua is not a directory @@ -303,11 +326,11 @@ describe('processSrcDir', () => { isSymbolicLinkMock.mockResolvedValueOnce(false) // test.lua isDirMock.mockResolvedValueOnce(false) // test.lua is not a directory - const matcher = jest.fn() - .mockReturnValueOnce(false) // init.lua is not a test file - .mockReturnValueOnce(true) // test.lua is a test file + const matcherMock = jest.fn() + .mockReturnValueOnce(false as unknown as Result) // init.lua is not a test file + .mockReturnValueOnce(true as unknown as Result) // test.lua is a test file - expect(await processSrcDir('project dir', zipMock, [matcher])).toBe(true) + expect(await processSrcDir('project dir', zipMock, [matcherMock as unknown as Matcher])).toBe(true) expect(requireDirMock).toHaveBeenCalledTimes(1) expect(requireDirMock).toHaveBeenCalledWith('project dir/src') @@ -325,11 +348,11 @@ describe('processSrcDir', () => { expect(realPathForSymbolicLinkMock).toHaveBeenCalledTimes(0) }) - it('throws error if nesting is too deep', async () => { + it('displays error if nesting is too deep', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua exists - readdirSyncMock.mockReturnValueOnce(['init.lua', 'subdirectory']) + readdirSyncMock.mockReturnValueOnce(['init.lua', 'subdirectory'] as unknown as Dirent>[]) fileExistsMock.mockResolvedValueOnce(true) // init.lua isSymbolicLinkMock.mockResolvedValueOnce(false) // init.lua isDirMock.mockResolvedValueOnce(false) // init.lua is not a directory @@ -339,7 +362,7 @@ describe('processSrcDir', () => { // The services limit nesting to 10 but count both the main directory and the source // directory, so we only need to add a total of 9 directories to get one too many. for (let count = 1; count <= 8; count++) { - readdirSyncMock.mockReturnValueOnce(['subdirectory']) + readdirSyncMock.mockReturnValueOnce(['subdirectory' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(true) // subdirectory isSymbolicLinkMock.mockResolvedValueOnce(false) // subdirectory isDirMock.mockResolvedValueOnce(true) @@ -358,10 +381,8 @@ describe('processSrcDir', () => { expect(readdirSyncMock).toHaveBeenCalledWith(`src dir${'/subdirectory'.repeat(count)}`) expect(isDirMock).toHaveBeenCalledWith(`src dir${'/subdirectory'.repeat(count + 1)}`) } - expect(errorSpy).toHaveBeenCalledTimes(1) - expect(errorSpy).toHaveBeenCalledWith( + expect(consoleErrorSpy).toHaveBeenCalledExactlyOnceWith( `drivers directory nested too deeply (at src dir${'/subdirectory'.repeat(9)}); max depth is 10`, - { 'exit': false }, ) expect(createReadStreamMock).toHaveBeenCalledTimes(1) expect(createReadStreamMock).toHaveBeenCalledWith('src dir/init.lua') @@ -373,7 +394,7 @@ describe('processSrcDir', () => { it('logs error for sym link to directory', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['dir link']) + readdirSyncMock.mockReturnValueOnce(['dir link' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(true) isSymbolicLinkMock.mockResolvedValueOnce(true) realPathForSymbolicLinkMock.mockResolvedValueOnce('real dir') @@ -395,11 +416,8 @@ describe('processSrcDir', () => { expect(realPathForSymbolicLinkMock).toHaveBeenCalledWith('src dir/dir link') expect(isDirMock).toHaveBeenCalledTimes(1) expect(isDirMock).toHaveBeenCalledWith('real dir') - expect(errorSpy).toHaveBeenCalledTimes(1) - expect(errorSpy).toHaveBeenCalledWith( - 'sym links to directories are not allowed (src dir/dir link)', - { 'exit': false }, - ) + expect(consoleErrorSpy).toHaveBeenCalledTimes(1) + expect(consoleErrorSpy).toHaveBeenCalledWith('sym links to directories are not allowed (src dir/dir link)') expect(createReadStreamMock).toHaveBeenCalledTimes(0) expect(zipFileMock).toHaveBeenCalledTimes(0) }) @@ -407,7 +425,7 @@ describe('processSrcDir', () => { it('logs error for broken sym link', async () => { requireDirMock.mockResolvedValueOnce('src dir') isFileMock.mockResolvedValueOnce(true) // init.lua specific check - readdirSyncMock.mockReturnValueOnce(['file link']) + readdirSyncMock.mockReturnValueOnce(['file link' as unknown as Dirent>]) fileExistsMock.mockResolvedValueOnce(false) expect(await processSrcDir('project dir', zipMock, [])).toBe(false) @@ -423,11 +441,8 @@ describe('processSrcDir', () => { expect(isSymbolicLinkMock).toHaveBeenCalledTimes(0) expect(realPathForSymbolicLinkMock).toHaveBeenCalledTimes(0) expect(isDirMock).toHaveBeenCalledTimes(0) - expect(errorSpy).toHaveBeenCalledTimes(1) - expect(errorSpy).toHaveBeenCalledWith( - 'sym link src dir/file link points to non-existent file', - { 'exit': false }, - ) + expect(consoleErrorSpy).toHaveBeenCalledTimes(1) + expect(consoleErrorSpy).toHaveBeenCalledWith('sym link src dir/file link points to non-existent file') expect(createReadStreamMock).toHaveBeenCalledTimes(0) expect(zipFileMock).toHaveBeenCalledTimes(0) }) @@ -453,7 +468,9 @@ describe('processProfiles', () => { it('adds yaml files with .yml extension', async () => { requireDirMock.mockResolvedValueOnce('profiles dir') - readdirSyncMock.mockReturnValueOnce(['profile1.yml', 'profile2.yml']) + readdirSyncMock.mockReturnValueOnce( + ['profile1.yml', 'profile2.yml'] as unknown as Dirent>[], + ) createReadStreamMock.mockReturnValueOnce(readStreamMock) await expect(processProfiles('project dir', zipMock)).resolves.not.toThrow() @@ -475,7 +492,7 @@ describe('processProfiles', () => { it('adds yaml files with .yaml extension as .yml', async () => { requireDirMock.mockResolvedValueOnce('profiles dir') - readdirSyncMock.mockReturnValueOnce(['profile.yaml']) + readdirSyncMock.mockReturnValueOnce(['profile.yaml' as unknown as Dirent>]) createReadStreamMock.mockReturnValueOnce(readStreamMock) await expect(processProfiles('project dir', zipMock)).resolves.not.toThrow() @@ -494,16 +511,17 @@ describe('processProfiles', () => { it('throws exception for non-yaml files in profiles directory', async () => { requireDirMock.mockResolvedValueOnce('profiles dir') - readdirSyncMock.mockReturnValueOnce(['profile.exe']) + readdirSyncMock.mockReturnValueOnce(['profile.exe' as unknown as Dirent>]) - await expect(processProfiles('project dir', zipMock)) - .rejects - .toThrow(new Errors.CLIError('invalid profile file "profiles dir/profile.exe" (must have .yaml or .yml extension)')) + expect(await processProfiles('project dir', zipMock)).toBe('never return') expect(requireDirMock).toHaveBeenCalledTimes(1) expect(requireDirMock).toHaveBeenCalledWith('project dir/profiles') expect(readdirSyncMock).toHaveBeenCalledTimes(1) expect(readdirSyncMock).toHaveBeenCalledWith('profiles dir') + expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith( + 'invalid profile file "profiles dir/profile.exe" (must have .yaml or .yml extension)', + ) expect(readYAMLFileMock).toHaveBeenCalledTimes(0) expect(createReadStreamMock).toHaveBeenCalledTimes(0) expect(zipFileMock).toHaveBeenCalledTimes(0) diff --git a/src/commands/edge/drivers/package.ts b/src/commands/edge/drivers/package.ts new file mode 100644 index 00000000..038da4d8 --- /dev/null +++ b/src/commands/edge/drivers/package.ts @@ -0,0 +1,205 @@ +import { createWriteStream } from 'node:fs' +import { readFile } from 'node:fs/promises' + +import JSZip from 'jszip' +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type EdgeDriver } from '@smartthings/core-sdk' + +import { fatalError } from '../../../lib/util.js' +import { apiCommand, apiCommandBuilder, apiDocsURL, type APICommandFlags } from '../../../lib/command/api-command.js' +import { outputItem, outputItemBuilder, type OutputItemConfig } from '../../../lib/command/output-item.js' +import { + buildTestFileMatchers, + processConfigFile, + processFingerprintsFile, + processProfiles, + processSearchParametersFile, + processSrcDir, + resolveProjectDirName, +} from '../../../lib/command/util/edge-driver-package.js' +import { chooseHub } from '../../../lib/command/util/hubs-choose.js' +import { chooseChannel } from '../../../lib/command/util/edge/channels-choose.js' + + +export type CommandArgs = + & APICommandFlags + & { + projectDirectory?: string + buildOnly?: string + upload?: string + assign?: boolean + channel?: string + install?: boolean + hub?: string + } + +const command = 'edge:drivers:package [project-directory]' + +const describe = 'build and upload an edge package' + +const builder = (yargs: Argv): Argv => + outputItemBuilder(apiCommandBuilder(yargs)) + .positional( + 'project-directory', + { describe: 'directory containing project to upload', type: 'string', default: '.' }, + ) + .option( + 'build-only', + { + alias: 'b', + describe: 'save package to specified zip file but skip upload', + type: 'string', + conflicts: ['upload'], + }, + ) + .option( + 'upload', + { + alias: 'u', + describe: 'upload zip file previously built with --build flag', + type: 'string', + conflicts: ['build-only'], + }, + ) + .option( + 'assign', + { + alias: 'a', + describe: 'prompt for a channel (or use default if one is set) to assign the driver to after upload', + type: 'boolean', + conflicts: ['channel', 'build-only'], + }, + ) + .option( + 'channel', + { + alias: 'C', + describe: 'automatically assign driver to specified channel after upload', + type: 'string', + conflicts: ['assign', 'build-only'], + }) + .option( + 'install', + { + alias: 'I', + describe: 'prompt for hub (or use default if one is set) to install to after assigning it' + + ' to the channel, implies --assign if --assign or --channel not included', + type: 'boolean', + conflicts: ['hub', 'build-only'], + }, + ) + .option( + 'hub', + { + describe: 'automatically install driver to specified hub, implies --assign if --assign or --channel' + + ' not included', + type: 'string', + conflicts: ['install', 'build-only'], + }) + .example([ + ['$0 edge:drivers:package', 'build and upload driver found in current directory'], + [ + '$0 edge:drivers:package --install', + 'build and upload driver found in current directory, assign it to a channel, and install it; you will' + + ' be prompted for channel and hub', + ], + [ + '$0 edge:drivers:package --channel 78f1ec15-96fb-419b-aa02-72d7921830a0' + + ' --hub a90dc5d9-c0b8-473b-b91f-35262b73466d', + 'build and upload driver found in current directory then assign it to the specified channel and' + + ' install it to the specified hub', + ], + [ + '$0 edge:drivers:package my-driver', + 'build and upload driver found in the my-driver directory', + ], + [ + '$0 edge:drivers:package --build-only driver.zip my-package', + 'build the driver in the my-package directory and save it as driver.zip', + ], + [ + '$0 edge:drivers:package --upload driver.zip', + 'upload the previously built driver found in driver.zip', + ], + ]) + .epilog(apiDocsURL('uploadDriverPackage')) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiCommand(argv) + + const uploadAndPostProcess = async (archiveData: Uint8Array): Promise => { + const config: OutputItemConfig = { + tableFieldDefinitions: ['driverId', 'name', 'packageKey', 'version'], + } + const driver = await outputItem(command, config, () => command.client.drivers.upload(archiveData)) + const doAssign = argv.assign || argv.channel || argv.install || argv.hub + const doInstall = argv.install || argv.hub + if (doAssign) { + const driverId = driver.driverId + const version = driver.version + const channelId = await chooseChannel( + command, + argv.channel, + { useConfigDefault: true, promptMessage: 'Select a channel for the driver.' }, + ) + await command.client.channels.assignDriver(channelId, driverId, version) + console.log(`Assigned driver ${driverId} (version ${version}) to channel ${channelId}.`) + + if (doInstall) { + const hubId = await chooseHub( + command, + argv.hub, + { promptMessage: 'Select a hub to install to.', useConfigDefault: true }, + ) + await command.client.hubdevices.installDriver(driverId, hubId, channelId) + console.log(`Installed driver ${driverId} (version ${version}) to hub ${hubId}.`) + } + } + } + + if (argv.upload) { + try { + const data = await readFile(argv.upload) + await uploadAndPostProcess(data) + } catch (error) { + if ((error as { code?: string }).code === 'ENOENT') { + return fatalError(`No file named "${argv.upload}" found.`) + } else { + throw error + } + } + } else { + const projectDirectory = await resolveProjectDirName(argv.projectDirectory ?? '.') + + const zip = new JSZip() + await processConfigFile(projectDirectory, zip) + + await processFingerprintsFile(projectDirectory, zip) + await processSearchParametersFile(projectDirectory, zip) + const edgeDriverTestDirs = command.cliConfig.stringArrayConfigValue( + 'edgeDriverTestDirs', + ['test/**', 'tests/**'], + ) + const testFileMatchers = buildTestFileMatchers(edgeDriverTestDirs) + if (!await processSrcDir(projectDirectory, zip, testFileMatchers)) { + // eslint-disable-next-line no-process-exit + process.exit(1) + } + + await processProfiles(projectDirectory, zip) + if (argv.buildOnly) { + zip.generateNodeStream({ type: 'nodebuffer', streamFiles: true, compression: 'DEFLATE' }) + .pipe(createWriteStream(argv.buildOnly)) + .on('finish', () => { + console.log(`wrote ${argv.buildOnly}`) + }) + } else { + const zipContents = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }) + await uploadAndPostProcess(zipContents) + } + } +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index 584e8ffa..9ebbf7c6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -72,6 +72,7 @@ import edgeDriversDevicesCommand from './edge/drivers/devices.js' 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 installedappsCommand from './installedapps.js' import installedappsDeleteCommand from './installedapps/delete.js' import installedappsRenameCommand from './installedapps/rename.js' @@ -197,6 +198,7 @@ export const commands: CommandModule[] = [ edgeDriversInstallCommand, edgeDriversInstalledCommand, edgeDriversLogcatCommand, + edgeDriversPackageCommand, installedappsCommand, installedappsDeleteCommand, installedappsRenameCommand, diff --git a/packages/edge/src/lib/commands/drivers/package-util.ts b/src/lib/command/util/edge-driver-package.ts similarity index 70% rename from packages/edge/src/lib/commands/drivers/package-util.ts rename to src/lib/command/util/edge-driver-package.ts index 952e317a..aa8204d5 100644 --- a/packages/edge/src/lib/commands/drivers/package-util.ts +++ b/src/lib/command/util/edge-driver-package.ts @@ -1,23 +1,29 @@ -import fs from 'fs' +import { createReadStream, readdirSync } from 'node:fs' -import { CliUx, Errors } from '@oclif/core' -import JSZip from 'jszip' +import type JSZip from 'jszip' import picomatch from 'picomatch' -import { fileExists, findYAMLFilename, isDir, isFile, isSymbolicLink, readYAMLFile, - realPathForSymbolicLink, requireDir, YAMLFileData } from '../../file-util.js' +import { + fileExists, + findYAMLFilename, + isDir, + isFile, + isSymbolicLink, + readYAMLFile, + realPathForSymbolicLink, + requireDir, + type YAMLFileData, +} from '../../file-util.js' +import { fatalError } from '../../util.js' -// Utility methods specific to the `edge:drivers:package` command. Split out here to make -// unit testing easier. - export const resolveProjectDirName = async (projectDirNameFromArgs: string): Promise => { let calculatedProjectDirName = projectDirNameFromArgs if (calculatedProjectDirName.endsWith('/')) { calculatedProjectDirName = calculatedProjectDirName.slice(0, -1) } if (!await isDir(calculatedProjectDirName)) { - throw new Errors.CLIError(`${calculatedProjectDirName} must exist and be a directory`) + return fatalError(`${calculatedProjectDirName} must exist and be a directory`) } return calculatedProjectDirName } @@ -25,22 +31,26 @@ export const resolveProjectDirName = async (projectDirNameFromArgs: string): Pro export const processConfigFile = async (projectDirectory: string, zip: JSZip): Promise => { const configFile = await findYAMLFilename(`${projectDirectory}/config`) if (configFile === false) { - throw new Errors.CLIError('missing main config.yaml (or config.yml) file') + return fatalError('missing main config.yaml (or config.yml) file') } const parsedConfig = readYAMLFile(configFile) - zip.file('config.yml', fs.createReadStream(configFile)) + zip.file('config.yml', createReadStream(configFile)) return parsedConfig } -export const processOptionalYAMLFile = async (baseFilename: string, projectDirectory: string, zip: JSZip): Promise => { +export const processOptionalYAMLFile = async ( + baseFilename: string, + projectDirectory: string, + zip: JSZip, +): Promise => { const yamlFile = await findYAMLFilename(`${projectDirectory}/${baseFilename}`) if (yamlFile !== false) { // validate file is at least parsable as a YAML file readYAMLFile(yamlFile) - zip.file(`${baseFilename}.yml`, fs.createReadStream(yamlFile)) + zip.file(`${baseFilename}.yml`, createReadStream(yamlFile)) } } @@ -53,22 +63,26 @@ export const processSearchParametersFile = async (projectDirectory: string, zip: export const buildTestFileMatchers = (matchersFromConfig: string[]): picomatch.Matcher[] => matchersFromConfig.map(glob => picomatch(glob)) -export const processSrcDir = async (projectDirectory: string, zip: JSZip, testFileMatchers: picomatch.Matcher[]): Promise => { +export const processSrcDir = async ( + projectDirectory: string, + zip: JSZip, + testFileMatchers: picomatch.Matcher[], +): Promise => { const srcDir = await requireDir(`${projectDirectory}/src`) if (!await isFile(`${srcDir}/init.lua`)) { - throw new Errors.CLIError(`missing required ${srcDir}/init.lua file`) + return fatalError(`missing required ${srcDir}/init.lua file`) } let successful = true const fatalIssue = (message: string): void => { successful = false - CliUx.ux.error(message, { exit: false }) + console.error(message) } // The max depth is 10 but the main project directory and the src directory itself count, // so we start at 2. const walkDir = async (fromDir: string, nested = 2): Promise => { - await Promise.all(fs.readdirSync(fromDir).map(async filename => { + await Promise.all(readdirSync(fromDir).map(async filename => { const fullFilename = `${fromDir}/${filename}` if (await fileExists(fullFilename)) { const isLink = await isSymbolicLink(fullFilename) @@ -88,7 +102,7 @@ export const processSrcDir = async (projectDirectory: string, zip: JSZip, testFi const filenameForTestMatch = fullFilename.substring(srcDir.length + 1) if (!testFileMatchers.some(matcher => matcher(filenameForTestMatch))) { const archiveName = `src${fullFilename.substring(srcDir.length)}` - zip.file(archiveName, fs.createReadStream(fullFilename)) + zip.file(archiveName, createReadStream(fullFilename)) } } } else { @@ -104,7 +118,7 @@ export const processSrcDir = async (projectDirectory: string, zip: JSZip, testFi export const processProfiles = async (projectDirectory: string, zip: JSZip): Promise => { const profilesDir = await requireDir(`${projectDirectory}/profiles`) - for (const filename of fs.readdirSync(profilesDir)) { + for (const filename of readdirSync(profilesDir)) { const fullFilename = `${profilesDir}/${filename}` if (filename.endsWith('.yaml') || filename.endsWith('.yml')) { // read and parse to make sure profiles are at least valid yaml @@ -113,9 +127,9 @@ export const processProfiles = async (projectDirectory: string, zip: JSZip): Pro if (archiveName.endsWith('.yaml')) { archiveName = `${archiveName.slice(0, -4)}yml` } - zip.file(archiveName, fs.createReadStream(fullFilename)) + zip.file(archiveName, createReadStream(fullFilename)) } else { - throw new Errors.CLIError(`invalid profile file "${fullFilename}" (must have .yaml or .yml extension)`) + return fatalError(`invalid profile file "${fullFilename}" (must have .yaml or .yml extension)`) } } }