Skip to content

Commit

Permalink
test: add tests for CLI commands (#799)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kanadgupta authored Nov 2, 2023
1 parent f06ad7f commit e456d46
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 33 deletions.
4 changes: 3 additions & 1 deletion packages/api/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 5 additions & 1 deletion packages/api/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
});
})();
33 changes: 13 additions & 20 deletions packages/api/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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(() => {
Expand All @@ -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()) {
Expand All @@ -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('');
Expand Down
18 changes: 7 additions & 11 deletions packages/api/src/commands/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -65,21 +63,19 @@ 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(() => {
spinner.succeed(spinner.text);
})
.catch(err => {
spinner.fail(spinner.text);
logger(err.message, true);
process.exit(1);
throw err;
});

logger('🚀 All done!');
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/logger.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
}
24 changes: 24 additions & 0 deletions packages/api/test/commands/__snapshots__/install.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`install command > should print help screen 1`] = `
"Usage: install [options] <uri>
install an API SDK into your codebase
Arguments:
uri an API to install
Options:
-i, --identifier <identifier> API identifier (eg. \`@api/petstore\`)
-l, --lang <language> 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
"
`;
25 changes: 25 additions & 0 deletions packages/api/test/commands/__snapshots__/list.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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
"
`;
19 changes: 19 additions & 0 deletions packages/api/test/commands/__snapshots__/uninstall.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`install command > should print help screen 1`] = `
"Usage: uninstall [options] <identifier>
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
"
`;
85 changes: 85 additions & 0 deletions packages/api/test/commands/install.test.ts
Original file line number Diff line number Diff line change
@@ -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 <language>' 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');
});
Loading

0 comments on commit e456d46

Please sign in to comment.