From 8e63ea98b0c4cd5d5eb1dfef9005c66c4ccdbc80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 23 Jun 2020 17:02:37 +0200 Subject: [PATCH 1/2] feat(build): add a helper to merge mocha config objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miroslav Bajtoš --- packages/build/index.js | 2 ++ packages/build/package.json | 1 + packages/build/src/merge-mocha-configs.js | 39 +++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 packages/build/src/merge-mocha-configs.js diff --git a/packages/build/index.js b/packages/build/index.js index 2c5165b153d5..651ad2d82d4b 100644 --- a/packages/build/index.js +++ b/packages/build/index.js @@ -20,3 +20,5 @@ exports.typeScriptPath = path.resolve( require.resolve('typescript/package.json'), '..', ); + +exports.mergeMochaConfigs = require('./src/merge-mocha-configs'); diff --git a/packages/build/package.json b/packages/build/package.json index afe495fcb023..80b096162b17 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -26,6 +26,7 @@ "eslint": "^7.3.1", "fs-extra": "^9.0.1", "glob": "^7.1.6", + "lodash": "^4.17.15", "mocha": "^8.0.1", "nyc": "^15.1.0", "prettier": "^2.0.5", diff --git a/packages/build/src/merge-mocha-configs.js b/packages/build/src/merge-mocha-configs.js new file mode 100644 index 000000000000..0b73779c3f7b --- /dev/null +++ b/packages/build/src/merge-mocha-configs.js @@ -0,0 +1,39 @@ +// Copyright IBM Corp. 2017,2020. All Rights Reserved. +// Node module: @loopback/build +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const debug = require('debug')('loopback:build:merge-mocha-configs'); +const {assignWith} = require('lodash'); + +module.exports = mergeMochaConfigs; + +/** + * Merge multiple Mocha configuration files into a single one. + * + * @param {MochaConfig[]} configs A list of Mocha configuration objects + * as provided by `.mocharc.js` files. + */ +function mergeMochaConfigs(...configs) { + debug('Merging mocha configurations', ...configs); + const result = assignWith({}, ...configs, assignMochaConfigEntry); + debug('Merged config:', result); + return result; +} + +function assignMochaConfigEntry(targetValue, sourceValue, key) { + switch (key) { + case 'timeout': + return Math.max(targetValue || 0, sourceValue); + case 'require': + if (Array.isArray(sourceValue)) { + debug('Adding an array of files to require:', sourceValue); + return [...(targetValue || []), ...sourceValue]; + } else { + debug('Adding a single file to require:', sourceValue); + return [...(targetValue || []), sourceValue]; + } + } +} From e16d378b55ae84752237d45859d8b3e9afa287b5 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sat, 4 Apr 2020 18:28:59 -0700 Subject: [PATCH 2/2] feat(cli): improve snapshot matcher to be compatible with parallel testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - For each snapshotDir, share the same instance of snapshot matcher - Use Mocha root hooks to inject the current test, reset snapshot matchers and update snapshot files. - Add .mocharc.js for packages/cli to register root hooks Co-Authored-By: Miroslav Bajtoš --- .mocharc.js | 13 +++ packages/cli/.mocharc.js | 29 ++++++ packages/cli/test/snapshot-matcher.js | 144 +++++++++++++++++++------- 3 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 .mocharc.js create mode 100644 packages/cli/.mocharc.js diff --git a/.mocharc.js b/.mocharc.js new file mode 100644 index 000000000000..7dafe8f9ee1d --- /dev/null +++ b/.mocharc.js @@ -0,0 +1,13 @@ +// 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 + +const {mergeMochaConfigs} = require('./packages/build'); +const defaultConfig = require('./packages/build/config/.mocharc.json'); + +module.exports = mergeMochaConfigs( + defaultConfig, + // Apply Mocha config from packages that require custom Mocha setup + require('./packages/cli/.mocharc.js'), +); diff --git a/packages/cli/.mocharc.js b/packages/cli/.mocharc.js new file mode 100644 index 000000000000..74a3f5b47ad1 --- /dev/null +++ b/packages/cli/.mocharc.js @@ -0,0 +1,29 @@ +// 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'); +const {mergeMochaConfigs} = require('@loopback/build'); + +// Start with the default config from `@loopback/build` +const defaultConfig = require('@loopback/build/config/.mocharc.json'); +debug('Default mocha config:', defaultConfig); + +// 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); + +// Custom configuration for CLI tests +const CLI_MOCHA_CONFIG = { + timeout: 5000, + require: mochaHooksFile, +}; + +const config = mergeMochaConfigs(defaultConfig, CLI_MOCHA_CONFIG); +debug('Final mocha config:', config); + +module.exports = config; diff --git a/packages/cli/test/snapshot-matcher.js b/packages/cli/test/snapshot-matcher.js index 6351e20339fe..0c6fa93d11ec 100644 --- a/packages/cli/test/snapshot-matcher.js +++ b/packages/cli/test/snapshot-matcher.js @@ -16,15 +16,89 @@ move this file to a standalone package so that all Mocha users can use it. const chalk = require('chalk'); const assert = require('assert'); -const debug = require('debug')('test:snapshot-matcher'); const path = require('path'); +const debug = require('debug')('loopback:cli:test:snapshot-matcher'); const root = process.cwd(); +const shouldUpdateSnapshots = process.env.UPDATE_SNAPSHOTS; + +// Register root hooks for mocha +const mochaHooks = { + // This global hook is called once, before the test suite starts. + // When running tests in parallel, it is invoked once for each test file. + beforeAll: resetGlobalState, + + // This global hook is called per test + beforeEach: injectCurrentTest, + + // This global hook is called once, after mocha is finished + // When running tests in parallel, it is invoked once for each test file. + afterAll: updateSnapshotFiles, +}; module.exports = { initializeSnapshots, + mochaHooks, }; +// A lookup table for snapshot-matcher instance data (state) +// key: snapshot directory (snapshotDir) +// value: matcher state {snapshotDir, snapshots, snapshotErrors} +const snapshotMatchers = new Map(); + +// The currently running test (an instance of `Mocha.Test`) +let currentTest; + +/** @this {Mocha.Context} */ +function injectCurrentTest() { + currentTest = this.currentTest; + debug( + '[%d] Injecting current test %s', + process.pid, + getFullTestName(currentTest), + ); + currentTest.__snapshotCounter = 1; +} + +async function updateSnapshotFiles() { + if (!shouldUpdateSnapshots) return; + debug('[%d] Updating snapshots (writing to files)', process.pid); + for (const state of snapshotMatchers.values()) { + const tasks = Object.entries(state.snapshots).map(([f, data]) => { + const snapshotFile = buildSnapshotFilePath(state.snapshotDir, f); + return writeSnapshotData(snapshotFile, data); + }); + await Promise.all(tasks); + } +} + +function resetGlobalState() { + debug( + '[%d] Resetting snapshot matchers', + process.pid, + Array.from(snapshotMatchers.keys()), + ); + currentTest = undefined; + for (const matcher of snapshotMatchers.values()) { + resetMatcherState(matcher); + } +} + +function resetMatcherState(matcher) { + matcher.snapshotErrors = false; + matcher.snapshots = Object.create(null); + return matcher; +} + +function getOrCreateMatcherForDir(snapshotDir) { + let matcher = snapshotMatchers.get(snapshotDir); + if (matcher == null) { + matcher = resetMatcherState({snapshotDir}); + snapshotMatchers.set(snapshotDir, matcher); + } + return matcher; +} + /** * Create a function to match the given value against a pre-recorder snapshot. * @@ -56,25 +130,18 @@ function initializeSnapshots(snapshotDir) { .map(f => `\n${f}`) .join(); debug( - 'Initializing snapshot matcher, storing snapshots in %s%s', + '[%d] Initializing snapshot matcher, storing snapshots in %s%s', + process.pid, snapshotDir, stack, ); } - let currentTest; - let snapshotErrors = false; - - /** @this {Mocha.Context} */ - function setupSnapshots() { - currentTest = this.currentTest; - currentTest.__snapshotCounter = 1; - } - beforeEach(setupSnapshots); + const matcher = getOrCreateMatcherForDir(snapshotDir); - if (!process.env.UPDATE_SNAPSHOTS) { + if (!shouldUpdateSnapshots) { process.on('exit', function printSnapshotHelp() { - if (!snapshotErrors) return; + if (!matcher.snapshotErrors) return; console.log( chalk.red(` Some of the snapshot-based tests have failed. Please carefully inspect @@ -86,36 +153,30 @@ variable to update snapshots. }); return function expectToMatchSnapshot(actual) { try { - matchSnapshot(snapshotDir, currentTest, actual); + matchSnapshot(matcher, actual); } catch (err) { - snapshotErrors = true; + matcher.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(matcher, actual); }; } -function matchSnapshot(snapshotDir, currentTest, actualValue) { +function matchSnapshot(matcher, 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( + matcher.snapshotDir, + currentTest.file, + ); const snapshotData = loadSnapshotData(snapshotFile); const key = buildSnapshotKey(currentTest); @@ -141,7 +202,7 @@ function matchSnapshot(snapshotDir, currentTest, actualValue) { ); } -function recordSnapshot(snapshots, currentTest, actualValue) { +function recordSnapshot(matcher, actualValue) { assert( typeof actualValue === 'string', 'Snapshot matcher supports string values only, but was called with ' + @@ -157,23 +218,26 @@ function recordSnapshot(snapshots, currentTest, actualValue) { path.relative(root, testFile), ); } - if (!snapshots[testFile]) snapshots[testFile] = Object.create(null); - snapshots[testFile][key] = actualValue; + + if (!matcher.snapshots[testFile]) { + matcher.snapshots[testFile] = Object.create(null); + } + matcher.snapshots[testFile][key] = actualValue; } -function buildSnapshotKey(currentTest) { - const counter = currentTest.__snapshotCounter || 1; - currentTest.__snapshotCounter = counter + 1; - return `${getFullTestName(currentTest)} ${counter}`; +function buildSnapshotKey(test) { + const counter = test.__snapshotCounter || 1; + test.__snapshotCounter = counter + 1; + return `${getFullTestName(test)} ${counter}`; } -function getFullTestName(currentTest) { - let result = currentTest.title; +function getFullTestName(test) { + let result = test.title; for (;;) { - if (!currentTest.parent) break; - currentTest = currentTest.parent; - if (currentTest.title) { - result = currentTest.title + ' ' + result; + if (!test.parent) break; + test = test.parent; + if (test.title) { + result = test.title + ' ' + result; } } return result;