443 lines
12 KiB
JavaScript
443 lines
12 KiB
JavaScript
/* eslint-env jest */
|
|
import * as babel from "@babel/core";
|
|
import { buildExternalHelpers } from "@babel/core";
|
|
import {
|
|
default as getFixtures,
|
|
resolveOptionPluginOrPreset,
|
|
} from "@babel/helper-fixtures";
|
|
import sourceMap from "source-map";
|
|
import { codeFrameColumns } from "@babel/code-frame";
|
|
import * as helpers from "./helpers";
|
|
import merge from "lodash/merge";
|
|
import assert from "assert";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import vm from "vm";
|
|
import checkDuplicatedNodes from "babel-check-duplicated-nodes";
|
|
import QuickLRU from "quick-lru";
|
|
import diff from "jest-diff";
|
|
import escapeRegExp from "./escape-regexp";
|
|
|
|
const cachedScripts = new QuickLRU({ maxSize: 10 });
|
|
const contextModuleCache = new WeakMap();
|
|
const sharedTestContext = createContext();
|
|
|
|
function createContext() {
|
|
const context = vm.createContext({
|
|
...helpers,
|
|
process: process,
|
|
transform: babel.transform,
|
|
setTimeout: setTimeout,
|
|
setImmediate: setImmediate,
|
|
expect,
|
|
});
|
|
context.global = context;
|
|
|
|
const moduleCache = Object.create(null);
|
|
contextModuleCache.set(context, moduleCache);
|
|
|
|
// Initialize the test context with the polyfill, and then freeze the global to prevent implicit
|
|
// global creation in tests, which could cause things to bleed between tests.
|
|
runModuleInTestContext(
|
|
"regenerator-runtime",
|
|
__filename,
|
|
context,
|
|
moduleCache,
|
|
);
|
|
|
|
// Populate the "babelHelpers" global with Babel's helper utilities.
|
|
runCacheableScriptInTestContext(
|
|
path.join(__dirname, "babel-helpers-in-memory.js"),
|
|
buildExternalHelpers,
|
|
context,
|
|
moduleCache,
|
|
);
|
|
|
|
return context;
|
|
}
|
|
|
|
function runCacheableScriptInTestContext(
|
|
filename: string,
|
|
srcFn: () => string,
|
|
context: Context,
|
|
moduleCache: Object,
|
|
) {
|
|
let cached = cachedScripts.get(filename);
|
|
if (!cached) {
|
|
const code = `(function (exports, require, module, __filename, __dirname) {\n${srcFn()}\n});`;
|
|
cached = {
|
|
code,
|
|
cachedData: undefined,
|
|
};
|
|
cachedScripts.set(filename, cached);
|
|
}
|
|
|
|
const script = new vm.Script(cached.code, {
|
|
filename,
|
|
displayErrors: true,
|
|
lineOffset: -1,
|
|
cachedData: cached.cachedData,
|
|
produceCachedData: true,
|
|
});
|
|
|
|
if (script.cachedDataProduced) {
|
|
cached.cachedData = script.cachedData;
|
|
}
|
|
|
|
const module = {
|
|
id: filename,
|
|
exports: {},
|
|
};
|
|
const req = id => runModuleInTestContext(id, filename, context, moduleCache);
|
|
const dirname = path.dirname(filename);
|
|
|
|
script
|
|
.runInContext(context)
|
|
.call(module.exports, module.exports, req, module, filename, dirname);
|
|
|
|
return module;
|
|
}
|
|
|
|
/**
|
|
* A basic implementation of CommonJS so we can execute `@babel/polyfill` inside our test context.
|
|
* This allows us to run our unittests
|
|
*/
|
|
function runModuleInTestContext(
|
|
id: string,
|
|
relativeFilename: string,
|
|
context: Context,
|
|
moduleCache: Object,
|
|
) {
|
|
const filename = require.resolve(id, {
|
|
paths: [path.dirname(relativeFilename)],
|
|
});
|
|
|
|
// Expose Node-internal modules if the tests want them. Note, this will not execute inside
|
|
// the context's global scope.
|
|
if (filename === id) return require(id);
|
|
|
|
// Modules can only evaluate once per context, so the moduleCache is a
|
|
// stronger cache guarantee than the LRU's Script cache.
|
|
if (moduleCache[filename]) return moduleCache[filename].exports;
|
|
|
|
const module = runCacheableScriptInTestContext(
|
|
filename,
|
|
() => fs.readFileSync(filename, "utf8"),
|
|
context,
|
|
moduleCache,
|
|
);
|
|
moduleCache[filename] = module;
|
|
|
|
return module.exports;
|
|
}
|
|
|
|
/**
|
|
* Run the given snippet of code inside a CommonJS module.
|
|
*
|
|
* Exposed for unit tests, not for use as an API.
|
|
*/
|
|
export function runCodeInTestContext(
|
|
code: string,
|
|
opts: { filename: string },
|
|
context = sharedTestContext,
|
|
) {
|
|
const filename = opts.filename;
|
|
const dirname = path.dirname(filename);
|
|
const moduleCache = contextModuleCache.get(context);
|
|
const req = id => runModuleInTestContext(id, filename, context, moduleCache);
|
|
|
|
const module = {
|
|
id: filename,
|
|
exports: {},
|
|
};
|
|
|
|
const oldCwd = process.cwd();
|
|
try {
|
|
if (opts.filename) process.chdir(path.dirname(opts.filename));
|
|
|
|
// Expose the test options as "opts", but otherwise run the test in a CommonJS-like environment.
|
|
// Note: This isn't doing .call(module.exports, ...) because some of our tests currently
|
|
// rely on 'this === global'.
|
|
const src = `(function(exports, require, module, __filename, __dirname, opts) {\n${code}\n});`;
|
|
return vm.runInContext(src, context, {
|
|
filename,
|
|
displayErrors: true,
|
|
lineOffset: -1,
|
|
})(module.exports, req, module, filename, dirname, opts);
|
|
} finally {
|
|
process.chdir(oldCwd);
|
|
}
|
|
}
|
|
|
|
function run(task) {
|
|
const {
|
|
actual,
|
|
expect: expected,
|
|
exec,
|
|
options: opts,
|
|
optionsDir,
|
|
validateLogs,
|
|
ignoreOutput,
|
|
stdout,
|
|
stderr,
|
|
} = task;
|
|
|
|
function getOpts(self) {
|
|
const newOpts = merge(
|
|
{
|
|
ast: true,
|
|
cwd: path.dirname(self.loc),
|
|
filename: self.loc,
|
|
filenameRelative: self.filename,
|
|
sourceFileName: self.filename,
|
|
sourceType: "script",
|
|
babelrc: false,
|
|
inputSourceMap: task.inputSourceMap || undefined,
|
|
},
|
|
opts,
|
|
);
|
|
|
|
return resolveOptionPluginOrPreset(newOpts, optionsDir);
|
|
}
|
|
|
|
let execCode = exec.code;
|
|
let result;
|
|
let resultExec;
|
|
|
|
if (execCode) {
|
|
const context = createContext();
|
|
const execOpts = getOpts(exec);
|
|
result = babel.transform(execCode, execOpts);
|
|
checkDuplicatedNodes(babel, result.ast);
|
|
execCode = result.code;
|
|
|
|
try {
|
|
resultExec = runCodeInTestContext(execCode, execOpts, context);
|
|
} catch (err) {
|
|
// Pass empty location to include the whole file in the output.
|
|
err.message =
|
|
`${exec.loc}: ${err.message}\n` + codeFrameColumns(execCode, {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const inputCode = actual.code;
|
|
const expectedCode = expected.code;
|
|
if (!execCode || inputCode) {
|
|
const actualLogs = { stdout: "", stderr: "" };
|
|
let restoreSpies = null;
|
|
if (validateLogs) {
|
|
const spy1 = jest.spyOn(console, "log").mockImplementation(msg => {
|
|
actualLogs.stdout += `${msg}\n`;
|
|
});
|
|
const spy2 = jest.spyOn(console, "warn").mockImplementation(msg => {
|
|
actualLogs.stderr += `${msg}\n`;
|
|
});
|
|
restoreSpies = () => {
|
|
spy1.mockRestore();
|
|
spy2.mockRestore();
|
|
};
|
|
}
|
|
|
|
result = babel.transform(inputCode, getOpts(actual));
|
|
|
|
if (restoreSpies) restoreSpies();
|
|
|
|
const outputCode = normalizeOutput(result.code);
|
|
|
|
checkDuplicatedNodes(babel, result.ast);
|
|
if (!ignoreOutput) {
|
|
if (
|
|
!expected.code &&
|
|
outputCode &&
|
|
!opts.throws &&
|
|
fs.statSync(path.dirname(expected.loc)).isDirectory() &&
|
|
!process.env.CI
|
|
) {
|
|
const expectedFile = expected.loc.replace(
|
|
/\.m?js$/,
|
|
result.sourceType === "module" ? ".mjs" : ".js",
|
|
);
|
|
|
|
console.log(`New test file created: ${expectedFile}`);
|
|
fs.writeFileSync(expectedFile, `${outputCode}\n`);
|
|
|
|
if (expected.loc !== expectedFile) {
|
|
try {
|
|
fs.unlinkSync(expected.loc);
|
|
} catch (e) {}
|
|
}
|
|
} else {
|
|
validateFile(outputCode, expected.loc, expectedCode);
|
|
|
|
if (inputCode) {
|
|
expect(expected.loc).toMatch(
|
|
result.sourceType === "module" ? /\.mjs$/ : /\.js$/,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (validateLogs) {
|
|
validateFile(normalizeOutput(actualLogs.stdout), stdout.loc, stdout.code);
|
|
validateFile(normalizeOutput(actualLogs.stderr), stderr.loc, stderr.code);
|
|
}
|
|
}
|
|
|
|
if (task.sourceMap) {
|
|
expect(result.map).toEqual(task.sourceMap);
|
|
}
|
|
|
|
if (task.sourceMappings) {
|
|
const consumer = new sourceMap.SourceMapConsumer(result.map);
|
|
|
|
task.sourceMappings.forEach(function (mapping) {
|
|
const actual = mapping.original;
|
|
|
|
const expected = consumer.originalPositionFor(mapping.generated);
|
|
expect({ line: expected.line, column: expected.column }).toEqual(actual);
|
|
});
|
|
}
|
|
|
|
if (execCode && resultExec) {
|
|
return resultExec;
|
|
}
|
|
}
|
|
|
|
function validateFile(actualCode, expectedLoc, expectedCode) {
|
|
try {
|
|
expect(actualCode).toEqualFile({
|
|
filename: expectedLoc,
|
|
code: expectedCode,
|
|
});
|
|
} catch (e) {
|
|
if (!process.env.OVERWRITE) throw e;
|
|
|
|
console.log(`Updated test file: ${expectedLoc}`);
|
|
fs.writeFileSync(expectedLoc, `${actualCode}\n`);
|
|
}
|
|
}
|
|
|
|
function normalizeOutput(code) {
|
|
const projectRoot = path.resolve(__dirname, "../../../");
|
|
const cwdSymbol = "<CWD>";
|
|
let result = code
|
|
.trim()
|
|
// (non-win32) /foo/babel/packages -> <CWD>/packages
|
|
// (win32) C:\foo\babel\packages -> <CWD>\packages
|
|
.replace(new RegExp(escapeRegExp(projectRoot), "g"), cwdSymbol);
|
|
if (process.platform === "win32") {
|
|
result = result
|
|
// C:/foo/babel/packages -> <CWD>/packages
|
|
.replace(
|
|
new RegExp(escapeRegExp(projectRoot.replace(/\\/g, "/")), "g"),
|
|
cwdSymbol,
|
|
)
|
|
// C:\\foo\\babel\\packages -> <CWD>\\packages (in js string literal)
|
|
.replace(
|
|
new RegExp(escapeRegExp(projectRoot.replace(/\\/g, "\\\\")), "g"),
|
|
cwdSymbol,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const toEqualFile = () => ({
|
|
compare: (actual, { filename, code }) => {
|
|
const pass = actual === code;
|
|
return {
|
|
pass,
|
|
message: () => {
|
|
const diffString = diff(code, actual, {
|
|
expand: false,
|
|
});
|
|
return (
|
|
`Expected ${filename} to match transform output.\n` +
|
|
`To autogenerate a passing version of this file, delete the file and re-run the tests.\n\n` +
|
|
`Diff:\n\n${diffString}`
|
|
);
|
|
},
|
|
};
|
|
},
|
|
negativeCompare: () => {
|
|
throw new Error("Negation unsupported");
|
|
},
|
|
});
|
|
|
|
export default function (
|
|
fixturesLoc: string,
|
|
name: string,
|
|
suiteOpts = {},
|
|
taskOpts = {},
|
|
dynamicOpts?: Function,
|
|
) {
|
|
const suites = getFixtures(fixturesLoc);
|
|
|
|
for (const testSuite of suites) {
|
|
if (suiteOpts.ignoreSuites?.includes(testSuite.title)) continue;
|
|
|
|
describe(name + "/" + testSuite.title, function () {
|
|
jest.addMatchers({
|
|
toEqualFile,
|
|
});
|
|
|
|
for (const task of testSuite.tests) {
|
|
if (
|
|
suiteOpts.ignoreTasks?.includes(task.title) ||
|
|
suiteOpts.ignoreTasks?.includes(testSuite.title + "/" + task.title)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const testFn = task.disabled ? it.skip : it;
|
|
|
|
testFn(
|
|
task.title,
|
|
|
|
function () {
|
|
function runTask() {
|
|
run(task);
|
|
}
|
|
|
|
if ("sourceMap" in task.options === false) {
|
|
task.options.sourceMap = !!(
|
|
task.sourceMappings || task.sourceMap
|
|
);
|
|
}
|
|
|
|
Object.assign(task.options, taskOpts);
|
|
|
|
if (dynamicOpts) dynamicOpts(task.options, task);
|
|
|
|
const throwMsg = task.options.throws;
|
|
if (throwMsg) {
|
|
// internal api doesn't have this option but it's best not to pollute
|
|
// the options object with useless options
|
|
delete task.options.throws;
|
|
|
|
assert.throws(runTask, function (err) {
|
|
assert.ok(
|
|
throwMsg === true || err.message.includes(throwMsg),
|
|
`
|
|
Expected Error: ${throwMsg}
|
|
Actual Error: ${err.message}`,
|
|
);
|
|
return true;
|
|
});
|
|
} else {
|
|
if (task.exec.code) {
|
|
const result = run(task);
|
|
if (result && typeof result.then === "function") {
|
|
return result;
|
|
}
|
|
} else {
|
|
runTask();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|