webpack/test/runner/index.js

566 lines
14 KiB
JavaScript
Raw Normal View History

"use strict";
2025-06-20 22:08:04 +08:00
const fs = require("fs");
const { Module } = require("module");
2025-06-20 22:08:04 +08:00
const path = require("path");
2025-07-08 20:52:43 +08:00
const { fileURLToPath, pathToFileURL } = require("url");
2025-06-20 22:08:04 +08:00
const vm = require("vm");
/**
2025-07-08 20:52:43 +08:00
* @param {string} path path
* @returns {string} subPath
2025-06-20 22:08:04 +08:00
*/
const getSubPath = (path) => {
2025-06-20 22:08:04 +08:00
let subPath = "";
const lastSlash = path.lastIndexOf("/");
let firstSlash = path.indexOf("/");
if (lastSlash !== -1 && firstSlash !== lastSlash) {
if (firstSlash !== -1) {
let next = path.indexOf("/", firstSlash + 1);
let dir = path.slice(firstSlash + 1, next);
while (dir === ".") {
firstSlash = next;
next = path.indexOf("/", firstSlash + 1);
dir = path.slice(firstSlash + 1, next);
}
}
subPath = path.slice(firstSlash + 1, lastSlash + 1);
}
return subPath;
};
/**
2025-07-08 20:52:43 +08:00
* @param {string} path path
* @returns {boolean} whether path is a relative path
2025-06-20 22:08:04 +08:00
*/
const isRelativePath = (path) => /^\.\.?\//.test(path);
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @param {string} url url
* @param {string} outputDirectory outputDirectory
* @returns {string} absolute path
2025-06-20 22:08:04 +08:00
*/
const urlToPath = (url, outputDirectory) => {
if (url.startsWith("https://test.cases/path/")) url = url.slice(24);
else if (url.startsWith("https://test.cases/")) url = url.slice(19);
return path.resolve(outputDirectory, `./${url}`);
};
/**
2025-07-08 20:52:43 +08:00
* @param {string} url url
* @returns {string} relative path
2025-06-20 22:08:04 +08:00
*/
const urlToRelativePath = (url) => {
2025-06-20 22:08:04 +08:00
if (url.startsWith("https://test.cases/path/")) url = url.slice(24);
else if (url.startsWith("https://test.cases/")) url = url.slice(19);
return `./${url}`;
};
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} TestMeta
2025-06-20 22:08:04 +08:00
* @property {string} category
* @property {string} name
2025-07-08 20:52:43 +08:00
* @property {number=} round
2025-06-20 22:08:04 +08:00
*/
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} TestConfig
* @property {EXPECTED_FUNCTION=} resolveModule
* @property {EXPECTED_FUNCTION=} moduleScope
* @property {EXPECTED_FUNCTION=} nonEsmThis
* @property {boolean=} evaluateScriptOnAttached
* @property {"jsdom"=} env
2025-06-20 22:08:04 +08:00
*/
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} TestRunnerOptions
2025-06-20 22:08:04 +08:00
* @property {string|string[]} target
* @property {string} outputDirectory
* @property {TestMeta} testMeta
* @property {TestConfig} testConfig
* @property {EXPECTED_ANY} webpackOptions
*/
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} ModuleInfo
2025-06-20 22:08:04 +08:00
* @property {string} subPath
* @property {string} modulePath
* @property {string} content
*/
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} RequireContext
2025-06-20 22:08:04 +08:00
* @property {"unlinked"|"evaluated"} esmMode
*/
/**
2025-07-08 20:52:43 +08:00
* @typedef {object} ModuleRunner
2025-06-20 22:08:04 +08:00
* @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} cjs
* @property {(moduleInfo: ModuleInfo, context: RequireContext) => Promise<EXPECTED_ANY>} esm
* @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} json
* @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} raw
*/
class TestRunner {
/**
2025-07-08 20:52:43 +08:00
* @param {TestRunnerOptions} options test runner options
2025-06-20 22:08:04 +08:00
*/
constructor({
target,
outputDirectory,
testMeta,
testConfig,
webpackOptions
}) {
/** @type {string|string[]} */
2025-06-20 22:08:04 +08:00
this.target = target;
/** @type {string} */
2025-06-20 22:08:04 +08:00
this.outputDirectory = outputDirectory;
/** @type {TestConfig} */
2025-06-20 22:08:04 +08:00
this.testConfig = testConfig || {};
/** @type {TestMeta} */
2025-06-20 22:08:04 +08:00
this.testMeta = testMeta || {};
/** @type {EXPECTED_ANY} */
2025-06-20 22:08:04 +08:00
this.webpackOptions = webpackOptions || {};
/** @type {boolean} */
2025-06-20 22:08:04 +08:00
this._runInNewContext = this.isTargetWeb();
/** @type {EXPECTED_ANY} */
2025-06-20 22:08:04 +08:00
this._globalContext = this.createBaseGlobalContext();
/** @type {EXPECTED_ANY} */
2025-06-20 22:08:04 +08:00
this._moduleScope = this.createBaseModuleScope();
/** @type {ModuleRunner} */
2025-06-20 22:08:04 +08:00
this._moduleRunners = this.createModuleRunners();
/** @type {EXPECTED_ANY} */
2025-07-08 20:52:43 +08:00
this._esmContext = this.createBaseEsmContext();
2025-06-20 22:08:04 +08:00
}
/**
2025-07-08 20:52:43 +08:00
* @returns {ModuleRunner} module runners
2025-06-20 22:08:04 +08:00
*/
createModuleRunners() {
return {
cjs: this.createCjsRunner(),
esm: this.createEsmRunner(),
json: this.createJSONRunner(),
raw: this.createRawRunner()
};
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
* @returns {EXPECTED_ANY} globalContext
*/
createBaseGlobalContext() {
2025-07-08 20:52:43 +08:00
const base = { console, expect, setTimeout, clearTimeout };
2025-06-20 22:08:04 +08:00
Object.assign(base, this.setupEnv());
return base;
}
2025-07-08 20:52:43 +08:00
/**
* @param {EXPECTED_ANY} esmContext esm context
* @returns {EXPECTED_ANY} esm context
*/
mergeEsmContext(esmContext) {
return Object.assign(this._esmContext, esmContext);
}
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @returns {boolean} whether target is web
2025-06-20 22:08:04 +08:00
*/
isTargetWeb() {
return (
this.target === "web" ||
this.target === "webworker" ||
(Array.isArray(this.target) &&
(this.target.includes("web") || this.target.includes("webworker")))
);
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @returns {boolean} whether env is jsdom
2025-06-20 22:08:04 +08:00
*/
jsDom() {
return this.testConfig.env === "jsdom" || this.isTargetWeb();
2025-06-20 22:08:04 +08:00
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
* @returns {EXPECTED_ANY} moduleScope
*/
createBaseModuleScope() {
2025-07-08 20:52:43 +08:00
const base = {
2025-06-20 22:08:04 +08:00
console,
expect,
jest,
nsObj: (m) => {
2025-06-20 22:08:04 +08:00
Object.defineProperty(m, Symbol.toStringTag, {
value: "Module"
});
return m;
}
};
if (this.jsDom()) {
Object.assign(base, this._globalContext);
base.window = this._globalContext;
base.self = this._globalContext;
}
return base;
}
2025-07-08 20:52:43 +08:00
/**
* @returns {EXPECTED_ANY} esm context
*/
createBaseEsmContext() {
const base = {
global,
process,
setTimeout,
setImmediate,
URL,
Buffer
};
return base;
}
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @param {EXPECTED_ANY} globalContext global context
* @returns {EXPECTED_ANY} global context
2025-06-20 22:08:04 +08:00
*/
mergeGlobalContext(globalContext) {
return Object.assign(this._globalContext, globalContext);
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @param {EXPECTED_ANY} moduleScope module scope
* @returns {EXPECTED_ANY} module scope
2025-06-20 22:08:04 +08:00
*/
mergeModuleScope(moduleScope) {
return Object.assign(this._moduleScope, moduleScope);
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @param {string} currentDirectory current directory
* @param {string|string[]} module module
* @returns {ModuleInfo} module info
2025-06-20 22:08:04 +08:00
*/
_resolveModule(currentDirectory, module) {
if (Array.isArray(module)) {
return {
subPath: "",
modulePath: path.join(currentDirectory, ".array-require.js"),
content: `module.exports = (${module
.map((arg) => `require(${JSON.stringify(`./${arg}`)})`)
2025-06-20 22:08:04 +08:00
.join(", ")});`
};
}
if (isRelativePath(module)) {
return {
subPath: getSubPath(module),
modulePath: path.join(currentDirectory, module),
2025-07-08 20:52:43 +08:00
content: fs.readFileSync(path.join(currentDirectory, module), "utf8")
2025-06-20 22:08:04 +08:00
};
}
if (path.isAbsolute(module)) {
return {
subPath: "",
modulePath: module,
2025-07-08 20:52:43 +08:00
content: fs.readFileSync(module, "utf8")
2025-06-20 22:08:04 +08:00
};
}
2025-06-23 20:22:23 +08:00
if (module.startsWith("https://test.")) {
const realPath = urlToPath(module, currentDirectory);
return {
subPath: "",
modulePath: realPath,
2025-07-08 20:52:43 +08:00
content: fs.readFileSync(realPath, "utf8")
2025-06-23 20:22:23 +08:00
};
}
2025-06-20 22:08:04 +08:00
}
/**
2025-07-08 20:52:43 +08:00
* @param {string} currentDirectory current directory
* @param {string|string[]} module module
* @param {RequireContext=} context context
* @returns {EXPECTED_ANY} require result
2025-06-20 22:08:04 +08:00
*/
require(currentDirectory, module, context = {}) {
if (this.testConfig.modules && module in this.testConfig.modules) {
return this.testConfig.modules[module];
}
if (this.testConfig.resolveModule) {
module = this.testConfig.resolveModule(
module,
this.testMeta.round || 0,
this.webpackOptions
);
}
2025-07-08 20:52:43 +08:00
const moduleInfo = this._resolveModule(currentDirectory, module);
2025-06-20 22:08:04 +08:00
if (!moduleInfo) {
// node v12.2.0+ has Module.createRequire
const rawRequire = Module.createRequire
? Module.createRequire(currentDirectory)
: require;
return rawRequire(module.startsWith("node:") ? module.slice(5) : module);
2025-06-20 22:08:04 +08:00
}
const { modulePath } = moduleInfo;
if (
modulePath.endsWith(".mjs") &&
this.webpackOptions.experiments &&
this.webpackOptions.experiments.outputModule
) {
return this._moduleRunners.esm(moduleInfo, context);
}
if (modulePath.endsWith(".json")) {
return this._moduleRunners.json(moduleInfo, context);
}
if (["css"].includes(modulePath.split(".").pop())) {
return this._moduleRunners.raw(moduleInfo, context);
}
return this._moduleRunners.cjs(moduleInfo, context);
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} cjs runner
2025-06-20 22:08:04 +08:00
*/
createCjsRunner() {
const requireCache = Object.create(null);
2025-07-08 20:52:43 +08:00
return (moduleInfo, _context) => {
2025-06-20 22:08:04 +08:00
const { modulePath, subPath, content } = moduleInfo;
let _content = content;
if (modulePath in requireCache) {
return requireCache[modulePath].exports;
}
const mod = {
2025-07-08 20:52:43 +08:00
exports: {},
webpackTestSuiteModule: true
2025-06-20 22:08:04 +08:00
};
requireCache[modulePath] = mod;
const moduleScope = {
...this._moduleScope,
2025-07-08 20:52:43 +08:00
require: Object.assign(
this.require.bind(this, path.dirname(modulePath)),
this.require
),
importScripts: (url) => {
2025-06-20 22:08:04 +08:00
expect(url).toMatch(/^https:\/\/test\.cases\/path\//);
this.require(this.outputDirectory, urlToRelativePath(url));
},
module: mod,
exports: mod.exports,
__dirname: path.dirname(modulePath),
__filename: modulePath,
_globalAssign: { expect, it: this._moduleScope.it }
};
// Call again because some tests rely on `scope.module`
if (this.testConfig.moduleScope) {
this.testConfig.moduleScope(moduleScope, this.webpackOptions);
}
2025-07-08 20:52:43 +08:00
if (!this._runInNewContext) {
2025-06-20 22:08:04 +08:00
_content = `Object.assign(global, _globalAssign); ${content}`;
2025-07-08 20:52:43 +08:00
}
2025-06-20 22:08:04 +08:00
const args = Object.keys(moduleScope);
const argValues = args.map((arg) => moduleScope[arg]);
2025-06-20 22:08:04 +08:00
const code = `(function(${args.join(", ")}) {${_content}\n})`;
const document = this._moduleScope.document;
const fn = this._runInNewContext
? vm.runInNewContext(code, this._globalContext, modulePath)
: vm.runInThisContext(code, modulePath);
const call = () => {
fn.call(
this.testConfig.nonEsmThis
? this.testConfig.nonEsmThis(module)
: mod.exports,
...argValues
);
};
if (document) {
const CurrentScript = require("../helpers/CurrentScript");
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
const oldCurrentScript = document.currentScript;
document.currentScript = new CurrentScript(subPath);
try {
call();
} finally {
document.currentScript = oldCurrentScript;
}
} else {
call();
}
return mod.exports;
};
}
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
/**
2025-07-08 20:52:43 +08:00
* @returns {(moduleInfo: ModuleInfo, context: RequireContext) => Promise<EXPECTED_ANY>} esm runner
2025-06-20 22:08:04 +08:00
*/
createEsmRunner() {
2025-07-08 20:52:43 +08:00
const createEsmContext = () =>
vm.createContext(
{ ...this._moduleScope, ...this._esmContext },
{
name: "context for esm"
}
);
2025-06-20 22:08:04 +08:00
const esmCache = new Map();
const { category, name, round } = this.testMeta;
const esmIdentifier = `${category.name}-${name}-${round || 0}`;
let esmContext = null;
return (moduleInfo, context) => {
const asModule = require("../helpers/asModule");
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
// lazy bind esm context
if (!esmContext) {
2025-07-08 20:52:43 +08:00
esmContext = createEsmContext();
2025-06-20 22:08:04 +08:00
}
2025-07-08 20:52:43 +08:00
const { modulePath, content } = moduleInfo;
2025-06-20 22:08:04 +08:00
const { esmMode } = context;
2025-07-08 20:52:43 +08:00
if (!vm.SourceTextModule) {
2025-06-20 22:08:04 +08:00
throw new Error(
"Running this test requires '--experimental-vm-modules'.\nRun with 'node --experimental-vm-modules node_modules/jest-cli/bin/jest'."
);
2025-07-08 20:52:43 +08:00
}
2025-06-20 22:08:04 +08:00
let esm = esmCache.get(modulePath);
if (!esm) {
esm = new vm.SourceTextModule(content, {
identifier: `${esmIdentifier}-${modulePath}`,
url: `${pathToFileURL(modulePath).href}?${esmIdentifier}`,
context: esmContext,
initializeImportMeta: (meta, module) => {
meta.url = pathToFileURL(modulePath).href;
},
importModuleDynamically: async (specifier, module) => {
const normalizedSpecifier = specifier.startsWith("file:")
? `./${path.relative(
path.dirname(modulePath),
fileURLToPath(specifier)
)}`
: specifier.replace(
/https:\/\/example.com\/public\/path\//,
"./"
);
const result = await this.require(
path.dirname(modulePath),
normalizedSpecifier,
{
esmMode: "evaluated"
}
);
return await asModule(result, module.context);
}
});
esmCache.set(modulePath, esm);
}
if (esmMode === "unlinked") return esm;
return (async () => {
if (esmMode === "unlinked") return esm;
await esm.link(
async (specifier, referencingModule) =>
await asModule(
await this.require(
path.dirname(
referencingModule.identifier
? referencingModule.identifier.slice(
esmIdentifier.length + 1
)
: fileURLToPath(referencingModule.url)
),
specifier,
{ esmMode: "unlinked" }
),
referencingModule.context,
true
)
);
// node.js 10 needs instantiate
if (esm.instantiate) esm.instantiate();
await esm.evaluate();
if (esmMode === "evaluated") return esm;
const ns = esm.namespace;
return ns.default && ns.default instanceof Promise ? ns.default : ns;
})();
};
}
2025-07-08 20:52:43 +08:00
/**
* @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} json runner
*/
2025-06-20 22:08:04 +08:00
createJSONRunner() {
return (moduleInfo) => JSON.parse(moduleInfo.content);
2025-06-20 22:08:04 +08:00
}
2025-07-08 20:52:43 +08:00
/**
* @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} raw runner
*/
2025-06-20 22:08:04 +08:00
createRawRunner() {
return (moduleInfo) => moduleInfo.content;
2025-06-20 22:08:04 +08:00
}
2025-07-08 20:52:43 +08:00
/**
* @returns {EXPECTED_ANY} env
*/
2025-06-20 22:08:04 +08:00
setupEnv() {
if (this.jsDom()) {
const outputDirectory = this.outputDirectory;
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
const FakeDocument = require("../helpers/FakeDocument");
const createFakeWorker = require("../helpers/createFakeWorker");
const EventSource = require("../helpers/EventSourceForNode");
2025-07-08 20:52:43 +08:00
2025-06-20 22:08:04 +08:00
const document = new FakeDocument(outputDirectory);
if (this.testConfig.evaluateScriptOnAttached) {
document.onScript = (src) => {
2025-06-20 22:08:04 +08:00
this.require(outputDirectory, urlToRelativePath(src));
};
}
const fetch = async (url) => {
2025-06-20 22:08:04 +08:00
try {
const buffer = await new Promise((resolve, reject) => {
fs.readFile(urlToPath(url, this.outputDirectory), (err, b) =>
err ? reject(err) : resolve(b)
);
});
return {
status: 200,
ok: true,
2025-07-08 20:52:43 +08:00
json: async () => JSON.parse(buffer.toString("utf8"))
2025-06-20 22:08:04 +08:00
};
} catch (err) {
if (err.code === "ENOENT") {
return {
status: 404,
ok: false
};
}
throw err;
}
};
2025-07-08 20:52:43 +08:00
const env = {
2025-06-20 22:08:04 +08:00
setTimeout,
document,
location: {
href: "https://test.cases/path/index.html",
origin: "https://test.cases",
toString() {
return "https://test.cases/path/index.html";
}
},
getComputedStyle: document.getComputedStyle.bind(document),
Worker: createFakeWorker({
outputDirectory
}),
URL,
EventSource,
clearTimeout,
fetch
};
if (typeof Blob !== "undefined") {
// node.js >= 18
env.Blob = Blob;
}
return env;
}
return {};
}
}
module.exports.TestRunner = TestRunner;