Skip to content

Commit

Permalink
feat: custom checks (#403)
Browse files Browse the repository at this point in the history
  • Loading branch information
chimurai committed Apr 15, 2024
1 parent 8dafa4a commit e6fa5ea
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 27 deletions.
16 changes: 12 additions & 4 deletions README.md
Expand Up @@ -48,6 +48,14 @@ export default {
flag: '-v', // custom version flag
},
},
// custom functions to verify requirements which are not related to software versions
// see ./tests/custom-check-ssh.config.mjs for an example
custom: {
'Example title for custom requirements check', {
fn: () => { throw new Error('throw Error when requirement not met.') },
errorMessage: 'This error message is shown when the above function throws Error',
}
}
};
```

Expand All @@ -56,22 +64,22 @@ export default {
Run `requirements` command in the project root. By default it will try to find the `requirements.config.mjs` file.

```bash
$ npx requirements
npx requirements
```

Or use a custom path:

```bash
$ npx requirements --config <filepath>
npx requirements --config <filepath>
```

## CLI options

```bash
$ npx requirements --help
npx requirements --help
```

```
```shell
Options:
--help, -h Show help [boolean]
--version, -v Show version number [boolean]
Expand Down
2 changes: 0 additions & 2 deletions jest.config.js
Expand Up @@ -9,7 +9,5 @@ export default {
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'#ansi-styles': 'ansi-styles/index.js',
'#supports-color': 'supports-color/index.js',
},
};
54 changes: 48 additions & 6 deletions src/__tests__/bin.spec.ts
Expand Up @@ -5,10 +5,12 @@ import { exec } from '../bin';
describe('bin', () => {
let logSpy;
let debugSpy;
let errorSpy;

beforeEach(() => {
logSpy = jest.spyOn(console, 'log');
debugSpy = jest.spyOn(console, 'debug');
errorSpy = jest.spyOn(console, 'error');
jest.resetAllMocks();
});

Expand Down Expand Up @@ -36,13 +38,53 @@ describe('bin', () => {
expect(debugSpy).toHaveBeenCalled();
});

it('should scaffold with --init', async () => {
const filePath = './requirements.config.mjs';
describe('Configuration scaffold', () => {
it('should scaffold with --init', async () => {
const filePath = './requirements.config.mjs';

await exec({ init: true });
expect(fs.existsSync(filePath)).toBe(true);
await exec({ init: true });
expect(fs.existsSync(filePath)).toBe(true);

// clean up
fs.unlinkSync(filePath);
// clean up
fs.unlinkSync(filePath);
});
});

describe('Software Checks', () => {
it('should execute ok tests', async () => {
await exec({ config: './tests/ok_every.config.mjs' });
expect(logSpy).toHaveBeenNthCalledWith(1, '🔍 Checking software requirements...');
expect(logSpy).toHaveBeenNthCalledWith(3, '✅ All is well!');
});

it('should execute nok tests', async () => {
await expect(exec({ config: './tests/ok_some.config.mjs' })).rejects.toMatchInlineSnapshot(
`[Error: ❌ Not all requirements are satisfied]`
);
});

it('should execute nok --force tests', async () => {
await exec({ config: './tests/ok_some.config.mjs', force: true });
expect(logSpy).toHaveBeenNthCalledWith(3, '⚠️ Not all requirements are satisfied (--force)');
});

it('should print debug data with --debug', async () => {
await exec({ config: './tests/ok_every.config.mjs', debug: true });
expect(debugSpy).toHaveBeenCalled();
});
});

describe('Custom Checks', () => {
it('should execute nok tests', async () => {
await expect(
exec({ config: './tests/custom-check-always_fail.mjs' })
).rejects.toMatchInlineSnapshot(`[Error: ❌ Not all custom requirements are satisfied]`);
});

it('should not reject with --force', async () => {
await expect(
exec({ config: './tests/custom-check-always_fail.mjs', force: true })
).resolves.toBe(undefined);
});
});
});
2 changes: 1 addition & 1 deletion src/__tests__/requirements.spec.ts
@@ -1,5 +1,5 @@
import type { SoftwareConfiguration } from '../types';
import { normalizeConfig, checkSoftware } from '../requirements.js';
import { normalizeConfig, checkSoftware } from '../requirements/software.js';

describe('requirements', () => {
describe('checkSoftware()', () => {
Expand Down
59 changes: 47 additions & 12 deletions src/bin.ts
@@ -1,7 +1,8 @@
import yargs from 'yargs';
import path from 'path';
import chalk from 'chalk';
import { checkSoftware } from './requirements.js';
import { checkSoftware } from './requirements/software.js';
import { checkCustom } from './requirements/custom.js';
import { renderTable, renderMessages } from './reporter.js';
import type { Configuration } from './types';
import { scaffold } from './scaffold.js';
Expand All @@ -18,38 +19,72 @@ export async function exec(_debug_argv_?) {
return;
}

const { custom, software } = await getConfiguration(argv);

/**
* Check custom requirements
*/
if (custom) {
if (!argv.quiet) {
console.log(`🔍 Checking custom requirements... \n`);
}

let customCheckResults = await checkCustom(custom);

const failedCustomChecks = customCheckResults.filter((item) => !item.passed);

if (argv.debug) {
console.debug('👀 RAW custom check data:\n', customCheckResults);
}

failedCustomChecks.forEach(({ name, errorMessage }) => {
const ANSI_ORANGE = 215;
console.log(`${chalk.ansi256(ANSI_ORANGE)(`${name}`)}:\n${errorMessage}\n`);
});

if (!failedCustomChecks.length && !argv.quiet) {
console.log(`✅ All custom checks are well!\n\n`);
}

if (failedCustomChecks.length && !argv.force) {
throw new Error(`❌ Not all custom requirements are satisfied`);
}
}

/**
* Check software requirements
*/
if (!argv.quiet) {
console.log(`🔍 Checking software requirements...`);
}

const config = await getConfiguration(argv);
let rawResults = await checkSoftware(config.software);
let softwareResults = await checkSoftware(software);

const ALL_OK = isAllOK(rawResults);
const ALL_SOFTWARE_OK = isAllOK(softwareResults);

if (argv.debug) {
console.debug('👀 RAW data:\n', rawResults);
console.debug('👀 RAW data:\n', softwareResults);
console.debug('👀 yargs:\n', argv);
}

if (!ALL_OK && !argv.force) {
const messages = getMessages(rawResults);
console.error(renderTable(rawResults));
if (!ALL_SOFTWARE_OK && !argv.force) {
const messages = getMessages(softwareResults);
console.error(renderTable(softwareResults));
console.log(renderMessages(messages));
throw new Error(`❌ Not all requirements are satisfied`);
}

if (argv.quiet && ALL_OK) {
if (argv.quiet && ALL_SOFTWARE_OK) {
// silent
} else {
console.log(renderTable(rawResults));
console.log(renderTable(softwareResults));
}

if (!argv.quiet && ALL_OK) {
if (!argv.quiet && ALL_SOFTWARE_OK) {
console.log(`✅ All is well!`);
}

if (argv.force && !ALL_OK) {
if (argv.force && !ALL_SOFTWARE_OK) {
console.log(`⚠️ Not all requirements are satisfied (--force)`);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
@@ -1,4 +1,4 @@
import { checkSoftware } from './requirements.js';
import { checkSoftware } from './requirements/software.js';
import { renderTable } from './reporter.js';

export { checkSoftware, renderTable };
18 changes: 18 additions & 0 deletions src/requirements/custom.ts
@@ -0,0 +1,18 @@
import { CustomChecks } from '../types';

export async function checkCustom(customChecks: CustomChecks = {}) {
let results = [];

for await (const [name, { fn, errorMessage }] of Object.entries(customChecks)) {
const item = { name, errorMessage };

try {
await fn();
results.push({ ...item, passed: true });
} catch (error) {
results.push({ ...item, passed: false, error });
}
}

return results;
}
2 changes: 1 addition & 1 deletion src/requirements.ts → src/requirements/software.ts
@@ -1,6 +1,6 @@
import binVersion from 'bin-version';
import semver from 'semver';
import type { RawResult, SoftwareConfiguration } from './types';
import type { RawResult, SoftwareConfiguration } from '../types';

export async function checkSoftware(software: SoftwareConfiguration = {}): Promise<RawResult[]> {
const softwareList = normalizeConfig(software);
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
@@ -1,11 +1,21 @@
export interface Configuration {
custom?: CustomChecks;
software: SoftwareConfiguration;
}

export interface CustomChecks {
[bin: string]: CustomCheckValue;
}

export interface SoftwareConfiguration {
[bin: string]: ConfigurationValue;
}

export interface CustomCheckValue {
fn: any;
errorMessage: string;
}

export type ConfigurationValue = ConfigurationStringValue | ConfigurationObjectValue;

export type ConfigurationStringValue = string;
Expand Down
10 changes: 10 additions & 0 deletions tests/custom-check-always_fail.mjs
@@ -0,0 +1,10 @@
export default {
custom: {
'Title Always Failure': {
errorMessage: `Error message when failure occurs.`,
fn: async () => {
throw new Error('This example always fails.');
},
},
},
};
27 changes: 27 additions & 0 deletions tests/custom-check-ssh.config.mjs
@@ -0,0 +1,27 @@
import chalk from 'chalk';
import fs from 'fs';
import { promisify } from 'util';
const readFile = promisify(fs.readFile);

export default {
software: {
npm: {
semver: '>= 16.x',
updateMessage: `Outdated 'npm' found.\nRun '${chalk.cyan('npm i -g npm')}' to update.`,
},
},
custom: {
'Git SSH Check': {
errorMessage: `HTTPS usage in Git is not allowed.\nFollow '${chalk.cyan(
'https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/connecting-to-github-with-ssh'
)}' to fix this.`,
fn: async () => {
const file = await readFile('./.git/config', 'utf-8');
const reAllowedProtocols = /(ssh:|git@)/;
if (reAllowedProtocols.test(file) === false) {
throw new Error('.git/config: ssh or git protocol not found');
}
},
},
},
};

0 comments on commit e6fa5ea

Please sign in to comment.