diff --git a/.mocharc.js b/.mocharc.js new file mode 100644 index 000000000000..25a7f85989cd --- /dev/null +++ b/.mocharc.js @@ -0,0 +1,16 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: loopback-next +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// Reuse the mocha config from `@loopback/cli` +const config = require('./packages/cli/.mocharc.js'); + +// Set max listeners to 16 for testing to avoid the following warning: +// (node:11220) MaxListenersExceededWarning: Possible EventEmitter +// memory leak detected. 11 SIGTERM listeners added to [process]. +// Use emitter.setMaxListeners() to increase limit +// It only happens when multiple app instances are started but not stopped +process.setMaxListeners(16); + +module.exports = config; diff --git a/.vscode/launch.json b/.vscode/launch.json index 9e7fd55cf293..af2aaa5af443 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,7 @@ "program": "${workspaceRoot}/packages/build/node_modules/mocha/bin/_mocha", "runtimeArgs": ["-r", "${workspaceRoot}/packages/build/node_modules/source-map-support/register"], "cwd": "${workspaceRoot}", + "autoAttachChildProcesses": true, "args": [ "--config", "${workspaceRoot}/packages/build/config/.mocharc.json", @@ -26,6 +27,7 @@ "program": "${workspaceRoot}/bin/mocha-current-file", "runtimeArgs": ["-r", "${workspaceRoot}/packages/build/node_modules/source-map-support/register"], "cwd": "${workspaceRoot}", + "autoAttachChildProcesses": true, "args": [ "--config", "${workspaceRoot}/packages/build/config/.mocharc.json", diff --git a/packages/build/bin/run-mocha.js b/packages/build/bin/run-mocha.js index cc988314f905..c1e7304378fd 100755 --- a/packages/build/bin/run-mocha.js +++ b/packages/build/bin/run-mocha.js @@ -15,6 +15,9 @@ Usage: 'use strict'; +const path = require('path'); +const fs = require('fs-extra'); + function run(argv, options) { const utils = require('./utils'); @@ -24,7 +27,6 @@ function run(argv, options) { !utils.isOptionSet( mochaOpts, '--config', // mocha 6.x - '--opts', // legacy '--package', // mocha 6.x '--no-config', // mocha 6.x ) && !utils.mochaConfiguredForProject(); @@ -59,6 +61,17 @@ function run(argv, options) { mochaOpts.splice(lang, 2); } + // Set `--parallel` for `@loopback/*` packages + if (!mochaOpts.includes('--parallel')) { + const pkgFile = path.join(utils.getPackageDir(), 'package.json'); + if (fs.existsSync(pkgFile)) { + const pkg = fs.readJsonSync(pkgFile); + if (pkg.name.startsWith('@loopback/')) { + mochaOpts.push('--parallel'); + } + } + } + const args = [...mochaOpts]; return utils.runCLI('mocha/bin/mocha', args, options); diff --git a/packages/build/bin/utils.js b/packages/build/bin/utils.js index dd39a93aabd2..14d81ea3699c 100644 --- a/packages/build/bin/utils.js +++ b/packages/build/bin/utils.js @@ -228,7 +228,6 @@ function mochaConfiguredForProject() { '.mocharc.json', '.mocharc.yaml', '.mocharc.yml', - 'test/mocha.opts', ]; return configFiles.some(f => { const configFile = path.join(getPackageDir(), f); diff --git a/packages/build/config/.mocharc.json b/packages/build/config/.mocharc.json index 1be483a88471..f310739c9b10 100644 --- a/packages/build/config/.mocharc.json +++ b/packages/build/config/.mocharc.json @@ -1,5 +1,5 @@ { - "require": "source-map-support/register", + "require": ["source-map-support/register"], "recursive": true, "exit": true, "reporter": "dot" diff --git a/packages/build/package-lock.json b/packages/build/package-lock.json index d9fedb8ac05e..1a043e23ecae 100644 --- a/packages/build/package-lock.json +++ b/packages/build/package-lock.json @@ -234,36 +234,6 @@ "resolve-from": "^5.0.0" }, "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, "resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -464,9 +434,9 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, "chalk": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.0.0.tgz", - "integrity": "sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -866,9 +836,9 @@ } }, "fast-deep-equal": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", - "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-json-stable-stringify": { "version": "2.1.0", @@ -1719,36 +1689,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, "resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1927,38 +1867,6 @@ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "requires": { "find-up": "^4.0.0" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "requires": { - "p-limit": "^2.2.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - } } }, "prelude-ls": { diff --git a/packages/cli/.mocharc.js b/packages/cli/.mocharc.js new file mode 100644 index 000000000000..118bb3ae49c8 --- /dev/null +++ b/packages/cli/.mocharc.js @@ -0,0 +1,53 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// A workaround to use `--require ./test/snapshot-matcher.js` so that root +// hooks are executed by mocha parallel testing for each job + +const debug = require('debug')('loopback:cli:test'); + +/** + * Build mocha config for `@loopback/cli` + */ +function buildConfig() { + // Use the default config from `@loopback/build` + const mochaConfig = require('@loopback/build/config/.mocharc.json'); + debug('Default mocha config:', mochaConfig); + // Resolve `./test/snapshot-matcher.js` to get the absolute path + const mochaHooksFile = require.resolve('./test/snapshot-matcher.js'); + debug('Root hooks for --require %s', mochaHooksFile); + const config = {...mochaConfig, timeout: 5000}; + + // Allow env var `MOCHA_JOBS` to override parallel testing parameters + const jobs = +process.env.MOCHA_JOBS; + if (jobs === 0) { + // Disable parallel testing + config.parallel = false; + } else if (jobs > 0) { + // Override the default number of concurrent jobs + config.parallel = true; + config.jobs = jobs; + } + addRequire(config, mochaHooksFile); + debug('Final mocha config:', config); + return config; +} + +/** + * Add a new entry to the mocha config.require + * @param {object} config - Mocha config + * @param {string} mochaHooksFile - A module to be loaded by mocha + */ +function addRequire(config, mochaHooksFile) { + if (typeof config.require === 'string') { + config.require = [config.require, mochaHooksFile]; + } else if (Array.isArray(config.require)) { + config.require = config.require.concat(mochaHooksFile); + } else { + config.require = mochaHooksFile; + } +} + +module.exports = buildConfig(); diff --git a/packages/cli/test/snapshot-matcher.js b/packages/cli/test/snapshot-matcher.js index 17b841d3f6a5..b5c44cd6bff5 100644 --- a/packages/cli/test/snapshot-matcher.js +++ b/packages/cli/test/snapshot-matcher.js @@ -17,11 +17,64 @@ move this file to a standalone package so that all Mocha users can use it. const chalk = require('chalk'); const assert = require('assert'); const path = require('path'); +const debug = require('debug')('loopback:cli:test'); + +// Cached states in the process for snapshots +// key: snapshot director +// value: state +const states = new Map(); + +const mochaHooks = { + // Register root hooks for mocha + beforeEach: function injectCurrentTest() { + const currentTest = this.currentTest; + debug( + '[%d] Injecting current test %s', + process.pid, + getFullTestName(currentTest), + ); + // This global hook is called per test + for (const state of states.values()) { + state.currentTest = currentTest; + state.currentTest.__snapshotCounter = 1; + } + }, + + // This global hook is called after mocha is finished + afterAll: async function updateSnapshots() { + for (const state of states.values()) { + const tasks = Object.entries(state.snapshots).map(([f, data]) => { + const snapshotFile = buildSnapshotFilePath(state.snapshotDir, f); + return writeSnapshotData(snapshotFile, data); + }); + await Promise.all(tasks); + } + }, + + beforeAll: () => { + debug( + '[%d] Resetting states for snapshots', + process.pid, + Array.from(states.keys()), + ); + for (const state of states.values()) { + resetState(state); + } + }, +}; module.exports = { initializeSnapshots, + mochaHooks, }; +function resetState(state) { + state.currentTest = undefined; + state.snapshotErrors = false; + state.snapshots = Object.create(null); + return state; +} + /** * Create a function to match the given value against a pre-recorder snapshot. * @@ -43,19 +96,16 @@ module.exports = { * ``` */ function initializeSnapshots(snapshotDir) { - let currentTest; - let snapshotErrors = false; - - /** @this {Mocha.Context} */ - function setupSnapshots() { - currentTest = this.currentTest; - currentTest.__snapshotCounter = 1; + debug('[%d] Initializing snapshots for %s', process.pid, snapshotDir); + let state = states.get(snapshotDir); + if (state == null) { + state = resetState({snapshotDir}); + states.set(snapshotDir, state); } - beforeEach(setupSnapshots); if (!process.env.UPDATE_SNAPSHOTS) { process.on('exit', function printSnapshotHelp() { - if (!snapshotErrors) return; + if (!state.snapshotErrors) return; console.log( chalk.red(` Some of the snapshot-based tests have failed. Please carefully inspect @@ -67,38 +117,32 @@ variable to update snapshots. }); return function expectToMatchSnapshot(actual) { try { - matchSnapshot(snapshotDir, currentTest, actual); + matchSnapshot(state, actual); } catch (err) { - snapshotErrors = true; + state.snapshotErrors = true; throw err; } }; } - const snapshots = Object.create(null); - after(async function updateSnapshots() { - const tasks = Object.entries(snapshots).map(([f, data]) => { - const snapshotFile = buildSnapshotFilePath(snapshotDir, f); - return writeSnapshotData(snapshotFile, data); - }); - await Promise.all(tasks); - }); - return function expectToRecordSnapshot(actual) { - recordSnapshot(snapshots, currentTest, actual); + recordSnapshot(state, actual); }; } -function matchSnapshot(snapshotDir, currentTest, actualValue) { +function matchSnapshot(state, actualValue) { assert( typeof actualValue === 'string', 'Snapshot matcher supports string values only, but was called with ' + typeof actualValue, ); - const snapshotFile = buildSnapshotFilePath(snapshotDir, currentTest.file); + const snapshotFile = buildSnapshotFilePath( + state.snapshotDir, + state.currentTest.file, + ); const snapshotData = loadSnapshotData(snapshotFile); - const key = buildSnapshotKey(currentTest); + const key = buildSnapshotKey(state.currentTest); if (!(key in snapshotData)) { throw new Error( @@ -120,17 +164,19 @@ function matchSnapshot(snapshotDir, currentTest, actualValue) { ); } -function recordSnapshot(snapshots, currentTest, actualValue) { +function recordSnapshot(state, actualValue) { assert( typeof actualValue === 'string', 'Snapshot matcher supports string values only, but was called with ' + typeof actualValue, ); - const key = buildSnapshotKey(currentTest); - const testFile = currentTest.file; - if (!snapshots[testFile]) snapshots[testFile] = Object.create(null); - snapshots[testFile][key] = actualValue; + const key = buildSnapshotKey(state.currentTest); + const testFile = state.currentTest.file; + if (!state.snapshots[testFile]) { + state.snapshots[testFile] = Object.create(null); + } + state.snapshots[testFile][key] = actualValue; } function buildSnapshotKey(currentTest) {