diff --git a/package.json b/package.json index c055d57..e02b0c9 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:unit:base": "DEBUG=any mocha 'src/**/*-test.js'", "lint:peer": "npm ls >/dev/null", "lint:gherkin": "gherkin-lint", - "test:integration": "preview && run-s 'test:integration:base -- --profile noWip'", + "pretest:integration:base": "preview", + "test:integration": "run-s 'test:integration:base -- --profile noWip'", "test:integration:base": "DEBUG=any cucumber-js test/integration --profile base", "test:integration:debug": "NODE_OPTIONS=--enable-source-maps DEBUG=test run-s test:integration", "test:integration:wip": "run-s 'test:integration:base -- --profile wip'", diff --git a/src/enhancers/apply-enhancers-test.js b/src/enhancers/apply-enhancers-test.js new file mode 100644 index 0000000..4ada5a8 --- /dev/null +++ b/src/enhancers/apply-enhancers-test.js @@ -0,0 +1,61 @@ +import {assert} from 'chai'; +import any from '@travi/any'; +import sinon from 'sinon'; +import applyEnhancers from './apply'; + +suite('enhancers', () => { + const results = any.simpleObject(); + const projectRoot = any.string(); + + test('that an enhancer that matches the project is executed', async () => { + const lift = sinon.stub(); + const anotherLift = sinon.stub(); + const test = sinon.stub(); + const otherLift = sinon.spy(); + const liftNextSteps = any.listOf(any.simpleObject); + const liftResults = {nextSteps: liftNextSteps}; + const anotherLiftResults = any.simpleObject(); + test.withArgs({projectRoot}).resolves(true); + lift.withArgs({results, projectRoot}).resolves(liftResults); + anotherLift.withArgs({results: {...results, ...liftResults}, projectRoot}).resolves(anotherLiftResults); + + const enhancerResults = await applyEnhancers({ + results, + projectRoot, + enhancers: { + [any.word()]: {test, lift}, + [any.word()]: {test: () => Promise.resolve(false), lift: otherLift}, + [any.word()]: {test, lift: anotherLift} + } + }); + + assert.deepEqual(enhancerResults, {...results, ...liftResults, ...anotherLiftResults}); + assert.calledWith(lift, {results, projectRoot}); + assert.notCalled(otherLift); + }); + + test('that an enhancer error rejects the enhancer application', async () => { + const error = new Error('from test'); + + try { + await applyEnhancers({ + results, + projectRoot, + enhancers: { + [any.word()]: { + test: () => Promise.resolve(true), + lift: () => Promise.reject(error) + } + } + }); + + throw new Error('applying enhancers should have thrown an error'); + } catch (e) { + assert.equal(e, error); + } + }); + + test('that no liftEnhancers are applied if none are provided', async () => { + assert.deepEqual(await applyEnhancers({results}), results); + }); +}); diff --git a/src/enhancers/apply.js b/src/enhancers/apply.js new file mode 100644 index 0000000..fe38d92 --- /dev/null +++ b/src/enhancers/apply.js @@ -0,0 +1,20 @@ +import deepmerge from 'deepmerge'; +import {info} from '@travi/cli-messages'; + +export default async function ({results, enhancers = {}, projectRoot}) { + info('Applying Enhancers'); + + return Object.values(enhancers) + .reduce(async (acc, enhancer) => { + if (await enhancer.test({projectRoot})) { + const previousResults = await acc; + + return deepmerge( + previousResults, + await enhancer.lift({results: previousResults, projectRoot}) + ); + } + + return acc; + }, results); +} diff --git a/src/enhancers/engines-test.js b/src/enhancers/engines-test.js new file mode 100644 index 0000000..8b7c1eb --- /dev/null +++ b/src/enhancers/engines-test.js @@ -0,0 +1,49 @@ +import {promises as fs} from 'fs'; + +import {assert} from 'chai'; +import any from '@travi/any'; +import sinon from 'sinon'; + +import {test as predicate, lift} from './engines'; + +suite('engines enhancer', () => { + let sandbox; + const projectRoot = any.string(); + + setup(() => { + sandbox = sinon.createSandbox(); + + sandbox.stub(fs, 'readFile'); + }); + + teardown(() => sandbox.restore()); + + test('that the predicate returns `true` when `engines.node` is defined', async () => { + fs.readFile.withArgs(`${projectRoot}/package.json`, 'utf8').resolves(JSON.stringify({engines: {node: any.word()}})); + + assert.isTrue(await predicate({projectRoot})); + }); + + test('that the predicate returns `false` when `engines.node` is not defined', async () => { + fs.readFile.resolves(JSON.stringify({engines: {}})); + + assert.isFalse(await predicate({projectRoot})); + }); + + test('that the predicate returns `false` when `engines` is not defined', async () => { + fs.readFile.resolves(JSON.stringify({})); + + assert.isFalse(await predicate({projectRoot})); + }); + + test('that the lifter returns the details for linting and communicating engines restrictions', async () => { + const projectName = any.word(); + fs.readFile.withArgs(`${projectRoot}/package.json`, 'utf8').resolves(JSON.stringify({name: projectName})); + + const {scripts, badges, devDependencies} = await lift({projectRoot}); + + assert.equal(scripts['lint:engines'], 'ls-engines'); + assert.deepEqual(devDependencies, ['ls-engines']); + assert.deepEqual(badges.consumer.node, {img: `https://img.shields.io/node/v/${projectName}.svg`, text: 'node'}); + }); +}); diff --git a/src/enhancers/engines.js b/src/enhancers/engines.js new file mode 100644 index 0000000..c48b1ad --- /dev/null +++ b/src/enhancers/engines.js @@ -0,0 +1,17 @@ +import {promises as fs} from 'fs'; + +export async function test({projectRoot}) { + const {engines} = JSON.parse(await fs.readFile(`${projectRoot}/package.json`, 'utf8')); + + return !!engines?.node; +} + +export async function lift({projectRoot}) { + const {name} = JSON.parse(await fs.readFile(`${projectRoot}/package.json`, 'utf8')); + + return { + devDependencies: ['ls-engines'], + scripts: {'lint:engines': 'ls-engines'}, + badges: {consumer: {node: {img: `https://img.shields.io/node/v/${name}.svg`, text: 'node'}}} + }; +} diff --git a/src/lift-test.js b/src/lift-test.js index 9588910..8204103 100644 --- a/src/lift-test.js +++ b/src/lift-test.js @@ -1,9 +1,13 @@ import * as huskyLifter from '@form8ion/husky'; import * as eslint from '@form8ion/eslint'; import deepmerge from 'deepmerge'; + import sinon from 'sinon'; import any from '@travi/any'; import {assert} from 'chai'; + +import * as enhancers from './enhancers/apply'; +import * as enginesEnhancer from './enhancers/engines'; import * as packageLifter from './package'; import * as packageManagerResolver from './package-manager'; import lift from './lift'; @@ -36,6 +40,7 @@ suite('lift', () => { sandbox.stub(eslint, 'lift'); sandbox.stub(packageManagerResolver, 'default'); sandbox.stub(huskyLifter, 'lift'); + sandbox.stub(enhancers, 'default'); huskyLifter.lift.withArgs({projectRoot, packageManager}).resolves(huskyLiftResults); packageManagerResolver.default.withArgs({projectRoot, packageManager: manager}).resolves(packageManager); @@ -46,17 +51,20 @@ suite('lift', () => { test('that results specific to js projects are lifted', async () => { const scope = any.word(); const eslintLiftResults = {...any.simpleObject(), devDependencies: any.listOf(any.word)}; + const enhancerResults = any.simpleObject(); eslint.lift.withArgs({configs: eslintConfigs, projectRoot}).resolves(eslintLiftResults); + enhancers.default.withArgs({results, enhancers: [enginesEnhancer], projectRoot}).resolves(enhancerResults); const liftResults = await lift({projectRoot, results, configs: {eslint: {scope}}}); - assert.deepEqual(liftResults, {}); + assert.deepEqual(liftResults, enhancerResults); assert.calledWith( packageLifter.default, deepmerge.all([ {projectRoot, scripts, tags, dependencies, devDependencies, packageManager}, huskyLiftResults, - eslintLiftResults + eslintLiftResults, + enhancerResults ]) ); }); diff --git a/src/lift.js b/src/lift.js index b59435a..aa3234b 100644 --- a/src/lift.js +++ b/src/lift.js @@ -1,34 +1,32 @@ -import {info} from '@travi/cli-messages'; import deepmerge from 'deepmerge'; +import {info} from '@travi/cli-messages'; import {lift as liftHusky} from '@form8ion/husky'; import {lift as liftEslint} from '@form8ion/eslint'; + +import applyEnhancers from './enhancers/apply'; +import * as enginesEnhancer from './enhancers/engines'; import liftPackage from './package'; import resolvePackageManager from './package-manager'; -export default async function ({ - projectRoot, - results: {scripts, tags, eslintConfigs, dependencies, devDependencies, packageManager: manager} -}) { +export default async function ({projectRoot, results}) { info('Lifting JavaScript-specific details'); + const {scripts, tags, eslintConfigs, dependencies, devDependencies, packageManager: manager} = results; + const packageManager = await resolvePackageManager({projectRoot, packageManager: manager}); + const huskyResults = await liftHusky({projectRoot, packageManager}); const eslintResults = await liftEslint({projectRoot, configs: eslintConfigs}); + const enhancerResults = await applyEnhancers({results, enhancers: [enginesEnhancer], projectRoot}); await liftPackage( deepmerge.all([ - { - projectRoot, - scripts, - tags, - dependencies, - devDependencies, - packageManager - }, + {projectRoot, scripts, tags, dependencies, devDependencies, packageManager}, + enhancerResults, huskyResults, eslintResults ]) ); - return {}; + return enhancerResults; } diff --git a/src/package.js b/src/package.js index 5afd1a9..ca96c06 100644 --- a/src/package.js +++ b/src/package.js @@ -36,10 +36,5 @@ export default async function ({ info('Installing dependencies'); await installDependencies(dependencies || [], PROD_DEPENDENCY_TYPE, projectRoot, packageManager); - await installDependencies( - [...devDependencies || []], - DEV_DEPENDENCY_TYPE, - projectRoot, - packageManager - ); + await installDependencies([...devDependencies || []], DEV_DEPENDENCY_TYPE, projectRoot, packageManager); } diff --git a/test/integration/features/engines.feature b/test/integration/features/engines.feature new file mode 100644 index 0000000..83e37c5 --- /dev/null +++ b/test/integration/features/engines.feature @@ -0,0 +1,10 @@ +Feature: Engines + + Scenario: Engines defined for node + Given a definition exists for engines.node + And an "npm" lockfile exists + And husky v5 is installed + When the scaffolder results are processed + Then the script is added for ensuring the node engines requirement is met + And ls-engines is added as a dependency + And the engines badge is added to the consumer group diff --git a/test/integration/features/step_definitions/common-steps.js b/test/integration/features/step_definitions/common-steps.js index 0ea14a3..8290089 100644 --- a/test/integration/features/step_definitions/common-steps.js +++ b/test/integration/features/step_definitions/common-steps.js @@ -35,8 +35,10 @@ When('the scaffolder results are processed', async function () { await fs.writeFile( `${process.cwd()}/package.json`, JSON.stringify({ + name: this.projectName, scripts: this.existingScripts, - keywords: this.existingKeywords + keywords: this.existingKeywords, + ...this.enginesNode && {engines: {node: this.enginesNode}} }) ); diff --git a/test/integration/features/step_definitions/dependencies-steps.js b/test/integration/features/step_definitions/dependencies-steps.js new file mode 100644 index 0000000..e805d42 --- /dev/null +++ b/test/integration/features/step_definitions/dependencies-steps.js @@ -0,0 +1,21 @@ +import {Then} from '@cucumber/cucumber'; +import td from 'testdouble'; + +function escapeSpecialCharacters(string) { + return string.replace(/[.*+?^$\-{}()|[\]\\]/g, '\\$&'); +} + +export function assertDevDependencyIsInstalled(execa, dependencyName) { + const {DEV_DEPENDENCY_TYPE} = require('@form8ion/javascript-core'); + + td.verify( + execa(td.matchers.contains( + new RegExp(`(npm install|yarn add).*${escapeSpecialCharacters(dependencyName)}.*${DEV_DEPENDENCY_TYPE}`) + )), + {ignoreExtraArgs: true} + ); +} + +Then('ls-engines is added as a dependency', async function () { + assertDevDependencyIsInstalled(this.execa, 'ls-engines'); +}); diff --git a/test/integration/features/step_definitions/engines-steps.js b/test/integration/features/step_definitions/engines-steps.js new file mode 100644 index 0000000..a156cf0 --- /dev/null +++ b/test/integration/features/step_definitions/engines-steps.js @@ -0,0 +1,19 @@ +import {Given, Then} from '@cucumber/cucumber'; +import any from '@travi/any'; +import {assert} from 'chai'; + +Given('a definition exists for engines.node', async function () { + this.enginesNode = any.word(); +}); + +Then('the engines badge is added to the consumer group', async function () { + const {badges} = this.results; + + assert.deepEqual( + badges.consumer.node, + { + img: `https://img.shields.io/node/v/${this.projectName}.svg`, + text: 'node' + } + ); +}); diff --git a/test/integration/features/step_definitions/eslint-config-steps.js b/test/integration/features/step_definitions/eslint-config-steps.js index 0cffd83..bf11822 100644 --- a/test/integration/features/step_definitions/eslint-config-steps.js +++ b/test/integration/features/step_definitions/eslint-config-steps.js @@ -4,7 +4,8 @@ import {Given, Then} from '@cucumber/cucumber'; import {assert} from 'chai'; import any from '@travi/any'; import {fileExists} from '@form8ion/core'; -import td from 'testdouble'; + +import {assertDevDependencyIsInstalled} from './dependencies-steps'; const pathToYamlConfig = `${process.cwd()}/.eslintrc.yml`; const eslintConfigScope = `@${any.word()}`; @@ -58,8 +59,5 @@ Then('dependencies are defined for the additional configs', async function () { return `${this.eslintConfigScope}/eslint-config-${config.name}`; }); - td.verify( - this.execa(td.matchers.contains(new RegExp(`(npm install|yarn add).*${additionalConfigPackageNames.join(' ')}`))), - {ignoreExtraArgs: true} - ); + assertDevDependencyIsInstalled(this.execa, additionalConfigPackageNames.join(' ')); }); diff --git a/test/integration/features/step_definitions/package-manager-steps.js b/test/integration/features/step_definitions/package-manager-steps.js index 1eb2530..b80884b 100644 --- a/test/integration/features/step_definitions/package-manager-steps.js +++ b/test/integration/features/step_definitions/package-manager-steps.js @@ -19,6 +19,5 @@ Given('an {string} lockfile exists', async function (packageManager) { } this.packageManager = packageManager; + this.projectName = any.word(); }); - -// TODO: move the td.verify for package installation here diff --git a/test/integration/features/step_definitions/scripts-steps.js b/test/integration/features/step_definitions/scripts-steps.js index 2edf6a6..c31966f 100644 --- a/test/integration/features/step_definitions/scripts-steps.js +++ b/test/integration/features/step_definitions/scripts-steps.js @@ -32,3 +32,9 @@ Then('the additional scripts exist', async function () { Object.entries(this.scriptsResults).forEach(([scriptName, script]) => assert.equal(scripts[scriptName], script)); }); + +Then('the script is added for ensuring the node engines requirement is met', async function () { + const {scripts} = JSON.parse(await fs.readFile(`${process.cwd()}/package.json`, 'utf8')); + + assert.equal(scripts['lint:engines'], 'ls-engines'); +});