From 4de2ebb262b4b117e19ab468097bc80c5044b479 Mon Sep 17 00:00:00 2001 From: maxjeffos <44034094+maxjeffos@users.noreply.github.com> Date: Wed, 6 May 2020 23:21:59 -0400 Subject: [PATCH] feat: add --json-file-output option for snyk test --- help/help.txt | 4 + src/cli/args.ts | 5 +- src/cli/commands/auth/index.ts | 2 +- src/cli/commands/test/index.ts | 64 +++--- src/cli/commands/types.ts | 57 +++++ src/cli/index.ts | 86 +++++++- .../json-file-output-bad-input-error.ts | 13 ++ src/lib/json-file-output.ts | 55 +++++ test/acceptance/cli-args.test.ts | 163 ++++++++++++++- test/acceptance/cli-json-file-output.test.ts | 137 ++++++++++++ .../cli-test/cli-test.all-projects.spec.ts | 195 +++++++++++------- .../cli-test/cli-test.generic.spec.ts | 6 +- .../cli-test/cli-test.gradle.spec.ts | 14 +- test/acceptance/cli-test/cli-test.npm.spec.ts | 6 +- .../acceptance/cli-test/cli-test.ruby.spec.ts | 9 +- test/cli-command-types.test.ts | 42 ++++ test/json-file-output.test.ts | 137 ++++++++++++ test/system/remote-package.test.ts | 14 +- 18 files changed, 900 insertions(+), 109 deletions(-) create mode 100644 src/cli/commands/types.ts create mode 100644 src/lib/errors/json-file-output-bad-input-error.ts create mode 100644 src/lib/json-file-output.ts create mode 100644 test/acceptance/cli-json-file-output.test.ts create mode 100644 test/cli-command-types.test.ts create mode 100644 test/json-file-output.test.ts diff --git a/help/help.txt b/help/help.txt index 707d80d4bc..396b8d9dbd 100644 --- a/help/help.txt +++ b/help/help.txt @@ -81,6 +81,10 @@ Options: Upgradable fails when there is at least one vulnerability that can be upgraded. Patchable fails when there is at least one vulnerability that can be patched. If vulnerabilities do not have a fix and this option is being used tests will pass. + --json-file-output= + (test command only) + Save test output in JSON format directly to the specified file, regardless of whether or not you use the `--json` option. + This is especially useful if you want to display the human-readable test output via stdout and at the same time save the JSON format output to a file. Maven options: --scan-all-unmanaged diff --git a/src/cli/args.ts b/src/cli/args.ts index 0fa7f7ffba..3245ace390 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,5 @@ import * as abbrev from 'abbrev'; +import { CommandResult } from './commands/types'; import debugModule = require('debug'); import { parseMode } from './modes'; @@ -39,7 +40,7 @@ function dashToCamelCase(dash) { // Last item is ArgsOptions, the rest are strings (positional arguments, e.g. paths) export type MethodArgs = Array; -export type Method = (...args: MethodArgs) => Promise; +export type Method = (...args: MethodArgs) => Promise; export interface Args { command: string; @@ -148,7 +149,7 @@ export function args(rawArgv: string[]): Args { argv._.unshift(tmp.shift()!); } - let method: () => Promise = cli[command]; + let method: () => Promise = cli[command]; if (!method) { // if we failed to find a command, then default to an error diff --git a/src/cli/commands/auth/index.ts b/src/cli/commands/auth/index.ts index c1dd76076b..f827e63477 100644 --- a/src/cli/commands/auth/index.ts +++ b/src/cli/commands/auth/index.ts @@ -117,7 +117,7 @@ async function testAuthComplete(token: string): Promise<{ res; body }> { }); } -async function auth(apiToken: string, via: AuthCliCommands) { +async function auth(apiToken: string, via: AuthCliCommands): Promise { let promise; resetAttempts(); if (apiToken) { diff --git a/src/cli/commands/test/index.ts b/src/cli/commands/test/index.ts index 5687357560..e776f01466 100644 --- a/src/cli/commands/test/index.ts +++ b/src/cli/commands/test/index.ts @@ -16,6 +16,7 @@ import { } from '../../../lib/types'; import { isLocalFolder } from '../../../lib/detect'; import { MethodArgs } from '../../args'; +import { TestCommandResult } from '../../commands/types'; import { GroupedVuln, LegacyVulnApiResult, @@ -56,9 +57,9 @@ const showVulnPathsMapping: Record = { // TODO: avoid using `as any` whenever it's possible -async function test(...args: MethodArgs): Promise { +async function test(...args: MethodArgs): Promise { const resultOptions = [] as any[]; - let results = [] as any[]; + const results = [] as any[]; let options = ({} as any) as Options & TestOptions; if (typeof args[args.length - 1] === 'object') { @@ -164,25 +165,18 @@ async function test(...args: MethodArgs): Promise { // resultOptions is now an array of 1 or more options used for // the tests results is now an array of 1 or more test results // values depend on `options.json` value - string or object - if (options.json) { - results = results.map((result) => { - // add json for when thrown exception - if (result instanceof Error) { - return { - ok: false, - error: result.message, - path: (result as any).path, - }; - } - return result; - }); + const errorMappedResults = createErrorMappedResultsForJsonOutput(results); + // backwards compat - strip array IFF only one result + const dataToSend = + errorMappedResults.length === 1 + ? errorMappedResults[0] + : errorMappedResults; + const stringifiedData = JSON.stringify(dataToSend, null, 2); - // backwards compat - strip array IFF only one result - const dataToSend = results.length === 1 ? results[0] : results; - const stringifiedData = JSON.stringify(dataToSend, null, 2); - - if (results.every((res) => res.ok)) { - return stringifiedData; + if (options.json) { + // if all results are ok (.ok == true) then return the json + if (errorMappedResults.every((res) => res.ok)) { + return TestCommandResult.createJsonTestCommandResult(stringifiedData); } const err = new Error(stringifiedData) as any; @@ -192,7 +186,7 @@ async function test(...args: MethodArgs): Promise { const fail = shouldFail(vulnerableResults, options.failOn); if (!fail) { // return here to prevent failure - return stringifiedData; + return TestCommandResult.createJsonTestCommandResult(stringifiedData); } } err.code = 'VULNS'; @@ -202,6 +196,7 @@ async function test(...args: MethodArgs): Promise { } err.json = stringifiedData; + err.jsonStringifiedResults = stringifiedData; throw err; } @@ -253,7 +248,10 @@ async function test(...args: MethodArgs): Promise { if (!fail) { // return here to prevent throwing failure response += chalk.bold.green(summaryMessage); - return response; + return TestCommandResult.createHumanReadableTestCommandResult( + response, + stringifiedData, + ); } } @@ -265,11 +263,31 @@ async function test(...args: MethodArgs): Promise { // first one error.code = vulnerableResults[0].code || 'VULNS'; error.userMessage = vulnerableResults[0].userMessage; + error.jsonStringifiedResults = stringifiedData; throw error; } response += chalk.bold.green(summaryMessage); - return response; + return TestCommandResult.createHumanReadableTestCommandResult( + response, + stringifiedData, + ); +} + +function createErrorMappedResultsForJsonOutput(results) { + const errorMappedResults = results.map((result) => { + // add json for when thrown exception + if (result instanceof Error) { + return { + ok: false, + error: result.message, + path: (result as any).path, + }; + } + return result; + }); + + return errorMappedResults; } function shouldFail(vulnerableResults: any[], failOn: FailOn) { diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts new file mode 100644 index 0000000000..e42e6b3530 --- /dev/null +++ b/src/cli/commands/types.ts @@ -0,0 +1,57 @@ +export class CommandResult { + result: string; + constructor(result: string) { + this.result = result; + } + + public toString(): string { + return this.result; + } + + public getDisplayResults() { + return this.result; + } +} + +export abstract class TestCommandResult extends CommandResult { + protected jsonResult = ''; + public getJsonResult(): string { + return this.jsonResult; + } + + public static createHumanReadableTestCommandResult( + humanReadableResult: string, + jsonResult: string, + ): HumanReadableTestCommandResult { + return new HumanReadableTestCommandResult(humanReadableResult, jsonResult); + } + + public static createJsonTestCommandResult( + jsonResult: string, + ): JsonTestCommandResult { + return new JsonTestCommandResult(jsonResult); + } +} + +class HumanReadableTestCommandResult extends TestCommandResult { + protected jsonResult = ''; + + constructor(humanReadableResult: string, jsonResult: string) { + super(humanReadableResult); + this.jsonResult = jsonResult; + } + + public getJsonResult(): string { + return this.jsonResult; + } +} + +class JsonTestCommandResult extends TestCommandResult { + constructor(jsonResult: string) { + super(jsonResult); + } + + public getJsonResult(): string { + return this.result; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index eef8dbc2bb..4e0ef8030c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import * as analytics from '../lib/analytics'; import * as alerts from '../lib/alerts'; import * as sln from '../lib/sln'; import { args as argsLib, Args } from './args'; +import { CommandResult, TestCommandResult } from './commands/types'; import { copy } from './copy'; import spinner = require('../lib/spinner'); import errors = require('../lib/errors/legacy-errors'); @@ -26,6 +27,11 @@ import { import stripAnsi from 'strip-ansi'; import { ExcludeFlagInvalidInputError } from '../lib/errors/exclude-flag-invalid-input'; import { modeValidation } from './modes'; +import { JsonFileOutputBadInputError } from '../lib/errors/json-file-output-bad-input-error'; +import { + createDirectory, + writeContentsToFileSwallowingErrors, +} from '../lib/json-file-output'; const debug = Debug('snyk'); const EXIT_CODES = { @@ -34,13 +40,18 @@ const EXIT_CODES = { }; async function runCommand(args: Args) { - const result = await args.method(...args.options._); + const commandResult: CommandResult | string = await args.method( + ...args.options._, + ); + const res = analytics({ args: args.options._, command: args.command, org: args.options.org, }); + const result = commandResult.toString(); + if (result && !args.options.quiet) { if (args.options.copy) { copy(result); @@ -50,6 +61,19 @@ async function runCommand(args: Args) { } } + // also save the json (in error.json) to file if option is set + if (args.command === 'test') { + const jsonOutputFile = args.options['json-file-output']; + if (jsonOutputFile) { + const jsonOutputFileStr = jsonOutputFile as string; + const fullOutputFilePath = getFullPath(jsonOutputFileStr); + saveJsonResultsToFile( + stripAnsi((commandResult as TestCommandResult).getJsonResult()), + fullOutputFilePath, + ); + } + } + return res; } @@ -87,6 +111,16 @@ async function handleError(args, error) { } } + // also save the json (in error.json) to file if `--json-file-output` option is set + const jsonOutputFile = args.options['json-file-output']; + if (jsonOutputFile && error.jsonStringifiedResults) { + const fullOutputFilePath = getFullPath(jsonOutputFile); + saveJsonResultsToFile( + stripAnsi(error.jsonStringifiedResults), + fullOutputFilePath, + ); + } + const analyticsError = vulnsFound ? { stack: error.jsonNoVulns, @@ -121,6 +155,37 @@ async function handleError(args, error) { return { res, exitCode }; } +function getFullPath(filepathFragment: string): string { + if (pathLib.isAbsolute(filepathFragment)) { + return filepathFragment; + } else { + const fullPath = pathLib.join(process.cwd(), filepathFragment); + return fullPath; + } +} + +function saveJsonResultsToFile( + stringifiedJson: string, + jsonOutputFile: string, +) { + if (!jsonOutputFile) { + console.error('empty jsonOutputFile'); + return; + } + + if (jsonOutputFile.constructor.name !== String.name) { + console.error('--json-output-file should be a filename path'); + return; + } + + // create the directory if it doesn't exist + const dirPath = pathLib.dirname(jsonOutputFile); + const createDirSuccess = createDirectory(dirPath); + if (createDirSuccess) { + writeContentsToFileSwallowingErrors(jsonOutputFile, stringifiedJson); + } +} + function checkRuntime() { if (!runtime.isSupported(process.versions.node)) { console.error( @@ -221,6 +286,25 @@ async function main() { throw new FileFlagBadInputError(); } + if (args.options['json-file-output'] && args.command !== 'test') { + throw new UnsupportedOptionCombinationError([ + args.command, + 'json-file-output', + ]); + } + + const jsonFileOptionSet: boolean = 'json-file-output' in args.options; + if (jsonFileOptionSet) { + const jsonFileOutputValue = args.options['json-file-output']; + if (!jsonFileOutputValue || typeof jsonFileOutputValue !== 'string') { + throw new JsonFileOutputBadInputError(); + } + // On Windows, seems like quotes get passed in + if (jsonFileOutputValue === "''" || jsonFileOutputValue === '""') { + throw new JsonFileOutputBadInputError(); + } + } + checkPaths(args); res = await runCommand(args); diff --git a/src/lib/errors/json-file-output-bad-input-error.ts b/src/lib/errors/json-file-output-bad-input-error.ts new file mode 100644 index 0000000000..d5aeabe59f --- /dev/null +++ b/src/lib/errors/json-file-output-bad-input-error.ts @@ -0,0 +1,13 @@ +import { CustomError } from './custom-error'; + +export class JsonFileOutputBadInputError extends CustomError { + private static ERROR_CODE = 422; + private static ERROR_MESSAGE = + 'Empty --json-file-output argument. Did you mean --file=path/to/output-file.json ?'; + + constructor() { + super(JsonFileOutputBadInputError.ERROR_MESSAGE); + this.code = JsonFileOutputBadInputError.ERROR_CODE; + this.userMessage = JsonFileOutputBadInputError.ERROR_MESSAGE; + } +} diff --git a/src/lib/json-file-output.ts b/src/lib/json-file-output.ts new file mode 100644 index 0000000000..4f1c90717b --- /dev/null +++ b/src/lib/json-file-output.ts @@ -0,0 +1,55 @@ +import { gte } from 'semver'; +import { existsSync, mkdirSync, createWriteStream } from 'fs'; + +export const MIN_VERSION_FOR_MKDIR_RECURSIVE = '10.12.0'; + +/** + * Attempts to create a directory and fails quietly if it cannot. Rather than throwing errors it logs them to stderr and returns false. + * It will attempt to recursively nested direcotry (ex `mkdir -p` style) if it needs to but will fail to do so with Node < 10 LTS. + * @param newDirectoryFullPath the full path to a directory to create + * @returns true if either the directory already exists or it is successful in creating one or false if it fails to create it. + */ +export function createDirectory(newDirectoryFullPath: string): boolean { + // if the path already exists, true + // if we successfully create the directory, return true + // if we can't successfully create the directory, either because node < 10 and recursive or some other failure, catch the error and return false + + if (existsSync(newDirectoryFullPath)) { + return true; + } + + const nodeVersion = process.version; + + try { + if (gte(nodeVersion, MIN_VERSION_FOR_MKDIR_RECURSIVE)) { + // nodeVersion is >= 10.12.0 - required for mkdirsync recursive + const options: any = { recursive: true }; // TODO: remove this after we drop support for node v8 + mkdirSync(newDirectoryFullPath, options); + return true; + } else { + // nodeVersion is < 10.12.0 + mkdirSync(newDirectoryFullPath); + return true; + } + } catch (err) { + console.error(err); + console.error(`could not create directory ${newDirectoryFullPath}`); + return false; + } +} + +export function writeContentsToFileSwallowingErrors( + jsonOutputFile: string, + contents: string, +) { + try { + const ws = createWriteStream(jsonOutputFile, { flags: 'w' }); + ws.on('error', (err) => { + console.error(err); + }); + ws.write(contents); + ws.end('\n'); + } catch (err) { + console.error(err); + } +} diff --git a/test/acceptance/cli-args.test.ts b/test/acceptance/cli-args.test.ts index c305d7360f..7ed0140015 100644 --- a/test/acceptance/cli-args.test.ts +++ b/test/acceptance/cli-args.test.ts @@ -1,6 +1,9 @@ import { test } from 'tap'; import { exec } from 'child_process'; -import { sep } from 'path'; +import { sep, join } from 'path'; +import { readFileSync, unlinkSync, rmdirSync, mkdirSync, existsSync } from 'fs'; +import { v4 as uuidv4 } from 'uuid'; + const osName = require('os-name'); const main = './dist/cli/index.js'.replace(/\//g, sep); @@ -187,3 +190,161 @@ test('`test --exclude=path/to/dir displays error message`', (t) => { }, ); }); + +test('`other commands not allowed with --json-file-output`', (t) => { + const commandsNotCompatibleWithJsonFileOutput = [ + 'auth', + 'config', + 'help', + 'ignore', + 'modules', + 'monitor', + 'policy', + 'protect', + 'version', + 'wizard', + 'woof', + ]; + + t.plan(commandsNotCompatibleWithJsonFileOutput.length); + + for (const nextCommand of commandsNotCompatibleWithJsonFileOutput) { + exec(`node ${main} ${nextCommand} --json-file-output`, (err, stdout) => { + if (err) { + throw err; + } + t.match( + stdout.trim(), + `The following option combination is not currently supported: ${nextCommand} + json-file-output`, + `correct error output when ${nextCommand} is used with --json-file-output`, + ); + }); + } +}); + +test('`test --json-file-output no value produces error message`', (t) => { + const optionsToTest = [ + '--json-file-output', + '--json-file-output=', + '--json-file-output=""', + "--json-file-output=''", + ]; + + t.plan(optionsToTest.length); + + const validate = (jsonFileOutputOption: string) => { + const fullCommand = `node ${main} test ${jsonFileOutputOption}`; + exec(fullCommand, (err, stdout) => { + if (err) { + throw err; + } + t.equals( + stdout.trim(), + 'Empty --json-file-output argument. Did you mean --file=path/to/output-file.json ?', + ); + }); + }; + + optionsToTest.forEach(validate); +}); + +test('`test --json-file-output can save JSON output to file while sending human readable output to stdout`', (t) => { + t.plan(2); + + exec( + `node ${main} test --json-file-output=snyk-direct-json-test-output.json`, + (err, stdout) => { + if (err) { + throw err; + } + t.match(stdout, 'Organization:', 'contains human readable output'); + const outputFileContents = readFileSync( + 'snyk-direct-json-test-output.json', + 'utf-8', + ); + unlinkSync('./snyk-direct-json-test-output.json'); + const jsonObj = JSON.parse(outputFileContents); + const okValue = jsonObj.ok as boolean; + t.ok(okValue, 'JSON output ok'); + }, + ); +}); + +test('`test --json-file-output produces same JSON output as normal JSON output to stdout`', (t) => { + t.plan(1); + + exec( + `node ${main} test --json --json-file-output=snyk-direct-json-test-output.json`, + (err, stdout) => { + if (err) { + throw err; + } + const stdoutJson = stdout; + const outputFileContents = readFileSync( + 'snyk-direct-json-test-output.json', + 'utf-8', + ); + unlinkSync('./snyk-direct-json-test-output.json'); + t.equals(stdoutJson, outputFileContents); + }, + ); +}); + +test('`test --json-file-output can handle a relative path`', (t) => { + t.plan(1); + + // if 'test-output' doesn't exist, created it + if (!existsSync('test-output')) { + mkdirSync('test-output'); + } + + const tempFolder = uuidv4(); + const outputPath = `test-output/${tempFolder}/snyk-direct-json-test-output.json`; + + exec( + `node ${main} test --json --json-file-output=${outputPath}`, + (err, stdout) => { + if (err) { + throw err; + } + const stdoutJson = stdout; + const outputFileContents = readFileSync(outputPath, 'utf-8'); + unlinkSync(outputPath); + rmdirSync(`test-output/${tempFolder}`); + t.equals(stdoutJson, outputFileContents); + }, + ); +}); + +test( + '`test --json-file-output can handle an absolute path`', + { skip: iswindows }, + (t) => { + t.plan(1); + + // if 'test-output' doesn't exist, created it + if (!existsSync('test-output')) { + mkdirSync('test-output'); + } + + const tempFolder = uuidv4(); + const outputPath = join( + process.cwd(), + `test-output/${tempFolder}/snyk-direct-json-test-output.json`, + ); + + exec( + `node ${main} test --json --json-file-output=${outputPath}`, + (err, stdout) => { + if (err) { + throw err; + } + const stdoutJson = stdout; + const outputFileContents = readFileSync(outputPath, 'utf-8'); + unlinkSync(outputPath); + rmdirSync(`test-output/${tempFolder}`); + t.equals(stdoutJson, outputFileContents); + }, + ); + }, +); diff --git a/test/acceptance/cli-json-file-output.test.ts b/test/acceptance/cli-json-file-output.test.ts new file mode 100644 index 0000000000..e4576db3a1 --- /dev/null +++ b/test/acceptance/cli-json-file-output.test.ts @@ -0,0 +1,137 @@ +import * as tap from 'tap'; +import * as cli from '../../src/cli/commands'; +import { fakeServer } from './fake-server'; +import { chdirWorkspaces, getWorkspaceJSON } from './workspace-helper'; +import { TestCommandResult } from '../../src/cli/commands/types'; + +const { test, only } = tap; +(tap as any).runOnly = false; // <- for debug. set to true, and replace a test to only(..) + +const port = (process.env.PORT = process.env.SNYK_PORT = '12345'); +const BASE_API = '/api/v1'; +process.env.SNYK_API = 'http://localhost:' + port + BASE_API; +process.env.SNYK_HOST = 'http://localhost:' + port; +process.env.LOG_LEVEL = '0'; +const apiKey = '123456789'; +let oldkey; +let oldendpoint; +const server = fakeServer(BASE_API, apiKey); +const before = tap.runOnly ? only : test; +const after = tap.runOnly ? only : test; + +// fake server responses +const noVulnsResult = getWorkspaceJSON( + 'fail-on', + 'no-vulns', + 'vulns-result.json', +); +const noFixableResult = getWorkspaceJSON( + 'fail-on', + 'no-fixable', + 'vulns-result.json', +); + +// @later: remove this config stuff. +// Was copied straight from ../src/cli-server.js +before('setup', async (t) => { + t.plan(3); + let key = await cli.config('get', 'api'); + oldkey = key; + t.pass('existing user config captured'); + + key = await cli.config('get', 'endpoint'); + oldendpoint = key; + t.pass('existing user endpoint captured'); + + await new Promise((resolve) => { + server.listen(port, resolve); + }); + t.pass('started demo server'); + t.end(); +}); + +// @later: remove this config stuff. +// Was copied straight from ../src/cli-server.js +before('prime config', async (t) => { + await cli.config('set', 'api=' + apiKey); + t.pass('api token set'); + await cli.config('unset', 'endpoint'); + t.pass('endpoint removed'); + t.end(); +}); + +test('test with --json returns without error and with JsonTestCommandResult return type when no vulns found', async (t) => { + try { + server.setNextResponse(noVulnsResult); + chdirWorkspaces('fail-on'); + const res: TestCommandResult = await cli.test('no-vulns', { + json: true, + }); + t.pass('should not throw an exception'); + const resType = res.constructor.name; + t.equal(resType, 'JsonTestCommandResult'); + } catch (err) { + t.fail('should not thrown an exception'); + } +}); + +test('test without --json returns without error and with HumanReadableTestCommandResult return type when no vulns found', async (t) => { + try { + server.setNextResponse(noVulnsResult); + chdirWorkspaces('fail-on'); + const res: TestCommandResult = await cli.test('no-vulns', {}); + t.pass('should not throw an exception'); + const resType = res.constructor.name; + t.equal(resType, 'HumanReadableTestCommandResult'); + } catch (err) { + t.fail('should not thrown an exception'); + } +}); + +test('test with --json throws error and error contains json output with vulnerabilities when vulns found', async (t) => { + try { + server.setNextResponse(noFixableResult); + chdirWorkspaces('fail-on'); + await cli.test('no-fixable', { + json: true, + }); + t.fail('should throw exception'); + } catch (err) { + t.pass('expected err to be thrown'); + t.equal(err.code, 'VULNS', 'should throw exception'); + const returnedJson = JSON.parse(err.jsonStringifiedResults); + t.ok(returnedJson.vulnerabilities.length > 0); + } +}); + +// @later: try and remove this config stuff +// Was copied straight from ../src/cli-server.js +after('teardown', async (t) => { + t.plan(4); + + delete process.env.SNYK_API; + delete process.env.SNYK_HOST; + delete process.env.SNYK_PORT; + t.notOk(process.env.SNYK_PORT, 'fake env values cleared'); + + await new Promise((resolve) => { + server.close(resolve); + }); + t.pass('server shutdown'); + let key = 'set'; + let value = 'api=' + oldkey; + if (!oldkey) { + key = 'unset'; + value = 'api'; + } + await cli.config(key, value); + t.pass('user config restored'); + if (oldendpoint) { + await cli.config('endpoint', oldendpoint); + t.pass('user endpoint restored'); + t.end(); + } else { + t.pass('no endpoint'); + t.end(); + } +}); diff --git a/test/acceptance/cli-test/cli-test.all-projects.spec.ts b/test/acceptance/cli-test/cli-test.all-projects.spec.ts index 6ea1e11a7d..e7493c8ff8 100644 --- a/test/acceptance/cli-test/cli-test.all-projects.spec.ts +++ b/test/acceptance/cli-test/cli-test.all-projects.spec.ts @@ -2,6 +2,7 @@ import { AcceptanceTests } from './cli-test.acceptance.test'; import { getWorkspaceJSON } from '../workspace-helper'; import * as path from 'path'; import * as sinon from 'sinon'; +import { CommandResult } from '../../../src/cli/commands/types'; export const AllProjectsTests: AcceptanceTests = { language: 'Mixed', @@ -29,7 +30,7 @@ export const AllProjectsTests: AcceptanceTests = { loadPlugin.withArgs('pip').returns(mockPlugin); loadPlugin.callThrough(); // don't mock other plugins - const result = await params.cli.test('mono-repo-project', { + const result: CommandResult = await params.cli.test('mono-repo-project', { allProjects: true, detectionDepth: 1, skipUnresolved: true, @@ -59,38 +60,42 @@ export const AllProjectsTests: AcceptanceTests = { // results should contain test results from both package managers t.match( - result, + result.getDisplayResults(), 'Package manager: rubygems', 'contains package manager rubygems', ); t.match( - result, + result.getDisplayResults(), 'Target file: Gemfile.lock', 'contains target file Gemfile.lock', ); t.match( - result, + result.getDisplayResults(), 'Project name: shallow-goof', 'contains correct project name for npm', ); - t.match(result, 'Package manager: npm', 'contains package manager npm'); t.match( - result, + result.getDisplayResults(), + 'Package manager: npm', + 'contains package manager npm', + ); + t.match( + result.getDisplayResults(), 'Target file: package-lock.json', 'contains target file package-lock.json', ); t.match( - result, + result.getDisplayResults(), 'Package manager: maven', 'contains package manager maven', ); t.match( - result, + result.getDisplayResults(), 'Target file: pom.xml', 'contains target file pom.xml', ); t.match( - result, + result.getDisplayResults(), 'Target file: Pipfile', 'contains target file Pipfile', ); @@ -138,7 +143,7 @@ export const AllProjectsTests: AcceptanceTests = { .returns(mockRequirements); loadPlugin.callThrough(); // don't mock other plugins - const result = await params.cli.test('mono-repo-project', { + const result: CommandResult = await params.cli.test('mono-repo-project', { allProjects: true, detectionDepth: 3, allowMissing: true, // allow requirements.txt to pass when deps not installed @@ -173,89 +178,97 @@ export const AllProjectsTests: AcceptanceTests = { // ruby t.match( - result, + result.getDisplayResults(), 'Package manager: rubygems', 'contains package manager rubygems', ); t.match( - result, + result.getDisplayResults(), 'Target file: Gemfile.lock', 'contains target file Gemfile.lock', ); t.match( - result, + result.getDisplayResults(), `Target file: bundler-app${path.sep}Gemfile.lock`, `contains target file bundler-app${path.sep}Gemfile.lock`, ); // npm t.match( - result, + result.getDisplayResults(), 'Project name: shallow-goof', 'contains correct project name for npm', ); t.match( - result, + result.getDisplayResults(), 'Project name: goof', 'contains correct project name for npm', ); - t.match(result, 'Package manager: npm', 'contains package manager npm'); t.match( - result, + result.getDisplayResults(), + 'Package manager: npm', + 'contains package manager npm', + ); + t.match( + result.getDisplayResults(), 'Target file: package-lock.json', 'contains target file package-lock.json', ); t.match( - result, + result.getDisplayResults(), `Target file: npm-project${path.sep}package.json`, `contains target file npm-project${path.sep}package.json`, ); // maven t.match( - result, + result.getDisplayResults(), 'Package manager: maven', 'contains package manager maven', ); t.match( - result, + result.getDisplayResults(), 'Target file: pom.xml', 'contains target file pom.xml', ); // nuget t.match( - result, + result.getDisplayResults(), 'Package manager: nuget', 'contains package manager nuget', ); t.match( - result, + result.getDisplayResults(), 'Target file: packages.config', 'contains target file packages.config', ); // paket t.match( - result, + result.getDisplayResults(), 'Package manager: paket', 'contains package manager paket', ); t.match( - result, + result.getDisplayResults(), 'Target file: paket.dependencies', 'contains target file paket.dependencies', ); // pip - t.match(result, 'Package manager: pip', 'contains package manager pip'); t.match( - result, + result.getDisplayResults(), + 'Package manager: pip', + 'contains package manager pip', + ); + t.match( + result.getDisplayResults(), 'Target file: Pipfile', 'contains target file Pipfile', ); t.match( - result, + result.getDisplayResults(), `Target file: python-app-with-req-file${path.sep}requirements.txt`, `contains target file python-app-with-req-file${path.sep}requirements.txt`, ); @@ -385,7 +398,7 @@ export const AllProjectsTests: AcceptanceTests = { const spyPlugin = sinon.spy(params.plugins, 'loadPlugin'); t.teardown(spyPlugin.restore); - const result = await params.cli.test('maven-multi-app', { + const result: CommandResult = await params.cli.test('maven-multi-app', { allProjects: true, detectionDepth: 2, }); @@ -412,17 +425,17 @@ export const AllProjectsTests: AcceptanceTests = { ); }); t.match( - result, + result.getDisplayResults(), 'Package manager: maven', 'contains package manager maven', ); t.match( - result, + result.getDisplayResults(), 'Target file: pom.xml', 'contains target file pom.xml', ); t.match( - result, + result.getDisplayResults(), `Target file: simple-child${path.sep}pom.xml`, `contains target file simple-child${path.sep}pom.xml`, ); @@ -489,9 +502,12 @@ export const AllProjectsTests: AcceptanceTests = { utils, ) => async (t) => { utils.chdirWorkspaces(); - const result = await params.cli.test('mono-repo-project-manifests-only', { - allProjects: true, - }); + const result: CommandResult = await params.cli.test( + 'mono-repo-project-manifests-only', + { + allProjects: true, + }, + ); params.server.popRequests(3).forEach((req) => { t.equal(req.method, 'POST', 'makes POST request'); t.equal( @@ -510,28 +526,32 @@ export const AllProjectsTests: AcceptanceTests = { // results should contain test results from all package managers t.match( - result, + result.getDisplayResults(), 'Package manager: rubygems', 'contains package manager rubygems', ); t.match( - result, + result.getDisplayResults(), 'Target file: Gemfile.lock', 'contains target file Gemfile.lock', ); - t.match(result, 'Package manager: npm', 'contains package manager npm'); t.match( - result, + result.getDisplayResults(), + 'Package manager: npm', + 'contains package manager npm', + ); + t.match( + result.getDisplayResults(), 'Target file: package-lock.json', 'contains target file package-lock.json', ); t.match( - result, + result.getDisplayResults(), 'Package manager: maven', 'contains package manager maven', ); t.match( - result, + result.getDisplayResults(), 'Target file: pom.xml', 'contains target file pom.xml', ); @@ -542,7 +562,9 @@ export const AllProjectsTests: AcceptanceTests = { const spyPlugin = sinon.spy(params.plugins, 'loadPlugin'); t.teardown(spyPlugin.restore); - const res = await params.cli.test('ruby-app', { allProjects: true }); + const res: CommandResult = await params.cli.test('ruby-app', { + allProjects: true, + }); t.ok(spyPlugin.withArgs('rubygems').calledOnce, 'calls rubygems plugin'); t.notOk(spyPlugin.withArgs('npm').calledOnce, "doesn't call npm plugin"); @@ -552,12 +574,12 @@ export const AllProjectsTests: AcceptanceTests = { ); t.match( - res, + res.getDisplayResults(), 'Package manager: rubygems', 'contains package manager rubygems', ); t.match( - res, + res.getDisplayResults(), 'Target file: Gemfile.lock', 'contains target file Gemfile.lock', ); @@ -660,10 +682,13 @@ export const AllProjectsTests: AcceptanceTests = { loadPlugin.callThrough(); // don't mock other plugins try { - const res = await params.cli.test('monorepo-with-nuget', { - allProjects: true, - detectionDepth: 4, - }); + const res: CommandResult = await params.cli.test( + 'monorepo-with-nuget', + { + allProjects: true, + detectionDepth: 4, + }, + ); t.equal( loadPlugin.withArgs('nuget').callCount, 2, @@ -680,49 +705,61 @@ export const AllProjectsTests: AcceptanceTests = { ); t.ok(loadPlugin.withArgs('paket').calledOnce, 'calls nuget plugin'); t.match( - res, + res.getDisplayResults(), /Tested 6 projects, no vulnerable paths were found./, 'Six projects tested', ); t.match( - res, + res.getDisplayResults(), `Target file: src${path.sep}paymentservice${path.sep}package-lock.json`, 'Npm project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: src${path.sep}cocoapods-app${path.sep}Podfile`, 'Cocoapods project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: src${path.sep}frontend${path.sep}Gopkg.lock`, 'Go dep project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: src${path.sep}cartservice-nuget${path.sep}obj${path.sep}project.assets.json`, 'Nuget project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: test${path.sep}nuget-app-4${path.sep}packages.config`, 'Nuget project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: test${path.sep}paket-app${path.sep}paket.dependencies`, 'Paket project targetFile is as expected', ); - t.match(res, 'Package manager: nuget', 'Nuget package manager'); t.match( - res, + res.getDisplayResults(), + 'Package manager: nuget', + 'Nuget package manager', + ); + t.match( + res.getDisplayResults(), 'Package manager: cocoapods', 'Cocoapods package manager', ); - t.match(res, 'Package manager: npm', 'Npm package manager'); - t.match(res, 'Package manager: golangdep', 'Go dep package manager'); + t.match( + res.getDisplayResults(), + 'Package manager: npm', + 'Npm package manager', + ); + t.match( + res.getDisplayResults(), + 'Package manager: golangdep', + 'Go dep package manager', + ); } catch (err) { t.fail('expected to pass'); } @@ -732,7 +769,7 @@ export const AllProjectsTests: AcceptanceTests = { const spyPlugin = sinon.spy(params.plugins, 'loadPlugin'); t.teardown(spyPlugin.restore); - const result = await params.cli.test('composer-app', { + const result: CommandResult = await params.cli.test('composer-app', { allProjects: true, }); @@ -748,12 +785,12 @@ export const AllProjectsTests: AcceptanceTests = { t.match(req.url, '/api/v1/test', 'posts to correct url'); }); t.match( - result, + result.getDisplayResults(), 'Package manager: composer', 'contains package manager composer', ); t.match( - result, + result.getDisplayResults(), 'Target file: composer.lock', 'contains target file composer.lock', ); @@ -781,7 +818,7 @@ export const AllProjectsTests: AcceptanceTests = { loadPlugin.withArgs('govendor').returns(mockPlugin); loadPlugin.callThrough(); // don't mock npm plugin - const res = await params.cli.test('mono-repo-go', { + const res: CommandResult = await params.cli.test('mono-repo-go', { allProjects: true, detectionDepth: 3, }); @@ -793,34 +830,50 @@ export const AllProjectsTests: AcceptanceTests = { 'calls go vendor plugin', ); t.match( - res, + res.getDisplayResults(), /Tested 4 projects, no vulnerable paths were found./, 'Four projects tested', ); t.match( - res, + res.getDisplayResults(), `Target file: hello-dep${path.sep}Gopkg.lock`, 'Go dep project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: hello-mod${path.sep}go.mod`, 'Go mod project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: hello-node${path.sep}package-lock.json`, 'Npm project targetFile is as expected', ); t.match( - res, + res.getDisplayResults(), `Target file: hello-vendor${path.sep}vendor${path.sep}vendor.json`, 'Go vendor project targetFile is as expected', ); - t.match(res, 'Package manager: golangdep', 'Nuget package manager'); - t.match(res, 'Package manager: gomodules', 'Nuget package manager'); - t.match(res, 'Package manager: npm', 'Npm package manager'); - t.match(res, 'Package manager: govendor', 'Go dep package manager'); + t.match( + res.getDisplayResults(), + 'Package manager: golangdep', + 'Nuget package manager', + ); + t.match( + res.getDisplayResults(), + 'Package manager: gomodules', + 'Nuget package manager', + ); + t.match( + res.getDisplayResults(), + 'Package manager: npm', + 'Npm package manager', + ); + t.match( + res.getDisplayResults(), + 'Package manager: govendor', + 'Go dep package manager', + ); }, }, }; diff --git a/test/acceptance/cli-test/cli-test.generic.spec.ts b/test/acceptance/cli-test/cli-test.generic.spec.ts index f236027a5b..c8895bbadf 100644 --- a/test/acceptance/cli-test/cli-test.generic.spec.ts +++ b/test/acceptance/cli-test/cli-test.generic.spec.ts @@ -84,7 +84,11 @@ export const GenericTests: AcceptanceTests = { ); t.match(req.url, '/vuln/npm/semver', 'gets from correct url'); t.equal(req.query.org, 'EFF', 'org sent as a query in request'); - t.match(output, 'Testing semver', 'has "Testing semver" message'); + t.match( + output.getDisplayResults(), + 'Testing semver', + 'has "Testing semver" message', + ); t.notMatch(output, 'Remediation', 'shows no remediation advice'); t.notMatch(output, 'snyk wizard', 'does not suggest `snyk wizard`'); }, diff --git a/test/acceptance/cli-test/cli-test.gradle.spec.ts b/test/acceptance/cli-test/cli-test.gradle.spec.ts index ddc5a5d6dd..323e3db499 100644 --- a/test/acceptance/cli-test/cli-test.gradle.spec.ts +++ b/test/acceptance/cli-test/cli-test.gradle.spec.ts @@ -1,6 +1,7 @@ import * as sinon from 'sinon'; import { legacyPlugin as pluginApi } from '@snyk/cli-interface'; import { AcceptanceTests } from './cli-test.acceptance.test'; +import { CommandResult } from '../../../src/cli/commands/types'; export const GradleTests: AcceptanceTests = { language: 'Gradle', @@ -23,7 +24,10 @@ export const GradleTests: AcceptanceTests = { t.teardown(loadPlugin.restore); loadPlugin.withArgs('gradle').returns(plugin); - const res = await params.cli.test('gradle-kotlin-dsl-app'); + const commandResult: CommandResult = await params.cli.test( + 'gradle-kotlin-dsl-app', + ); + const res: string = commandResult.getDisplayResults(); const meta = res.slice(res.indexOf('Organization:')).split('\n'); t.match(meta[0], /Organization:\s+test-org/, 'organization displayed'); t.match( @@ -64,7 +68,8 @@ export const GradleTests: AcceptanceTests = { t.teardown(loadPlugin.restore); loadPlugin.withArgs('gradle').returns(plugin); - const res = await params.cli.test('gradle-app'); + const commandResult: CommandResult = await params.cli.test('gradle-app'); + const res = commandResult.getDisplayResults(); const meta = res.slice(res.indexOf('Organization:')).split('\n'); t.false( @@ -168,7 +173,10 @@ export const GradleTests: AcceptanceTests = { t.teardown(loadPlugin.restore); loadPlugin.withArgs('gradle').returns(plugin); - const res = await params.cli.test('gradle-app', { allSubProjects: true }); + const commandResult: CommandResult = await params.cli.test('gradle-app', { + allSubProjects: true, + }); + const res = commandResult.getDisplayResults(); t.true( ((spyPlugin.args[0] as any)[2] as any).allSubProjects, '`allSubProjects` option is sent', diff --git a/test/acceptance/cli-test/cli-test.npm.spec.ts b/test/acceptance/cli-test/cli-test.npm.spec.ts index a7d190d656..761b1fe643 100644 --- a/test/acceptance/cli-test/cli-test.npm.spec.ts +++ b/test/acceptance/cli-test/cli-test.npm.spec.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import { AcceptanceTests } from './cli-test.acceptance.test'; +import { CommandResult } from '../../../src/cli/commands/types'; export const NpmTests: AcceptanceTests = { language: 'NPM', @@ -78,7 +79,10 @@ export const NpmTests: AcceptanceTests = { t, ) => { utils.chdirWorkspaces(); - const res = await params.cli.test('npm-package-policy'); + const commandResult: CommandResult = await params.cli.test( + 'npm-package-policy', + ); + const res = commandResult.getDisplayResults(); const meta = res.slice(res.indexOf('Organization:')).split('\n'); t.match(meta[0], /Organization:\s+test-org/, 'organization displayed'); t.match(meta[1], /Package manager:\s+npm/, 'package manager displayed'); diff --git a/test/acceptance/cli-test/cli-test.ruby.spec.ts b/test/acceptance/cli-test/cli-test.ruby.spec.ts index 97f4b0f43f..e765337ced 100644 --- a/test/acceptance/cli-test/cli-test.ruby.spec.ts +++ b/test/acceptance/cli-test/cli-test.ruby.spec.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as _ from '@snyk/lodash'; import { AcceptanceTests } from './cli-test.acceptance.test'; import { getWorkspaceJSON } from '../workspace-helper'; +import { CommandResult } from '../../../src/cli/commands/types'; export const RubyTests: AcceptanceTests = { language: 'Ruby', @@ -43,7 +44,8 @@ export const RubyTests: AcceptanceTests = { '`test ruby-app` meta when no vulns': (params, utils) => async (t) => { utils.chdirWorkspaces(); - const res = await params.cli.test('ruby-app'); + const commandResult: CommandResult = await params.cli.test('ruby-app'); + const res = commandResult.getDisplayResults(); const meta = res.slice(res.indexOf('Organization:')).split('\n'); t.match(meta[0], /Organization:\s+test-org/, 'organization displayed'); @@ -448,7 +450,10 @@ export const RubyTests: AcceptanceTests = { utils, ) => async (t) => { utils.chdirWorkspaces(); - const res = await params.cli.test('ruby-app', { file: 'Gemfile.lock' }); + const commandResult: CommandResult = await params.cli.test('ruby-app', { + file: 'Gemfile.lock', + }); + const res = commandResult.getDisplayResults(); const meta = res.slice(res.indexOf('Organization:')).split('\n'); t.match(meta[2], /Target file:\s+Gemfile.lock/, 'target file displayed'); }, diff --git a/test/cli-command-types.test.ts b/test/cli-command-types.test.ts new file mode 100644 index 0000000000..4069991aa1 --- /dev/null +++ b/test/cli-command-types.test.ts @@ -0,0 +1,42 @@ +import { test } from 'tap'; +import { CommandResult, TestCommandResult } from '../src/cli/commands/types'; + +test('createHumanReadableTestCommandResult', (t) => { + t.plan(3); + const hrRes = TestCommandResult.createHumanReadableTestCommandResult( + 'hr result', + '{ json result}', + ); + t.equal(hrRes.toString(), 'hr result'); + t.equal(hrRes.getDisplayResults(), 'hr result'); + t.equal(hrRes.getJsonResult(), '{ json result}'); +}); + +test('createJsonTestCommandResult', (t) => { + t.plan(3); + const result = TestCommandResult.createJsonTestCommandResult( + '{ json result}', + ); + t.equal(result.toString(), '{ json result}'); + t.equal(result.getDisplayResults(), '{ json result}'); + t.equal(result.getJsonResult(), '{ json result}'); +}); + +test('CommandResult is a HumanReadableTestCommandResult', (t) => { + t.plan(2); + const result: CommandResult = TestCommandResult.createHumanReadableTestCommandResult( + 'hr result', + '{ json result}', + ); + t.equal(result.toString(), 'hr result'); + t.equal(result.getDisplayResults(), 'hr result'); +}); + +test('CommandResult is a JsonTestCommandResult', (t) => { + t.plan(2); + const result: CommandResult = TestCommandResult.createJsonTestCommandResult( + '{ json result}', + ); + t.equal(result.toString(), '{ json result}'); + t.equal(result.getDisplayResults(), '{ json result}'); +}); diff --git a/test/json-file-output.test.ts b/test/json-file-output.test.ts new file mode 100644 index 0000000000..10cccb3fe8 --- /dev/null +++ b/test/json-file-output.test.ts @@ -0,0 +1,137 @@ +import * as tap from 'tap'; +const fs = require('fs'); +import * as pathLib from 'path'; +import { gte } from 'semver'; +const osName = require('os-name'); + +import { + createDirectory, + MIN_VERSION_FOR_MKDIR_RECURSIVE, + writeContentsToFileSwallowingErrors, +} from '../src/lib/json-file-output'; + +const iswindows = + osName() + .toLowerCase() + .indexOf('windows') === 0; + +const test = tap.test; + +const testOutputRelative = 'test-output'; +const testOutputFull = pathLib.join(process.cwd(), testOutputRelative); + +const levelOneRelative = 'test-output/level-one'; +const levelOneFull = pathLib.join(process.cwd(), levelOneRelative); + +const readonlyRelative = 'test-output/read-only'; +const readonlyFull = pathLib.join(process.cwd(), readonlyRelative); +const testOutputFileFull = pathLib.join(testOutputFull, 'test-output.json'); + +tap.beforeEach((done) => { + cleanupOutputDirsAndFiles(); + done(); +}); + +tap.teardown(() => { + cleanupOutputDirsAndFiles(); +}); + +function cleanupOutputDirsAndFiles() { + if (fs.existsSync(levelOneFull)) { + fs.rmdirSync(levelOneFull); + } + if (fs.existsSync(testOutputFileFull)) { + console.log(`attempting to delete file ${testOutputFileFull}`); + fs.unlinkSync(testOutputFileFull); + if (fs.existsSync(testOutputFileFull)) { + console.log( + `${testOutputFileFull} still exists after attempting to delete it`, + ); + } else { + console.log(`${testOutputFileFull} appears to have been deleted`); + } + } + if (fs.existsSync(readonlyFull)) { + fs.rmdirSync(readonlyFull); + } + + // try-catch because seems like in Windows we can't delete the test-output directory because it + // thinks testOutputFileFull still exists + try { + if (fs.existsSync(testOutputFull)) { + fs.rmdirSync(testOutputFull); + } + } catch { + console.log('Error trying to delete test-output directory'); + const files = fs.readdirSync(testOutputFull); + files.forEach((file) => { + console.log(file); + }); + } +} + +test('createDirectory returns true if directory already exists - non-recursive', (t) => { + t.plan(2); + + // initially create the directory + fs.mkdirSync(testOutputFull); + + // attempt to create the directory + const success: boolean = createDirectory(testOutputFull); + t.ok(success); + + const directoryExists = fs.existsSync(testOutputFull); + t.ok(directoryExists); +}); + +test('createDirectory creates directory - recursive', (t) => { + t.plan(2); + + // attempt to create the directory requiring recursive + const success: boolean = createDirectory(levelOneFull); + const directoryExists = fs.existsSync(levelOneFull); + + // recursive should fail (return false) for node < 10 LTS and pass (return true) for node >= 10 LTS + // if node >= 10, verify that the deep folder was created + // if node 8 verify that the deep folder was not created + const nodeVersion = process.version; + if (gte(nodeVersion, MIN_VERSION_FOR_MKDIR_RECURSIVE)) { + t.ok(success); + t.ok(directoryExists); + } else { + t.notOk(success); + t.notOk(directoryExists); + } +}); + +test('writeContentsToFileSwallowingErrors can write a file', (t) => { + t.plan(1); + + // initially create the directory + fs.mkdirSync(testOutputFull); + + writeContentsToFileSwallowingErrors(testOutputFileFull, 'fake-contents'); + const fileExists = fs.existsSync(testOutputFileFull); + t.ok(fileExists, 'file exists after writing it'); +}); + +test( + 'writeContentsToFileSwallowingErrors captures any errors when attempting to write to a readonly directory', + { skip: iswindows }, + (t) => { + t.plan(2); + + // initially create the directory + fs.mkdirSync(testOutputFull); + + // create a directory without write permissions + fs.mkdirSync(readonlyFull, 0o555); + + const outputPath = pathLib.join(readonlyFull, 'test-output.json'); + + writeContentsToFileSwallowingErrors(outputPath, 'fake-contents'); + const fileExists = fs.existsSync(outputPath); + t.equals(fileExists, false); + t.pass('no exception is thrown'); // we expect to not get an error even though we can't write to this folder + }, +); diff --git a/test/system/remote-package.test.ts b/test/system/remote-package.test.ts index 101bd26a7a..4eb73872b2 100644 --- a/test/system/remote-package.test.ts +++ b/test/system/remote-package.test.ts @@ -20,6 +20,7 @@ const server = require('../cli-server')(BASE_API, apiKey, notAuthorizedApiKey); // ensure this is required *after* the demo server, since this will // configure our fake configuration too import * as cli from '../../src/cli/commands'; +import { CommandResult } from '../../src/cli/commands/types'; const before = test; const after = test; @@ -77,7 +78,8 @@ test('cli tests for online repos', async (t) => { test('multiple test arguments', async (t) => { try { - const res = await cli.test('semver@4', 'qs@6'); + const commandResult: CommandResult = await cli.test('semver@4', 'qs@6'); + const res = commandResult.getDisplayResults(); const lastLine = res .trim() .split('\n') @@ -142,7 +144,10 @@ test('multiple test arguments', async (t) => { test('test for existing remote package with dev-deps only with --dev', async (t) => { try { - const res = await cli.test('lodash@4.17.11', { dev: true }); + const commandResult: CommandResult = await cli.test('lodash@4.17.11', { + dev: true, + }); + const res = commandResult.getDisplayResults(); const lastLine = res .trim() .split('\n') @@ -163,7 +168,10 @@ test('test for existing remote package with dev-deps only', async (t) => { ciCheckerStub.returns(false); t.teardown(ciCheckerStub.restore); - const res = await cli.test('lodash@4.17.11', { dev: false }); + const commandResult: CommandResult = await cli.test('lodash@4.17.11', { + dev: false, + }); + const res = commandResult.getDisplayResults(); const lastLine = res .trim() .split('\n')