From 4d389bb6ccbfb7774e94d151fc8063a35031aba9 Mon Sep 17 00:00:00 2001 From: Lucian Date: Fri, 9 Aug 2019 18:36:52 +0100 Subject: [PATCH] Implement full command testing, beginning with "balena version" This also modifies the core CLI to be fed command programatically, which is useful for being able to do thing like mock endpoints with tools like "nock", and provide an easier debugging experience. The tests utilise a "runCommand" helper that intercepts and captures stdout/stderr writes and returns them once the command has finished running. At this point the test implementation can parse the stdout/stderr logs and assess nock interceptions to determine if the command ran correctly. This change also homogenises debug messages to start with `[debug]`, however this is not strictly enforced by linting rules. Change-type: minor Signed-off-by: Lucian --- lib/app-capitano.coffee | 19 ++++++++-------- lib/app-common.ts | 6 +++++- lib/app-oclif.ts | 11 +++++++--- lib/app.ts | 19 ++++++++++------ package.json | 2 +- tests/helpers.ts | 48 +++++++++++++++++++++++++++++++++++++++++ tests/version.spec.ts | 37 +++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 tests/helpers.ts create mode 100644 tests/version.spec.ts diff --git a/lib/app-capitano.coffee b/lib/app-capitano.coffee index eaf76cd4..ec547a8d 100644 --- a/lib/app-capitano.coffee +++ b/lib/app-capitano.coffee @@ -145,13 +145,14 @@ capitano.command(actions.push.push) capitano.command(actions.join.join) capitano.command(actions.leave.leave) -cli = capitano.parse(process.argv) -runCommand = -> - capitanoExecuteAsync = Promise.promisify(capitano.execute) - if cli.global?.help - capitanoExecuteAsync(command: "help #{cli.command ? ''}") - else - capitanoExecuteAsync(cli) +exports.run = (argv) -> + cli = capitano.parse(argv) + runCommand = -> + capitanoExecuteAsync = Promise.promisify(capitano.execute) + if cli.global?.help + capitanoExecuteAsync(command: "help #{cli.command ? ''}") + else + capitanoExecuteAsync(cli) -Promise.all([events.trackCommand(cli), runCommand()]) -.catch(require('./errors').handleError) + Promise.all([events.trackCommand(cli), runCommand()]) + .catch(require('./errors').handleError) diff --git a/lib/app-common.ts b/lib/app-common.ts index a3589c4d..a309803e 100644 --- a/lib/app-common.ts +++ b/lib/app-common.ts @@ -100,7 +100,11 @@ export function globalInit() { // stream-to-promise will produce native promises if not for this module, // which is likely to lead to errors as much of the CLI coffeescript code // expects bluebird promises. - require('any-promise/register/bluebird'); + // The registration is only run if it hasn't already happened (for example + // in a test case). + if (!(global as any)['@@any-promise/REGISTRATION']) { + require('any-promise/register/bluebird'); + } // check for CLI updates once a day require('./utils/update').notify(); diff --git a/lib/app-oclif.ts b/lib/app-oclif.ts index 8ae27ea8..79163eee 100644 --- a/lib/app-oclif.ts +++ b/lib/app-oclif.ts @@ -18,6 +18,7 @@ import { Main } from '@oclif/command'; import { ExitError } from '@oclif/errors'; +import { AppOptions } from './app'; import { handleError } from './errors'; class CustomMain extends Main { @@ -34,9 +35,13 @@ class CustomMain extends Main { /** * oclif CLI entrypoint */ -export function run(argv: string[]) { - CustomMain.run(argv.slice(2)).then( - require('@oclif/command/flush'), +export function run(command: string[], options: AppOptions) { + return CustomMain.run(command).then( + () => { + if (!options.noFlush) { + return require('@oclif/command/flush'); + } + }, (error: Error) => { // oclif sometimes exits with ExitError code 0 (not an error) if (error instanceof ExitError && error.oclif.exit === 0) { diff --git a/lib/app.ts b/lib/app.ts index 67d27a0f..7ea9381d 100644 --- a/lib/app.ts +++ b/lib/app.ts @@ -18,14 +18,19 @@ import { stripIndent } from 'common-tags'; import { exitWithExpectedError } from './utils/patterns'; +export interface AppOptions { + // Prevent the default behaviour of flushing stdout after running a command + noFlush: boolean; +} + /** * Simple command-line pre-parsing to choose between oclif or Capitano. * @param argv process.argv */ -function routeCliFramework(argv: string[]): void { +function routeCliFramework(argv: string[], options: AppOptions): void { if (process.env.DEBUG) { console.log( - `Debug: original argv0="${process.argv0}" argv=[${argv}] length=${ + `[debug] original argv0="${process.argv0}" argv=[${argv}] length=${ argv.length }`, ); @@ -65,11 +70,11 @@ function routeCliFramework(argv: string[]): void { argv = [argv[0], argv[1], ...cmdSlice]; } if (process.env.DEBUG) { - console.log(`Debug: new argv=[${argv}] length=${argv.length}`); + console.log(`[debug] new argv=[${argv}] length=${argv.length}`); } - require('./app-oclif').run(argv); + return require('./app-oclif').run(cmdSlice, options); } else { - require('./app-capitano'); + return require('./app-capitano').run(cmdSlice); } } @@ -154,10 +159,10 @@ function isOclifCommand(argvSlice: string[]): [boolean, boolean] { * CLI entrypoint, but see also `bin/balena` and `bin/balena-dev` which * call this function. */ -export function run(): void { +export function run(cliArgs = process.argv, options: AppOptions): void { // globalInit() must be called very early on (before other imports) because // it sets up Sentry error reporting, global HTTP proxy settings, balena-sdk // shared options, and performs node version requirement checks. require('./app-common').globalInit(); - routeCliFramework(process.argv); + return routeCliFramework(cliArgs, options); } diff --git a/package.json b/package.json index b6349fb7..e83783fd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "package": "npm run build:fast && npm run build:standalone && npm run build:installer", "release": "ts-node --type-check -P automation/tsconfig.json automation/run.ts release", "pretest": "npm run build", - "test": "mocha -r ts-node/register tests/**/*.spec.ts", + "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"", "test:fast": "npm run build:fast && npm run test", "ci": "npm run test && catch-uncommitted", "watch": "gulp watch", diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 00000000..92652fd2 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; +import * as balenaCLI from '../build/app'; + +export const runCommand = async (cmd: string) => { + const preArgs = [process.argv[0], path.join(process.cwd(), 'bin', 'balena')]; + + const oldStdOut = process.stdout.write; + const oldStdErr = process.stderr.write; + + const err: string[] = []; + const out: string[] = []; + + // @ts-ignore + process.stdout.write = (log: string) => { + // Skip over debug messages + if (!log.startsWith('[debug]')) { + out.push(log); + } + oldStdOut(log); + }; + // @ts-ignore + process.stderr.write = (log: string) => { + // Skip over debug messages + if (!log.startsWith('[debug]')) { + err.push(log); + } + oldStdErr(log); + }; + + try { + await balenaCLI.run(preArgs.concat(cmd.split(' ')), { + noFlush: true, + }); + + process.stdout.write = oldStdOut; + process.stderr.write = oldStdErr; + + return { + err, + out, + }; + } catch (err) { + process.stdout.write = oldStdOut; + process.stderr.write = oldStdErr; + + throw err; + } +}; diff --git a/tests/version.spec.ts b/tests/version.spec.ts new file mode 100644 index 00000000..20630946 --- /dev/null +++ b/tests/version.spec.ts @@ -0,0 +1,37 @@ +import * as chai from 'chai'; +import * as fs from 'fs'; +import { runCommand } from './helpers'; + +const packageJSON = JSON.parse(fs.readFileSync('./package.json', 'utf8')); +const nodeVersion = process.version.startsWith('v') + ? process.version.slice(1) + : process.version; + +describe('balena version', function() { + it('should print the installed version of the CLI', async () => { + const { out } = await runCommand('version'); + + chai.expect(out.join('')).to.equal(`${packageJSON.version}\n`); + }); + + it('should print additional version information with the -a flag', async () => { + const { out } = await runCommand('version -a'); + + chai.expect(out.join('')).to.equal( + `balena-cli version "${packageJSON.version}" +Node.js version "${nodeVersion}" +`, + ); + }); + + it('should print version information as JSON with the the -j flag', async () => { + const { out } = await runCommand('version -j'); + + const json = JSON.parse(out.join('')); + + chai.expect(json).to.deep.equal({ + 'balena-cli': packageJSON.version, + 'Node.js': nodeVersion, + }); + }); +});