Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
@@ -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'),
);
2 changes: 2 additions & 0 deletions packages/build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ exports.typeScriptPath = path.resolve(
require.resolve('typescript/package.json'),
'..',
);

exports.mergeMochaConfigs = require('./src/merge-mocha-configs');
1 change: 1 addition & 0 deletions packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions packages/build/src/merge-mocha-configs.js
Original file line number Diff line number Diff line change
@@ -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];
}
}
}
29 changes: 29 additions & 0 deletions packages/cli/.mocharc.js
Original file line number Diff line number Diff line change
@@ -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;
144 changes: 104 additions & 40 deletions packages/cli/test/snapshot-matcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand All @@ -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 ' +
Expand All @@ -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;
Expand Down