Skip to content

Commit

Permalink
feat: bundle Jest's modules with webpack [skip ci]
Browse files Browse the repository at this point in the history
  • Loading branch information
SimenB committed Apr 27, 2022
1 parent c6902a0 commit 633e8e2
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 132 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@typescript-eslint/parser": "^5.14.0",
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"babel-loader": "^8.2.3",
"camelcase": "^6.2.0",
"chalk": "^4.0.0",
"chokidar": "^3.3.0",
Expand Down Expand Up @@ -82,6 +83,8 @@
"ts-node": "^10.5.0",
"type-fest": "^2.11.2",
"typescript": "^4.2.4",
"webpack": "^5.68.0",
"webpack-node-externals": "^3.0.0",
"which": "^2.0.1"
},
"scripts": {
Expand Down Expand Up @@ -168,7 +171,8 @@
"babel-jest": "workspace:*",
"jest": "workspace:*",
"jest-environment-node": "workspace:*",
"react-native": "patch:react-native@npm:0.68.0#.yarn/patches/react-native-npm-0.68.0-9eb3ecb60a.patch"
"react-native": "patch:react-native@npm:0.68.0#.yarn/patches/react-native-npm-0.68.0-9eb3ecb60a.patch",
"terser-webpack-plugin/jest-worker": "27.5.0"
},
"packageManager": "[email protected]"
}
4 changes: 3 additions & 1 deletion packages/jest-util/src/requireOrImportModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export default async function requireOrImportModule<T>(
const moduleUrl = pathToFileURL(filePath);

// node `import()` supports URL, but TypeScript doesn't know that
const importedModule = await import(moduleUrl.href);
const importedModule = await import(
/* webpackIgnore: true */ moduleUrl.href
);

if (!applyInteropRequireDefault) {
return importedModule;
Expand Down
162 changes: 35 additions & 127 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,149 +5,57 @@
* LICENSE file in the root directory of this source tree.
*/

/**
* script to build (transpile) files.
* By default it transpiles js files for all packages and writes them
* into `build/` directory.
* Non-js files not matching IGNORE_PATTERN will be copied without transpiling.
*
* Example:
* node ./scripts/build.js
* node ./scripts/build.js /users/123/jest/packages/jest-111/src/111.js
*
* NOTE: this script is node@6 compatible
*/

'use strict';

const assert = require('assert');
const fs = require('fs');
const path = require('path');
const babel = require('@babel/core');
const util = require('util');
const chalk = require('chalk');
const glob = require('glob');
const micromatch = require('micromatch');
const prettier = require('prettier');
const transformOptions = require('../babel.config.js');
const {getPackages, adjustToTerminalWidth, OK} = require('./buildUtils');

const SRC_DIR = 'src';
const BUILD_DIR = 'build';
const JS_FILES_PATTERN = '**/*.js';
const TS_FILES_PATTERN = '**/*.ts';
const IGNORE_PATTERN = '**/__{tests,mocks}__/**';
const PACKAGES_DIR = path.resolve(__dirname, '../packages');

const INLINE_REQUIRE_EXCLUDE_LIST =
/packages\/expect|(jest-(circus|diff|get-type|jasmine2|matcher-utils|message-util|regex-util|snapshot))|pretty-format\//;

const prettierConfig = prettier.resolveConfig.sync(__filename);
prettierConfig.trailingComma = 'none';
prettierConfig.parser = 'babel';

function getPackageName(file) {
return path.relative(PACKAGES_DIR, file).split(path.sep)[0];
}

function getBuildPath(file, buildFolder) {
const pkgName = getPackageName(file);
const pkgSrcPath = path.resolve(PACKAGES_DIR, pkgName, SRC_DIR);
const pkgBuildPath = path.resolve(PACKAGES_DIR, pkgName, buildFolder);
const relativeToSrcPath = path.relative(pkgSrcPath, file);
return path.resolve(pkgBuildPath, relativeToSrcPath).replace(/\.ts$/, '.js');
}
const webpack = require('webpack');
const {createWebpackConfigs, OK, ERROR} = require('./buildUtils');

function buildNodePackage({packageDir, pkg}) {
const srcDir = path.resolve(packageDir, SRC_DIR);
const pattern = path.resolve(srcDir, '**/*');
const files = glob.sync(pattern, {nodir: true});
async function buildNodePackages() {
process.stdout.write(chalk.inverse(' Bundling packages \n'));

process.stdout.write(adjustToTerminalWidth(`${pkg.name}\n`));

files.forEach(file => buildFile(file, true));

assert.ok(
fs.existsSync(path.resolve(packageDir, pkg.main)),
`Main file "${pkg.main}" in ${pkg.name} should exist`,
const webpackConfigs = createWebpackConfigs();
const compiler = webpack(
webpackConfigs
.map(({webpackConfig}) => webpackConfig)
.filter(config => config != null),
);

process.stdout.write(`${OK}\n`);
}

function buildFile(file, silent) {
const destPath = getBuildPath(file, BUILD_DIR);

if (micromatch.isMatch(file, IGNORE_PATTERN)) {
silent ||
process.stdout.write(
`${
chalk.dim(' \u2022 ') + path.relative(PACKAGES_DIR, file)
} (ignore)\n`,
);
return;
}
let stats;
try {
stats = await util.promisify(compiler.run.bind(compiler))();
await util.promisify(compiler.close.bind(compiler))();

fs.mkdirSync(path.dirname(destPath), {recursive: true});
if (
!micromatch.isMatch(file, JS_FILES_PATTERN) &&
!micromatch.isMatch(file, TS_FILES_PATTERN)
) {
fs.createReadStream(file).pipe(fs.createWriteStream(destPath));
silent ||
process.stdout.write(
`${
chalk.red(' \u2022 ') +
path.relative(PACKAGES_DIR, file) +
chalk.red(' \u21D2 ') +
path.relative(PACKAGES_DIR, destPath)
} (copy)\n`,
);
} else {
const options = Object.assign({}, transformOptions);
options.plugins = options.plugins.slice();
assert.ok(!stats.hasErrors(), 'Must not have errors or warnings');
} catch (error) {
process.stdout.write(`${ERROR}\n\n`);

if (INLINE_REQUIRE_EXCLUDE_LIST.test(file)) {
// The excluded modules are injected into the user's sandbox
// We need to guard some globals there.
options.plugins.push(
require.resolve('./babel-plugin-jest-native-globals'),
);
} else {
options.plugins = options.plugins.map(plugin => {
if (
Array.isArray(plugin) &&
plugin[0] === '@babel/plugin-transform-modules-commonjs'
) {
return [plugin[0], Object.assign({}, plugin[1], {lazy: true})];
}
if (stats) {
const info = stats.toJson();

return plugin;
});
if (stats.hasErrors()) {
console.error('errors', info.errors);
}
}

const transformed = babel.transformFileSync(file, options).code;
const prettyCode = prettier.format(transformed, prettierConfig);

fs.writeFileSync(destPath, prettyCode);

silent ||
process.stdout.write(
`${
chalk.green(' \u2022 ') +
path.relative(PACKAGES_DIR, file) +
chalk.green(' \u21D2 ') +
path.relative(PACKAGES_DIR, destPath)
}\n`,
);
throw error;
}
}

const files = process.argv.slice(2);
webpackConfigs.forEach(({packageDir, pkg}) => {
assert.ok(
fs.existsSync(path.resolve(packageDir, pkg.main)),
`Main file "${pkg.main}" in "${pkg.name}" should exist`,
);
});

if (files.length) {
files.forEach(file => buildFile(file));
} else {
const packages = getPackages();
process.stdout.write(chalk.inverse(' Building packages \n'));
packages.forEach(buildNodePackage);
process.stdout.write(`${OK}\n`);
}

buildNodePackages().catch(error => {
console.error(error);
process.exitCode = 1;
});
118 changes: 116 additions & 2 deletions scripts/buildUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ const path = require('path');
const chalk = require('chalk');
const {sync: readPkg} = require('read-pkg');
const stringLength = require('string-length');
const nodeExternals = require('webpack-node-externals');
const rootPackage = require('../package.json');

const PACKAGES_DIR = path.resolve(__dirname, '../packages');

const OK = chalk.reset.inverse.bold.green(' DONE ');
const ERROR = chalk.reset.inverse.bold.red(' BOOM ');

// Get absolute paths of all directories under packages/*
module.exports.getPackages = function getPackages() {
function getPackages() {
const packages = fs
.readdirSync(PACKAGES_DIR)
.map(file => path.resolve(PACKAGES_DIR, file))
Expand Down Expand Up @@ -104,7 +106,9 @@ module.exports.getPackages = function getPackages() {

return {packageDir, pkg};
});
};
}

module.exports.getPackages = getPackages;

module.exports.adjustToTerminalWidth = function adjustToTerminalWidth(str) {
const columns = process.stdout.columns || 80;
Expand All @@ -117,5 +121,115 @@ module.exports.adjustToTerminalWidth = function adjustToTerminalWidth(str) {
return strs.slice(0, -1).concat(lastString).join('\n');
};

const INLINE_REQUIRE_EXCLUDE_LIST =
/packages\/expect|(jest-(circus|diff|get-type|jasmine2|matcher-utils|message-util|regex-util|snapshot))|pretty-format\//;

module.exports.OK = OK;
module.exports.ERROR = ERROR;
module.exports.PACKAGES_DIR = PACKAGES_DIR;

module.exports.INLINE_REQUIRE_EXCLUDE_LIST = INLINE_REQUIRE_EXCLUDE_LIST;

function createWebpackConfigs() {
const babelConfig = require('../babel.config.js');
const packages = getPackages();

return packages.map(({packageDir, pkg}) => {
const input = `${packageDir}/src/index.ts`;

if (!fs.existsSync(input)) {
return {packageDir, pkg};
}

const options = Object.assign({}, babelConfig);
options.plugins = options.plugins.slice();

if (INLINE_REQUIRE_EXCLUDE_LIST.test(input)) {
// The excluded modules are injected into the user's sandbox
// We need to guard some globals there.
options.plugins.push(
require.resolve('./babel-plugin-jest-native-globals'),
);
} else {
options.plugins = options.plugins.map(plugin => {
if (
Array.isArray(plugin) &&
plugin[0] === '@babel/plugin-transform-modules-commonjs'
) {
return [plugin[0], Object.assign({}, plugin[1], {lazy: true})];
}

return plugin;
});
}

return {
packageDir,
pkg,
webpackConfig: {
devtool: false,
entry: input,
externals: nodeExternals(),
mode: 'production',
module: {
rules: [
{
test: /.ts$/,
use: {
loader: 'babel-loader',
options,
},
},
],
},
output: {
filename: 'index.js',
library: {
type: 'commonjs2',
},
path: path.resolve(packageDir, 'build'),
},
plugins: [new IgnoreDynamicRequire()],
resolve: {
extensions: ['.ts', '.js'],
},
target: 'node',
},
};
});
}

// inspired by https://framagit.org/Glandos/webpack-ignore-dynamic-require
class IgnoreDynamicRequire {
apply(compiler) {
compiler.hooks.normalModuleFactory.tap('IgnoreDynamicRequire', factory => {
factory.hooks.parser
.for('javascript/auto')
.tap('IgnoreDynamicRequire', parser => {
// This is a SyncBailHook, so returning anything stops the parser, and nothing (undefined) allows to continue
function ignoreRequireCallExpression(expression) {
if (expression.arguments.length === 0) {
return undefined;
}
const arg = parser.evaluateExpression(expression.arguments[0]);
if (arg.isString() && !arg.string.startsWith('.')) {
return true;
}
if (!arg.isString() && !arg.isConditional()) {
return true;
}
return undefined;
}

parser.hooks.call
.for('require')
.tap('IgnoreDynamicRequire', ignoreRequireCallExpression);
parser.hooks.call
.for('require.resolve')
.tap('IgnoreDynamicRequire', ignoreRequireCallExpression);
});
});
}
}

module.exports.createWebpackConfigs = createWebpackConfigs;
Loading

0 comments on commit 633e8e2

Please sign in to comment.