packages/fb-babel-plugin-utils/TestUtil.js (189 lines of code) (raw):
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails oncall+i18n_fbt_js
* @noflow
*/
/*eslint max-len: ["error", 100]*/
'use strict';
/*global expect, it, describe*/
/*::
type TestEntry = {
// Test case filter:
// If `focus` is set, only run tests where `filter='focus'`
// If `skip` is set, this test entry won't be executed
filter?: 'focus' | 'skip',
input: string, // Input JS code to test
options?: {...}, // Babel transform options
output: string, // expected output code
// Set to `true` if an error is expected.
// You can also set an expected error string.
throws?: string | boolean,
};
type TestData = {[testTitle: string]: TestEntry};
*/
const babel = require('@babel/core');
const generate = require('@babel/generator').default;
const babelParser = require('@babel/parser');
const assert = require('assert');
const IGNORE_KEYS = [
'__clone',
'start',
'end',
'raw',
'rawValue',
'loc',
'tokens',
'parenthesized',
'parenStart',
];
function stripMeta(node, options) {
let ignoreKeys;
if (options && options.comments) {
// keep comments
ignoreKeys = [...IGNORE_KEYS];
} else {
ignoreKeys = [...IGNORE_KEYS, 'leadingComments', 'trailingComments'];
}
ignoreKeys.forEach(key => delete node[key]);
for (const p in node) {
if (node[p] && typeof node[p] === 'object') {
stripMeta(node[p], options);
}
}
return node;
}
function getDefaultTransformForPlugins(plugins) {
return function transform(source) {
return babel.transformSync(source, {
plugins,
}).code;
};
}
function parse(code) {
if ((typeof code !== 'string' && typeof code !== 'object') || code == null) {
// eslint-disable-next-line fb-www/no-new-error
throw new Error(
`Code must be a string or AST object but got: ${typeof code}`,
);
}
return babelParser.parse(code, {
sourceType: 'module',
plugins: ['flow', 'jsx', 'nullishCoalescingOperator'],
});
}
/**
* Generate formatted JS source code from a given Babel AST object.
* Note: JS comments are preserved.
* See `__tests__/TestUtil-test.js` for example.
*
* @param {BabelNode} babelNode BabelNode object obtained after parsing JS code
* or from a Babel Transform.
* @return {string} JS source code
*/
function generateFormattedCodeFromAST(babelNode) {
return generate(babelNode, {comments: true}, '').code.trim();
}
function formatSourceCode(input) {
return generateFormattedCodeFromAST(parse(input));
}
function firstCommonSubstring(left, right) {
let i = 0;
for (i = 0; i < left.length && i < right.length; i++) {
if (left.charAt(i) !== right.charAt(i)) {
break;
}
}
return left.substr(0, i);
}
// New versions of Babel detect and store the trailing comma of function arguments
// in the Babel node structure. But many of our unit tests assume that
// the function trailing comma is not important.
// So let's remove these to facilitate AST comparisons
// We'll also need to use the same type of quotes for strings.
function normalizeSourceCode(sourceCode /*: string */) /*: string */ {
const ast = parse(sourceCode);
// Note: @babel/generator does not generate trailing commas by default
return generate(
ast,
{
comments: true,
quotes: 'single',
},
sourceCode,
).code.trim();
}
/**
* Given a test config's "filter" status, decides whether we should run it with
* jest's it/fit/xit function.
*/
function getJestUnitTestFunction(
testEntry /*: TestEntry */,
) /*: (title: string, callback: () => void) => void */ {
switch (testEntry.filter) {
case 'focus':
return it.only;
case 'skip':
return it.skip;
default:
return it;
}
}
module.exports = {
generateFormattedCodeFromAST,
/**
* This function allows you to use mutliline template strings in your test
* cases without worrying about non standard loc's. It does this by stripping
* leading whitespace so the contents lines up based on the first lines
* offset.
*/
stripCodeBlockWhitespace(code) {
// Find standard whitespace offset for block
const match = code.match(/(\n\s*)\S/);
const strippedCode =
match == null
? code
: // Strip from each line
code.replace(new RegExp(match[1], 'g'), '\n');
return strippedCode;
},
assertSourceAstEqual(expected, actual, options) {
const expectedTree = stripMeta(
parse(normalizeSourceCode(expected)).program,
options,
);
const actualTree = stripMeta(
parse(normalizeSourceCode(actual)).program,
options,
);
try {
assert.deepStrictEqual(actualTree, expectedTree);
} catch (e) {
const jsonDiff = require('json-diff');
const expectedFormattedCode = formatSourceCode(expected);
const actualFormattedCode = formatSourceCode(actual);
const commonStr = firstCommonSubstring(
expectedFormattedCode,
actualFormattedCode,
);
const excerptLength = 60;
const excerptDiffFromExpected = expectedFormattedCode.substr(
commonStr.length,
excerptLength,
);
const excerptDiffFromActual = actualFormattedCode.substr(
commonStr.length,
excerptLength,
);
const errMessage = `deepEqual node AST assert failed for the following code:
Expected output: <<<${expectedFormattedCode}>>>
Actual output: <<<${actualFormattedCode}>>>
First common string: <<<${commonStr}>>>
The first difference is (${excerptLength} chars max):
Expected : <<<${excerptDiffFromExpected}>>>
Actual : <<<${excerptDiffFromActual}>>>
AST diff:
====
${jsonDiff.diffString(expectedTree, actualTree)}
====
`;
console.error(errMessage);
const err = new Error(errMessage);
err.stack = e.stack;
throw err;
}
},
// Alias of `getJestUnitTestFunction`
$it: getJestUnitTestFunction,
testSection(
testData /*: TestData */,
transform /*: Function */, // Babel transform function
options /*: {
comments?: boolean, // if true, strip comments from Babel transform output
} */,
) /*: void */ {
Object.entries(testData).forEach(([title, testInfo]) => {
getJestUnitTestFunction(testInfo)(title, () => {
if (testInfo.throws === true) {
expect(() => transform(testInfo.input, testInfo.options)).toThrow();
} else if (typeof testInfo.throws === 'string') {
expect(() => transform(testInfo.input, testInfo.options)).toThrow(
testInfo.throws,
);
} else if (testInfo.throws === false) {
transform(testInfo.input, testInfo.options);
} else {
expect(() => {
const transformOutput = transform(testInfo.input, testInfo.options);
if (options && options.matchSnapshot) {
expect(transformOutput).toMatchSnapshot();
} else {
this.assertSourceAstEqual(
testInfo.output,
transformOutput,
options,
);
}
}).not.toThrow();
}
});
});
},
testCase(name, plugins, testData, options) {
describe(name, () =>
this.testSection(
testData,
getDefaultTransformForPlugins(plugins),
options,
),
);
},
__parse__FOR_UNIT_TESTS: parse,
};