CLI linting + exit codes (#2318)

This commit is contained in:
Opender Singh
2020-06-30 12:00:33 +12:00
committed by GitHub
parent 85860ce031
commit 7d3660c8f0
24 changed files with 1200 additions and 123 deletions

View File

@@ -3,6 +3,7 @@ const mod = jest.requireActual('insomnia-testing');
module.exports = {
...mod,
generate: jest.fn(),
generateToFile: jest.fn(),
runTests: jest.fn(),
runTestsCli: jest.fn(),
};

View File

@@ -0,0 +1,5 @@
// @flow
declare module '@stoplight/spectral' {
declare module.exports: *;
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@
"rimraf": "^3.0.2"
},
"dependencies": {
"@stoplight/spectral": "^5.4.0",
"commander": "^5.1.0",
"insomnia-testing": "^2.2.9",
"openapi-2-kong": "^2.2.10",

View File

@@ -13,6 +13,7 @@ Options:
Commands:
generate Code generation utilities
run Execution utilities
lint Linting utilities
help [command] display help for command"
`;
@@ -29,6 +30,7 @@ Options:
Commands:
generate Code generation utilities
run Execution utilities
lint Linting utilities
help [command] display help for command"
`;
@@ -70,9 +72,32 @@ Options:
Commands:
generate Code generation utilities
run Execution utilities
lint Linting utilities
help [command] display help for command"
`;
exports[`Snapshot for "inso lint -h" 1`] = `
"Usage: inso lint [options] [command]
Linting utilities
Options:
-h, --help display help for command
Commands:
spec <identifier> Lint an API Specification
help [command] display help for command"
`;
exports[`Snapshot for "inso lint spec -h" 1`] = `
"Usage: inso lint spec [options] <identifier>
Lint an API Specification
Options:
-h, --help display help for command"
`;
exports[`Snapshot for "inso run -h" 1`] = `
"Usage: inso run [options] [command]
@@ -95,5 +120,6 @@ Options:
-r, --reporter <reporter> reporter to use, options are [dot, list, spec,
min, progress] (default: \\"spec\\")
-b, --bail abort (\\"bail\\") after first test failure
--keep-file do not delete the generated test file
-h, --help display help for command"
`;

View File

@@ -3,7 +3,6 @@ import * as cli from '../cli';
import { generateConfig } from '../commands/generate-config';
jest.mock('../commands/generate-config');
const originalError = console.error;
const initInso = () => {
return (args: string): void => {
@@ -21,11 +20,13 @@ describe('cli', () => {
let inso = initInso();
beforeEach(() => {
inso = initInso();
(console: any).error = jest.fn();
jest.spyOn(console, 'error').mockImplementation(() => {});
jest.spyOn(process, 'exit').mockImplementation(() => {});
(generateConfig: any).mockResolvedValue(true);
});
afterEach(() => {
(console: any).error = originalError;
jest.restoreAllMocks();
});
it('should error when required --type option is missing', () =>

View File

@@ -7,7 +7,17 @@ import * as packageJson from '../../package.json';
// These tests use the executable /bin/inso, which relies on /dist.
describe('Snapshot for', () => {
it.each(['-h', '--help', 'help', 'generate -h', 'generate config -h', 'run -h', 'run test -h'])(
it.each([
'-h',
'--help',
'help',
'generate -h',
'generate config -h',
'run -h',
'run test -h',
'lint -h',
'lint spec -h',
])(
'"inso %s"',
async args => {
const { stdout } = await execa(getBinPathSync(), args.split(' '));

View File

@@ -1,6 +1,6 @@
// @flow
import commander from 'commander';
import { getAllOptions } from '../util';
import { getAllOptions, exit, logErrorExit1 } from '../util';
describe('getAllOptions()', () => {
it('should combine options from all commands into one object', () => {
@@ -21,3 +21,45 @@ describe('getAllOptions()', () => {
parent.parse('node test command subCommand --global --subCmd'.split(' '));
});
});
describe('exit()', () => {
it('should exit 0 if successful result', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
await exit(new Promise(resolve => resolve(true)));
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('should exit 1 if unsuccessful result', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
await exit(new Promise(resolve => resolve(false)));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit 1 and print to console and if rejected', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('message');
await exit(new Promise((resolve, reject) => reject(error)));
expect(errorSpy).toHaveBeenCalledWith(error);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('logErrorExit1()', () => {
it('should exit 1 and print error to console', async () => {
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const error = new Error('message');
await logErrorExit1(error);
expect(errorSpy).toHaveBeenCalledWith(error);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,7 +1,8 @@
// @flow
import { ConversionTypeMap, generateConfig } from './commands/generate-config';
import { getVersion, createCommand, getAllOptions } from './util';
import { getVersion, createCommand, getAllOptions, logErrorExit1, exit } from './util';
import { runInsomniaTests, TestReporterEnum } from './commands/run-tests';
import { lintSpecification } from './commands/lint-specification';
function makeGenerateCommand(exitOverride: boolean) {
// inso generate
@@ -18,7 +19,7 @@ function makeGenerateCommand(exitOverride: boolean) {
`type of configuration to generate, options are [${conversionTypes}]`,
)
.option('-o, --output <path>', 'save the generated config to a file')
.action((identifier, cmd) => generateConfig(identifier, getAllOptions(cmd)));
.action((identifier, cmd) => exit(generateConfig(identifier, getAllOptions(cmd))));
return generate;
}
@@ -39,11 +40,25 @@ function makeTestCommand(exitOverride: boolean) {
TestReporterEnum.spec,
)
.option('-b, --bail', 'abort ("bail") after first test failure')
.action(cmd => runInsomniaTests(getAllOptions(cmd)));
.option('--keep-file', 'do not delete the generated test file')
.action(cmd => exit(runInsomniaTests(getAllOptions(cmd))));
return run;
}
function makeLintCommand(exitOverride: boolean) {
// inso lint
const lint = createCommand(exitOverride, 'lint').description('Linting utilities');
// inso lint spec
lint
.command('spec <identifier>')
.description('Lint an API Specification')
.action((identifier, cmd) => exit(lintSpecification(identifier, getAllOptions(cmd))));
return lint;
}
export function go(args?: Array<string>, exitOverride?: boolean): void {
if (!args) {
args = process.argv;
@@ -56,6 +71,7 @@ export function go(args?: Array<string>, exitOverride?: boolean): void {
.option('--working-dir <dir>', 'set working directory')
.addCommand(makeGenerateCommand(!!exitOverride))
.addCommand(makeTestCommand(!!exitOverride))
.addCommand(makeLintCommand(!!exitOverride))
.parseAsync(args)
.catch(err => console.log('An error occurred', err));
.catch(logErrorExit1);
}

View File

@@ -57,7 +57,7 @@ describe('generateConfig()', () => {
it('should write generated documents to file system', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
const writeFileSpy = jest.spyOn(fs.promises, 'writeFile').mockImplementation(() => {});
mock(o2k.generate).mockResolvedValue({ documents: ['a', 'b'] });
await generateConfig(filePath, { ...base, output: 'output.yaml' });
@@ -69,7 +69,7 @@ describe('generateConfig()', () => {
it('should generate documents using workingDir', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const writeFileSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
const writeFileSpy = jest.spyOn(fs.promises, 'writeFile').mockImplementation(() => {});
mock(o2k.generate).mockResolvedValue({ documents: ['a', 'b'] });
await generateConfig('file.yaml', {

View File

@@ -0,0 +1,20 @@
// @flow
import { lintSpecification } from '../lint-specification';
describe('lint specification', () => {
it('should return true for linting passed', async () => {
const result = await lintSpecification('spc_46c5a4a40e83445a9bd9d9758b86c16c', {
workingDir: 'src/db/__fixtures__/git-repo',
});
expect(result).toBe(true);
});
it('should return false for linting failed', async () => {
const result = await lintSpecification('spc_46c5a4a40e83445a9bd9d9758b86c16c', {
workingDir: 'src/db/__fixtures__/git-repo-malformed-spec',
});
expect(result).toBe(false);
});
});

View File

@@ -7,7 +7,12 @@ import type { RunTestsOptions } from '../run-tests';
jest.mock('insomnia-testing');
jest.mock('os');
jest.mock('console');
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
unlink: jest.fn(),
},
}));
describe('runInsomniaTests()', () => {
// make flow happy
@@ -39,9 +44,25 @@ describe('runInsomniaTests()', () => {
const contents = 'generated test contents';
mock(insomniaTesting.generate).mockResolvedValue(contents);
const options = { ...base, reporter: 'min', bail: true };
const options = { ...base, reporter: 'min', bail: true, keepFile: false };
await runInsomniaTests(options);
expect(insomniaTesting.runTestsCli).toHaveBeenCalledWith(contents, options);
});
it('should return false if test results have any failures', async function() {
mock(insomniaTesting.runTestsCli).mockResolvedValue(false);
const result = await runInsomniaTests(base);
expect(result).toBe(false);
});
it('should return true if test results have no failures', async function() {
mock(insomniaTesting.runTestsCli).mockResolvedValue(true);
const result = await runInsomniaTests(base);
expect(result).toBe(true);
});
});

View File

@@ -11,10 +11,10 @@ export const ConversionTypeMap: { [string]: ConversionResultType } = {
declarative: 'kong-declarative-config',
};
export type GenerateConfigOptions = GlobalOptions<{|
export type GenerateConfigOptions = GlobalOptions & {
type: $Keys<typeof ConversionTypeMap>,
output?: string,
|}>;
};
function validateOptions({ type }: GenerateConfigOptions): boolean {
if (!ConversionTypeMap[type]) {
@@ -29,9 +29,9 @@ function validateOptions({ type }: GenerateConfigOptions): boolean {
export async function generateConfig(
identifier: string,
options: GenerateConfigOptions,
): Promise<void> {
): Promise<boolean> {
if (!validateOptions(options)) {
return;
return false;
}
const { type, output } = options;
@@ -44,17 +44,13 @@ export async function generateConfig(
// try get from db
const specFromDb = db.ApiSpec.get(identifier);
try {
if (specFromDb?.contents) {
result = await o2k.generateFromString(specFromDb.contents, ConversionTypeMap[type]);
} else {
// try load as a file
const fileName = path.join(workingDir, identifier);
result = await o2k.generate(fileName, ConversionTypeMap[type]);
}
} catch (err) {
console.log('Config failed to generate:', err.message);
return;
if (specFromDb?.contents) {
result = await o2k.generateFromString(specFromDb.contents, ConversionTypeMap[type]);
} else {
// try load as a file
const fileName = path.join(workingDir, identifier);
result = await o2k.generate(fileName, ConversionTypeMap[type]);
}
const yamlDocs = result.documents.map(d => YAML.stringify(d));
@@ -64,8 +60,10 @@ export async function generateConfig(
if (output) {
const fullOutputPath = path.join(workingDir, output);
fs.writeFileSync(fullOutputPath, document);
await fs.promises.writeFile(fullOutputPath, document);
} else {
console.log(document);
}
return true;
}

View File

@@ -0,0 +1,30 @@
// @flow
import { Spectral } from '@stoplight/spectral';
import type { GlobalOptions } from '../util';
import { gitDataDirDb } from '../db/mem-db';
export type LintSpecificationOptions = GlobalOptions;
export async function lintSpecification(
identifier: string,
options: LintSpecificationOptions,
): Promise<boolean> {
const { workingDir } = options;
const db = await gitDataDirDb({ dir: workingDir, filterTypes: ['ApiSpec'] });
const specFromDb = db.ApiSpec.get(identifier);
const spectral = new Spectral();
const results = await spectral.run(specFromDb?.contents);
if (results.length) {
results.forEach(r =>
console.log(`${r.range.start.line}:${r.range.start.character} - ${r.message}`),
);
return false;
}
return true;
}

View File

@@ -11,10 +11,11 @@ export const TestReporterEnum = {
progress: 'progress',
};
export type RunTestsOptions = GlobalOptions<{|
export type RunTestsOptions = GlobalOptions & {
reporter: $Keys<typeof TestReporterEnum>,
bail?: boolean,
|}>;
keepFile?: boolean,
};
function validateOptions({ reporter }: RunTestsOptions): boolean {
if (reporter && !TestReporterEnum[reporter]) {
@@ -31,7 +32,7 @@ export async function runInsomniaTests(options: RunTestsOptions): Promise<void>
return;
}
const { reporter, bail } = options;
const { reporter, bail, keepFile } = options;
const suites = [
{
@@ -55,5 +56,5 @@ export async function runInsomniaTests(options: RunTestsOptions): Promise<void>
];
const testFileContents = await generate(suites);
await runTestsCli(testFileContents, { reporter, bail });
return await runTestsCli(testFileContents, { reporter, bail, keepFile });
}

View File

@@ -0,0 +1,32 @@
_id: spc_46c5a4a40e83445a9bd9d9758b86c16c
contentType: yaml
contents: |
openapi: '3.0.2'
info:
title: Global Security
version: '1.2'
servers:
- url: https://api.server.test/v1
tags:
- name: Folder
paths:
/global:
get:
tags:
- Folder
responses:
'200':
description: OK
/override:
get:
security:
- Key-Query: []
responses:
'200':
description: OK
created: 1589851906273
fileName: Global Security
modified: 1592254074772
parentId: wrk_012d4860c7da418a85ffea7406e1292a
type: ApiSpec

View File

@@ -0,0 +1,14 @@
_id: env_ca046a738f001eb3090261a537b1b78f86c2094c
color: null
created: 1589851906358
data:
base_url: "{{ scheme }}://{{ host }}{{ base_path }}"
dataPropertyOrder:
"&":
- base_url
isPrivate: false
metaSortKey: 1589851906358
modified: 1592254074769
name: Base environment
parentId: wrk_012d4860c7da418a85ffea7406e1292a
type: Environment

View File

@@ -0,0 +1,14 @@
_id: env_env_ca046a738f001eb3090261a537b1b78f86c2094c_sub
color: null
created: 1592252904087
data:
base_path: /v1
host: api.server.test
scheme: https
dataPropertyOrder: null
isPrivate: false
metaSortKey: 1592252904087
modified: 1592254074767
name: OpenAPI env
parentId: env_ca046a738f001eb3090261a537b1b78f86c2094c
type: Environment

View File

@@ -0,0 +1,21 @@
_id: req_wrk_012d4860c7da418a85ffea7406e1292a21946b60
authentication: {}
body: {}
created: 1592254074764
description: ""
headers: []
isPrivate: false
metaSortKey: -1592254074764
method: GET
modified: 1592254074764
name: /global
parameters: []
parentId: fld_wrk_012d4860c7da418a85ffea7406e1292a30baa249
settingDisableRenderRequestBody: false
settingEncodeUrl: true
settingFollowRedirects: global
settingRebuildPath: true
settingSendCookies: true
settingStoreCookies: true
type: Request
url: "{{ base_url }}/global"

View File

@@ -0,0 +1,21 @@
_id: req_wrk_012d4860c7da418a85ffea7406e1292ab410454b
authentication: {}
body: {}
created: 1592254074762
description: ""
headers: []
isPrivate: false
metaSortKey: -1592254074762
method: GET
modified: 1592254074762
name: /override
parameters: []
parentId: wrk_012d4860c7da418a85ffea7406e1292a
settingDisableRenderRequestBody: false
settingEncodeUrl: true
settingFollowRedirects: global
settingRebuildPath: true
settingSendCookies: true
settingStoreCookies: true
type: Request
url: "{{ base_url }}/override"

View File

@@ -0,0 +1,10 @@
_id: fld_wrk_012d4860c7da418a85ffea7406e1292a30baa249
created: 1592254074765
description: ""
environment: {}
environmentPropertyOrder: null
metaSortKey: -1592254074765
modified: 1592254074765
name: Folder
parentId: wrk_012d4860c7da418a85ffea7406e1292a
type: RequestGroup

View File

@@ -0,0 +1,8 @@
_id: wrk_012d4860c7da418a85ffea7406e1292a
created: 1589851906270
description: ""
modified: 1592254074771
name: Global Security 1.2
parentId: null
scope: spec
type: Workspace

View File

@@ -2,14 +2,14 @@
import commander from 'commander';
import * as packageJson from '../package.json';
export type GlobalOptions<T> = {|
export type GlobalOptions = {
workingDir?: string,
...T,
|};
};
export function createCommand(exitOverride: boolean, cmd?: string) {
const command = new commander.Command(cmd).storeOptionsAsProperties(false);
// TODO: can probably remove this
if (exitOverride) {
return command.exitOverride();
}
@@ -33,3 +33,13 @@ export function getAllOptions<T>(cmd: Object): T {
return opts;
}
export function logErrorExit1(err: Error) {
console.error(err);
process.exit(1);
}
export async function exit(result: Promise<boolean>): Promise<void> {
return result.then(r => process.exit(r ? 0 : 1)).catch(logErrorExit1);
}

View File

@@ -1,3 +1,3 @@
// @flow
export { generate } from './src/generate';
export { generate, generateToFile } from './src/generate';
export { runTests, runTestsCli } from './src/run';