From e456d46f8eeeb90bd40ab60a530f94cdb0a0f415 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <8854718+kanadgupta@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:41:32 -0500 Subject: [PATCH] test: add tests for CLI commands (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: disable ora in tests * refactor: bubble errors up to bin script this will make it easier for us to test! * refactor: also don't process.exit in uninstall commands * test: first pass at install tests * test: first pass at list tests * refactor: use enum * test: add another list command test we'll see if this snapshot passes in GHA 🤞🏽 * chore: another test TODO * chore: lint * test: first pass at uninstall tests * test: add snapshot tests for help command * refactor: use ora opts in uninstall command * test(list): make sure fetchmock is restored * test: more uninstall command tests * chore: consistent whitespace * fix: lint --- packages/api/.eslintrc | 4 +- packages/api/src/bin.ts | 6 +- packages/api/src/commands/install.ts | 33 +++--- packages/api/src/commands/uninstall.ts | 18 ++- packages/api/src/logger.ts | 9 ++ .../__snapshots__/install.test.ts.snap | 24 ++++ .../commands/__snapshots__/list.test.ts.snap | 25 ++++ .../__snapshots__/uninstall.test.ts.snap | 19 +++ packages/api/test/commands/install.test.ts | 85 ++++++++++++++ packages/api/test/commands/list.test.ts | 73 ++++++++++++ packages/api/test/commands/uninstall.test.ts | 109 ++++++++++++++++++ 11 files changed, 372 insertions(+), 33 deletions(-) create mode 100644 packages/api/test/commands/__snapshots__/install.test.ts.snap create mode 100644 packages/api/test/commands/__snapshots__/list.test.ts.snap create mode 100644 packages/api/test/commands/__snapshots__/uninstall.test.ts.snap create mode 100644 packages/api/test/commands/install.test.ts create mode 100644 packages/api/test/commands/list.test.ts create mode 100644 packages/api/test/commands/uninstall.test.ts diff --git a/packages/api/.eslintrc b/packages/api/.eslintrc index 4f94dfaa..83ecd019 100644 --- a/packages/api/.eslintrc +++ b/packages/api/.eslintrc @@ -5,6 +5,8 @@ { "allow": ["OpenAPIV3_1"] } - ] + ], + // This rule is only really applicable for OSS libraries and doesn't apply to api's case since it's a CLI. + "readme/no-dual-exports": "off" } } diff --git a/packages/api/src/bin.ts b/packages/api/src/bin.ts index 8027e77a..7fb410d6 100644 --- a/packages/api/src/bin.ts +++ b/packages/api/src/bin.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import commands from './commands/index.js'; +import logger from './logger.js'; import * as pkg from './packageInfo.js'; (async () => { @@ -17,5 +18,8 @@ import * as pkg from './packageInfo.js'; program.addCommand(cmd); }); - await program.parseAsync(process.argv); + await program.parseAsync(process.argv).catch(err => { + if (err.message) logger(err.message, true); + process.exit(1); + }); })(); diff --git a/packages/api/src/commands/install.ts b/packages/api/src/commands/install.ts index 4ac44094..1ca50af4 100644 --- a/packages/api/src/commands/install.ts +++ b/packages/api/src/commands/install.ts @@ -12,7 +12,7 @@ import { SupportedLanguages, codegenFactory } from '../codegen/factory.js'; import Fetcher from '../fetcher.js'; import promptTerminal from '../lib/prompt.js'; import { buildCodeSnippetForOperation, getSuggestedOperation } from '../lib/suggestedOperations.js'; -import logger from '../logger.js'; +import logger, { oraOptions } from '../logger.js'; import Storage from '../storage.js'; interface Options { @@ -103,7 +103,7 @@ cmd // logger(`It looks like you already have this API installed. Would you like to update it?`); } - let spinner = ora('Fetching your API definition').start(); + let spinner = ora({ text: 'Fetching your API definition', ...oraOptions() }).start(); const storage = new Storage(uri, language); const oas = await storage @@ -120,14 +120,12 @@ cmd .catch(err => { // @todo cleanup installed files spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; }); const identifier = await getIdentifier(oas, uri, options); if (!identifier) { - logger('You must tell us what you would like to identify this API as in order to install it.', true); - process.exit(1); + throw new Error('You must tell us what you would like to identify this API as in order to install it.'); } // Now that we've got an identifier we can save their spec and generate the directory structure @@ -136,7 +134,7 @@ cmd await storage.save(oas.api); // @todo look for a prettier config and if we find one ask them if we should use it - spinner = ora('Generating your SDK').start(); + spinner = ora({ text: 'Generating your SDK', ...oraOptions() }).start(); const generator = codegenFactory(language, oas, '../openapi.json', identifier); const sdkSource = await generator .generate() @@ -147,11 +145,10 @@ cmd .catch(err => { // @todo cleanup installed files spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; }); - spinner = ora('Saving your SDK into your codebase').start(); + spinner = ora({ text: 'Saving your SDK into your codebase', ...oraOptions() }).start(); await storage .saveSourceFiles(sdkSource) .then(() => { @@ -160,8 +157,7 @@ cmd .catch(err => { // @todo cleanup installed files spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; }); if (generator.hasRequiredPackages()) { @@ -184,33 +180,30 @@ cmd }).then(({ value }) => { if (!value) { // @todo cleanup installed files - logger('Installation cancelled.', true); - process.exit(1); + throw new Error('Installation cancelled.'); } }); } - spinner = ora('Installing required packages').start(); + spinner = ora({ text: 'Installing required packages', ...oraOptions() }).start(); try { await generator.install(storage); spinner.succeed(spinner.text); } catch (err) { // @todo cleanup installed files spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; } } - spinner = ora('Compiling your SDK').start(); + spinner = ora({ text: 'Compiling your SDK', ...oraOptions() }).start(); try { await generator.compile(storage); spinner.succeed(spinner.text); } catch (err) { // @todo cleanup installed files spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; } logger(''); diff --git a/packages/api/src/commands/uninstall.ts b/packages/api/src/commands/uninstall.ts index d3cacd8b..08eddf27 100644 --- a/packages/api/src/commands/uninstall.ts +++ b/packages/api/src/commands/uninstall.ts @@ -6,7 +6,7 @@ import ora from 'ora'; import { SupportedLanguages, uninstallerFactory } from '../codegen/factory.js'; import promptTerminal from '../lib/prompt.js'; -import logger from '../logger.js'; +import logger, { oraOptions } from '../logger.js'; import Storage from '../storage.js'; interface Options { @@ -26,11 +26,9 @@ cmd const entry = Storage.getFromLockfile(identifier); if (!entry) { - logger( + throw new Error( `You do not appear to have ${identifier} installed. You can run \`npx api list\` to see what SDKs are present.`, - true, ); - process.exit(1); } storage.setLanguage(entry?.language); @@ -47,12 +45,12 @@ cmd initial: true, }).then(({ value }) => { if (!value) { - process.exit(1); + throw new Error('Uninstallation cancelled.'); } }); } - let spinner = ora(`Uninstalling ${chalk.grey(identifier)}`).start(); + let spinner = ora({ text: `Uninstalling ${chalk.grey(identifier)}`, ...oraOptions() }).start(); // If we have a known package name for this then we can uninstall it from within cooresponding // package manager. @@ -65,12 +63,11 @@ cmd }) .catch(err => { spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; }); } - spinner = ora(`Removing ${chalk.grey(directory)}`).start(); + spinner = ora({ text: `Removing ${chalk.grey(directory)}`, ...oraOptions() }).start(); await storage .remove() .then(() => { @@ -78,8 +75,7 @@ cmd }) .catch(err => { spinner.fail(spinner.text); - logger(err.message, true); - process.exit(1); + throw err; }); logger('🚀 All done!'); diff --git a/packages/api/src/logger.ts b/packages/api/src/logger.ts index 544caf0d..128c0f0d 100644 --- a/packages/api/src/logger.ts +++ b/packages/api/src/logger.ts @@ -1,4 +1,6 @@ /* eslint-disable no-console */ +import type { Options as OraOptions } from 'ora'; + import chalk from 'chalk'; export default function logger(log: string, error?: boolean) { @@ -8,3 +10,10 @@ export default function logger(log: string, error?: boolean) { console.log(log); } } + +export function oraOptions() { + // Disables spinner in tests so it doesn't pollute test output + const opts: OraOptions = { isSilent: process.env.NODE_ENV === 'test' }; + + return opts; +} diff --git a/packages/api/test/commands/__snapshots__/install.test.ts.snap b/packages/api/test/commands/__snapshots__/install.test.ts.snap new file mode 100644 index 00000000..ec95a69e --- /dev/null +++ b/packages/api/test/commands/__snapshots__/install.test.ts.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`install command > should print help screen 1`] = ` +"Usage: install [options] + +install an API SDK into your codebase + +Arguments: + uri an API to install + +Options: + -i, --identifier API identifier (eg. \`@api/petstore\`) + -l, --lang SDK language (choices: \\"js\\", default: \\"js\\") + -y, --yes Automatically answer \\"yes\\" to any prompts + printed + -h, --help display help for command + + +Examples: + $ npx api install @developers/v2.0#nysezql0wwo236 + $ npx api install https://raw.githubusercontent.com/readmeio/oas-examples/main/3.0/json/petstore-simple.json + $ npx api install ./petstore.json +" +`; diff --git a/packages/api/test/commands/__snapshots__/list.test.ts.snap b/packages/api/test/commands/__snapshots__/list.test.ts.snap new file mode 100644 index 00000000..589004ce --- /dev/null +++ b/packages/api/test/commands/__snapshots__/list.test.ts.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`install command > should list installed SDKs 1`] = ` +"petstore + +package name (private): @api/petstore + +language: js + +source: @petstore/v1.0#n6kvf10vakpemvplx + +installer version: 7.0.0-beta.3 + +created at: 2023-10-25T00:00:00.000Z" +`; + +exports[`install command > should print help screen 1`] = ` +"Usage: list|ls [options] + +list any installed API SDKs + +Options: + -h, --help display help for command +" +`; diff --git a/packages/api/test/commands/__snapshots__/uninstall.test.ts.snap b/packages/api/test/commands/__snapshots__/uninstall.test.ts.snap new file mode 100644 index 00000000..04fe1257 --- /dev/null +++ b/packages/api/test/commands/__snapshots__/uninstall.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`install command > should print help screen 1`] = ` +"Usage: uninstall [options] + +uninstall an SDK from your codebase + +Arguments: + identifier the SDK to uninstall + +Options: + -y, --yes Automatically answer \\"yes\\" to any prompts printed + -h, --help display help for command + + +Examples: + $ npx api uninstall petstore +" +`; diff --git a/packages/api/test/commands/install.test.ts b/packages/api/test/commands/install.test.ts new file mode 100644 index 00000000..b8a3c1f6 --- /dev/null +++ b/packages/api/test/commands/install.test.ts @@ -0,0 +1,85 @@ +import type { SpyInstance } from 'vitest'; + +import { CommanderError } from 'commander'; +import prompts from 'prompts'; +import uniqueTempDir from 'unique-temp-dir'; +import { describe, beforeEach, it, expect, vi, afterEach } from 'vitest'; + +import { SupportedLanguages } from '../../src/codegen/factory.js'; +import installCmd from '../../src/commands/install.js'; +import Storage from '../../src/storage.js'; + +const baseCommand = ['api', 'install']; + +const cmdError = (msg: string) => new CommanderError(0, '', msg); + +describe('install command', () => { + let stdout: string[]; + let stderr: string[]; + let consoleLogSpy: SpyInstance; + + beforeEach(() => { + stdout = []; + stderr = []; + + installCmd.exitOverride(); + installCmd.configureOutput({ + writeOut: str => stdout.push(str), + writeErr: str => stderr.push(str), + }); + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + Storage.setStorageDir(uniqueTempDir()); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + Storage.reset(); + }); + + it('should error out if uri is not passed', () => { + return expect(installCmd.parseAsync([...baseCommand])).rejects.toStrictEqual( + cmdError("error: missing required argument 'uri'"), + ); + }); + + it('should error out if invalid uri is passed', () => { + return expect(installCmd.parseAsync([...baseCommand, 'petstore.json'])).rejects.toThrow( + /Sorry, we were unable to load an API definition from .*petstore.json. Please either supply a URL or a path on your filesystem./, + ); + }); + + it('should accept valid lang parameter but error out if invalid uri is passed', () => { + return expect( + installCmd.parseAsync([...baseCommand, 'petstore.json', '--lang', SupportedLanguages.JS]), + ).rejects.toThrow( + /Sorry, we were unable to load an API definition from .*petstore.json. Please either supply a URL or a path on your filesystem./, + ); + }); + + it('should error out if invalid lang is passed', () => { + return expect(installCmd.parseAsync([...baseCommand, '--lang', 'javascript'])).rejects.toStrictEqual( + cmdError("error: option '-l, --lang ' argument 'javascript' is invalid. Allowed choices are js."), + ); + }); + + it('should handle user answering no to package installation confirmation prompt', () => { + prompts.inject(['petstore', false]); + return expect( + installCmd.parseAsync([...baseCommand, '../test-utils/definitions/simple.json']), + ).rejects.toStrictEqual(new Error('Installation cancelled.')); + }); + + it('should print help screen', async () => { + await expect(installCmd.parseAsync([...baseCommand, '--help'])).rejects.toStrictEqual(cmdError('(outputHelp)')); + + expect(stdout.join('\n')).toMatchSnapshot(); + }); + + it.todo('should surface generation errors'); + it.todo('should surface file save errors'); + it.todo('should surface package installation errors'); + it.todo('should surface compilation errors'); + it.todo('should successfully generate SDK'); + it.todo('should successfully bypass all prompts with --yes option'); +}); diff --git a/packages/api/test/commands/list.test.ts b/packages/api/test/commands/list.test.ts new file mode 100644 index 00000000..0e780c74 --- /dev/null +++ b/packages/api/test/commands/list.test.ts @@ -0,0 +1,73 @@ +import type { SpyInstance } from 'vitest'; + +import { loadSpec } from '@api/test-utils'; +import { CommanderError } from 'commander'; +import fetchMock from 'fetch-mock'; +import uniqueTempDir from 'unique-temp-dir'; +import { describe, beforeEach, it, expect, vi, afterEach } from 'vitest'; + +import { SupportedLanguages } from '../../src/codegen/factory.js'; +import installCmd from '../../src/commands/list.js'; +import Storage from '../../src/storage.js'; + +const baseCommand = ['api', 'list']; + +const cmdError = (msg: string) => new CommanderError(0, '', msg); + +describe('install command', () => { + let stdout: string[]; + let stderr: string[]; + let consoleLogSpy: SpyInstance; + + const getCommandOutput = () => { + return [consoleLogSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + }; + + beforeEach(() => { + stdout = []; + stderr = []; + + installCmd.exitOverride(); + installCmd.configureOutput({ + writeOut: str => stdout.push(str), + writeErr: str => stderr.push(str), + }); + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + Storage.setStorageDir(uniqueTempDir()); + vi.setSystemTime(new Date('2023-10-25')); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + fetchMock.restore(); + Storage.reset(); + vi.useRealTimers(); + }); + + it('should return placeholder message if no SDKs are installed', async () => { + await expect(installCmd.parseAsync([...baseCommand])).resolves.toBeDefined(); + + expect(getCommandOutput()).toBe('😔 You do not have any SDKs installed.'); + }); + + it('should list installed SDKs', async () => { + const petstoreSimple = await loadSpec('@readme/oas-examples/3.0/json/petstore-simple.json'); + fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple); + + const source = '@petstore/v1.0#n6kvf10vakpemvplx'; + const storage = new Storage(source, SupportedLanguages.JS, 'petstore'); + + await storage.load(); + + await expect(installCmd.parseAsync([...baseCommand])).resolves.toBeDefined(); + + expect(getCommandOutput()).toMatchSnapshot(); + }); + + it('should print help screen', async () => { + await expect(installCmd.parseAsync([...baseCommand, '--help'])).rejects.toStrictEqual(cmdError('(outputHelp)')); + + expect(stdout.join('\n')).toMatchSnapshot(); + }); +}); diff --git a/packages/api/test/commands/uninstall.test.ts b/packages/api/test/commands/uninstall.test.ts new file mode 100644 index 00000000..03a86499 --- /dev/null +++ b/packages/api/test/commands/uninstall.test.ts @@ -0,0 +1,109 @@ +import type { SpyInstance } from 'vitest'; + +import { loadSpec } from '@api/test-utils'; +import { CommanderError } from 'commander'; +import fetchMock from 'fetch-mock'; +import prompts from 'prompts'; +import uniqueTempDir from 'unique-temp-dir'; +import { describe, beforeEach, it, expect, vi, afterEach } from 'vitest'; + +import { SupportedLanguages } from '../../src/codegen/factory.js'; +import * as codegenFactoryModule from '../../src/codegen/factory.js'; +import installCmd from '../../src/commands/uninstall.js'; +import Storage from '../../src/storage.js'; + +const baseCommand = ['api', 'uninstall']; + +const cmdError = (msg: string) => new CommanderError(0, '', msg); + +describe('install command', () => { + let stdout: string[]; + let stderr: string[]; + let consoleLogSpy: SpyInstance; + + const getCommandOutput = () => { + return [consoleLogSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + }; + + beforeEach(() => { + stdout = []; + stderr = []; + + installCmd.exitOverride(); + installCmd.configureOutput({ + writeOut: str => stdout.push(str), + writeErr: str => stderr.push(str), + }); + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + Storage.setStorageDir(uniqueTempDir()); + }); + + afterEach(() => { + fetchMock.restore(); + Storage.reset(); + vi.restoreAllMocks(); + }); + + it('should error out if identifier is not passed', () => { + return expect(installCmd.parseAsync([...baseCommand])).rejects.toStrictEqual( + cmdError("error: missing required argument 'identifier'"), + ); + }); + + it('should error out if invalid identifier is passed', () => { + return expect(installCmd.parseAsync([...baseCommand, 'non-existent-identifier'])).rejects.toStrictEqual( + new Error( + 'You do not appear to have non-existent-identifier installed. You can run `npx api list` to see what SDKs are present.', + ), + ); + }); + + it('should print help screen', async () => { + await expect(installCmd.parseAsync([...baseCommand, '--help'])).rejects.toStrictEqual(cmdError('(outputHelp)')); + + expect(stdout.join('\n')).toMatchSnapshot(); + }); + + it('should successfully uninstall SDK', async () => { + prompts.inject([true]); + + const petstoreSimple = await loadSpec('@readme/oas-examples/3.0/json/petstore-simple.json'); + fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple); + + const source = '@petstore/v1.0#n6kvf10vakpemvplx'; + const identifier = 'petstore-to-be-uninstalled'; + const storage = new Storage(source, SupportedLanguages.JS, identifier); + + await storage.load(); + + expect(Storage.getLockfile().apis).toHaveLength(1); + + const uninstallSpy = vi.spyOn(codegenFactoryModule, 'uninstallerFactory').mockResolvedValue(); + + await expect(installCmd.parseAsync([...baseCommand, identifier])).resolves.toBeDefined(); + expect(uninstallSpy).toHaveBeenCalledTimes(1); + expect(getCommandOutput()).toBe('🚀 All done!'); + expect(Storage.getLockfile().apis).toHaveLength(0); + }); + + it('should uninstall SDK and bypass prompt with --yes option', async () => { + const petstoreSimple = await loadSpec('@readme/oas-examples/3.0/json/petstore-simple.json'); + fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple); + + const source = '@petstore/v1.0#n6kvf10vakpemvplx'; + const identifier = 'petstore-to-be-uninstalled'; + const storage = new Storage(source, SupportedLanguages.JS, identifier); + + await storage.load(); + + expect(Storage.getLockfile().apis).toHaveLength(1); + + const uninstallSpy = vi.spyOn(codegenFactoryModule, 'uninstallerFactory').mockResolvedValue(); + + await expect(installCmd.parseAsync([...baseCommand, identifier, '--yes'])).resolves.toBeDefined(); + expect(uninstallSpy).toHaveBeenCalledTimes(1); + expect(getCommandOutput()).toBe('🚀 All done!'); + expect(Storage.getLockfile().apis).toHaveLength(0); + }); +});