From e6fa5ead42b1ea3be283c6f2e743018e1c7f2e43 Mon Sep 17 00:00:00 2001 From: Steven Chim <655241+chimurai@users.noreply.github.com> Date: Mon, 15 Apr 2024 18:33:02 +0200 Subject: [PATCH] feat: custom checks (#403) --- README.md | 16 +++-- jest.config.js | 2 - src/__tests__/bin.spec.ts | 54 +++++++++++++++-- src/__tests__/requirements.spec.ts | 2 +- src/bin.ts | 59 +++++++++++++++---- src/index.ts | 2 +- src/requirements/custom.ts | 18 ++++++ .../software.ts} | 2 +- src/types.ts | 10 ++++ tests/custom-check-always_fail.mjs | 10 ++++ tests/custom-check-ssh.config.mjs | 27 +++++++++ 11 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 src/requirements/custom.ts rename src/{requirements.ts => requirements/software.ts} (96%) create mode 100644 tests/custom-check-always_fail.mjs create mode 100644 tests/custom-check-ssh.config.mjs diff --git a/README.md b/README.md index 46a692b..7cfd6b3 100644 --- a/README.md +++ b/README.md @@ -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', + } + } }; ``` @@ -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 +npx requirements --config ``` ## CLI options ```bash -$ npx requirements --help +npx requirements --help ``` -``` +```shell Options: --help, -h Show help [boolean] --version, -v Show version number [boolean] diff --git a/jest.config.js b/jest.config.js index a611bc8..6801e12 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,5 @@ export default { }, moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', - '#ansi-styles': 'ansi-styles/index.js', - '#supports-color': 'supports-color/index.js', }, }; diff --git a/src/__tests__/bin.spec.ts b/src/__tests__/bin.spec.ts index 5b2bddc..a6c599f 100644 --- a/src/__tests__/bin.spec.ts +++ b/src/__tests__/bin.spec.ts @@ -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(); }); @@ -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); + }); }); }); diff --git a/src/__tests__/requirements.spec.ts b/src/__tests__/requirements.spec.ts index 8a463cb..8628a0c 100644 --- a/src/__tests__/requirements.spec.ts +++ b/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()', () => { diff --git a/src/bin.ts b/src/bin.ts index e11d17a..59e36d7 100644 --- a/src/bin.ts +++ b/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'; @@ -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)`); } } diff --git a/src/index.ts b/src/index.ts index 31e5025..aae8d4a 100644 --- a/src/index.ts +++ b/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 }; diff --git a/src/requirements/custom.ts b/src/requirements/custom.ts new file mode 100644 index 0000000..4da6457 --- /dev/null +++ b/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; +} diff --git a/src/requirements.ts b/src/requirements/software.ts similarity index 96% rename from src/requirements.ts rename to src/requirements/software.ts index 01900d2..816c8b9 100644 --- a/src/requirements.ts +++ b/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 { const softwareList = normalizeConfig(software); diff --git a/src/types.ts b/src/types.ts index ec7514b..c353041 100644 --- a/src/types.ts +++ b/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; diff --git a/tests/custom-check-always_fail.mjs b/tests/custom-check-always_fail.mjs new file mode 100644 index 0000000..1bbdbcb --- /dev/null +++ b/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.'); + }, + }, + }, +}; diff --git a/tests/custom-check-ssh.config.mjs b/tests/custom-check-ssh.config.mjs new file mode 100644 index 0000000..8a2f420 --- /dev/null +++ b/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'); + } + }, + }, + }, +};