"use strict"; const fs = require("fs"); const { Module } = require("module"); const path = require("path"); const { fileURLToPath, pathToFileURL } = require("url"); const vm = require("vm"); const { getTargetProperties, getTargetsProperties } = require("../../lib/config/target"); const { ESModuleStatus, getNodeVersion, getSubPath, isRelativePath, urlToPath, urlToRelativePath } = require("./RunnerHelpers"); const [major] = getNodeVersion(); /** @typedef {import("./RunnerHelpers").ESModuleStatus} ESModuleStatus */ /** * @typedef {object} TestMeta * @property {string} category * @property {string} name * @property {number=} round */ /** * @typedef {object} TestConfig * @property {EXPECTED_FUNCTION=} resolveModule * @property {EXPECTED_FUNCTION=} moduleScope * @property {EXPECTED_FUNCTION=} nonEsmThis * @property {boolean=} evaluateScriptOnAttached * @property {"jsdom"=} env */ /** * @typedef {object} TestRunnerOptions * @property {string | string[]} target * @property {string} outputDirectory * @property {TestMeta} testMeta * @property {TestConfig} testConfig * @property {EXPECTED_ANY} webpackOptions */ /** * @typedef {object} ModuleInfo * @property {string} origin * @property {string} subPath * @property {string} modulePath * @property {string} content */ /** * @typedef {object} RequireContext * @property {typeof ESModuleStatus.Unlinked | typeof ESModuleStatus.Evaluated} esmReturnStatus */ /** * @typedef {object} ModuleRunner * @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} cjs * @property {(moduleInfo: ModuleInfo, context: RequireContext) => Promise} esm * @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} json * @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} raw * @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} bytes * @property {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} css */ class TestRunner { /** * @param {TestRunnerOptions} options test runner options */ constructor({ target, outputDirectory, testMeta, testConfig, webpackOptions }) { /** @type {string | string[]} */ this.target = target; /** @type {string} */ this.outputDirectory = outputDirectory; /** @type {TestConfig} */ this.testConfig = testConfig || {}; /** @type {TestMeta} */ this.testMeta = testMeta || {}; /** @type {EXPECTED_ANY} */ this.webpackOptions = webpackOptions || {}; /** @type {import("../../lib/config/target").TargetProperties | false} */ this._targetProperties = this._resolveTargetProperties(); /** @type {boolean} */ this._runInNewContext = this.hasWebTarget(); /** @type {EXPECTED_ANY} */ this._globalContext = this.createBaseGlobalContext(); /** @type {EXPECTED_ANY} */ this._esmContext = this.createBaseEsmContext(); /** @type {EXPECTED_ANY} */ this._moduleScope = this.createBaseModuleScope(); /** @type {ModuleRunner} */ this._moduleRunners = this.createModuleRunners(); } /** * @param {EXPECTED_ANY} webpackOptions webpack options * @returns {boolean} whether target is universal */ static isUniversalTarget(webpackOptions) { const outputModule = (webpackOptions.output && webpackOptions.output.module) || (webpackOptions.experiments && webpackOptions.experiments.outputModule); const target = webpackOptions.target; const targetProperties = target === false ? /** @type {false} */ (false) : typeof target === "string" ? getTargetProperties( target, /** @type {Context} */ (webpackOptions.context) ) : getTargetsProperties( /** @type {string[]} */ (target), /** @type {Context} */ (webpackOptions.context) ); return ( outputModule && targetProperties.node === null && targetProperties.web === null ); } /** * @param {object} options run options * @param {EXPECTED_ANY[]} options.optionsArr webpack options array * @param {string} options.outputDirectory output directory * @param {EXPECTED_ANY} options.testConfig test config * @param {{ name: string }} options.category test category * @param {string} options.testName test name * @param {(options: { runner: TestRunner, index: number, target: string }) => void} options.setupRunner configure runner * @param {(i: number, options: EXPECTED_ANY, runner: TestRunner) => string | string[] | null | undefined} options.getBundlePaths resolve bundle paths * @returns {{ filesCount: number, results: EXPECTED_ANY[] }} files count and results */ static runBundles({ optionsArr, outputDirectory, testConfig, category, testName, setupRunner, getBundlePaths }) { let filesCount = 0; const results = []; for (let i = 0; i < optionsArr.length; i++) { const options = optionsArr[i]; let targets = [options.target]; let found = false; if (TestRunner.isUniversalTarget(options)) { targets = targets.reduce((prev, cur) => [...prev, ...cur], []); } for (const target of targets) { const runner = new TestRunner({ target, outputDirectory, testMeta: { category: category.name, name: testName, round: i }, testConfig, webpackOptions: options }); setupRunner({ runner, index: i, target }); const bundlePaths = getBundlePaths(i, options, runner); if (bundlePaths) { const paths = Array.isArray(bundlePaths) ? bundlePaths : [bundlePaths]; for (const p of paths) { const normalized = path.isAbsolute(p) ? p : p.startsWith("./") ? p : `./${p}`; results.push(runner.require(outputDirectory, normalized)); } if (!found) { found = true; filesCount++; } } } } return { filesCount, results }; } /** * @returns {ModuleRunner} module runners */ createModuleRunners() { return { cjs: this.createCjsRunner(), esm: this.createEsmRunner(), json: this.createJSONRunner(), raw: this.createRawRunner(), bytes: this.createBytesRunner(), css: this.createCssRunner() }; } /** * @returns {EXPECTED_ANY} globalContext */ createBaseGlobalContext() { const base = { console, expect, setTimeout, clearTimeout, setInterval, clearInterval }; Object.assign(base, this.setupEnv()); return base; } /** * @param {EXPECTED_ANY} esmContext esm context * @returns {EXPECTED_ANY} esm context */ mergeEsmContext(esmContext) { return Object.assign(this._esmContext, esmContext); } /** * @returns {import("../../lib/config/target").TargetProperties | false} target properties */ _resolveTargetProperties() { const target = this.target; const context = /** @type {string} */ (this.webpackOptions.context); if (target === false) return false; return typeof target === "string" ? getTargetProperties(target, context) : getTargetsProperties(/** @type {string[]} */ (target), context); } /** * @returns {boolean} whether target is web */ hasWebTarget() { return ( this._targetProperties !== false && (this._targetProperties.web === true || this._targetProperties.webworker === true) ); } /** * @returns {boolean} whether target is node */ hasNodeTarget() { return ( this._targetProperties !== false && this._targetProperties.node === true ); } /** * @returns {boolean} whether env is jsdom */ jsDom() { return this.testConfig.env === "jsdom" || this.hasWebTarget(); } /** * @returns {EXPECTED_ANY} moduleScope */ createBaseModuleScope() { const base = { console, expect, jest, nsObj: (m) => { Object.defineProperty(m, Symbol.toStringTag, { value: "Module" }); return m; } }; Object.assign(base, this._globalContext); if (this.jsDom()) { base.window = this._globalContext; base.self = this._globalContext; } return base; } /** * @returns {EXPECTED_ANY} esm context */ createBaseEsmContext() { const base = { global, process, setTimeout, clearTimeout, setImmediate, URL, Buffer, TextEncoder: typeof TextEncoder !== "undefined" ? TextEncoder : undefined, TextDecoder: typeof TextDecoder !== "undefined" ? TextDecoder : undefined }; return base; } /** * @param {EXPECTED_ANY} globalContext global context * @returns {EXPECTED_ANY} global context */ mergeGlobalContext(globalContext) { return Object.assign(this._globalContext, globalContext); } /** * @param {EXPECTED_ANY} moduleScope module scope * @returns {EXPECTED_ANY} module scope */ mergeModuleScope(moduleScope) { return Object.assign(this._moduleScope, moduleScope); } /** * @param {string} currentDirectory current directory * @param {string | string[]} module module * @returns {ModuleInfo} module info */ _resolveModule(currentDirectory, module) { if (Array.isArray(module)) { return { origin: module, subPath: "", modulePath: path.join(currentDirectory, ".array-require.js"), content: `module.exports = (${module .map((arg) => `require(${JSON.stringify(`./${arg}`)})`) .join(", ")});` }; } if (isRelativePath(module)) { return { origin: module, subPath: getSubPath(module), modulePath: path.join(currentDirectory, module), content: fs.readFileSync(path.join(currentDirectory, module), "utf8") }; } if (path.isAbsolute(module)) { return { origin: module, subPath: "", modulePath: module, content: fs.readFileSync(module, "utf8") }; } if (module.startsWith("https://test.")) { const realPath = urlToPath(module, currentDirectory); return { origin: module, subPath: "", modulePath: realPath, content: fs.readFileSync(realPath, "utf8") }; } } /** * @param {string} currentDirectory current directory * @param {string | string[]} module module * @param {RequireContext=} context context * @param {Record=} importAttributes import attributes * @returns {EXPECTED_ANY} require result */ require(currentDirectory, module, context = {}, importAttributes = {}) { 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 ); } const moduleInfo = this._resolveModule(currentDirectory, module); 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); } const { modulePath } = moduleInfo; if (importAttributes && importAttributes.type === "bytes") { return this._moduleRunners.bytes(moduleInfo, context); } 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 (modulePath.endsWith(".css")) { return this._moduleRunners.css(moduleInfo, context); } return this._moduleRunners.cjs(moduleInfo, context); } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} cjs runner */ createCjsRunner() { const requireCache = Object.create(null); return (moduleInfo, _context) => { const { modulePath, subPath, content } = moduleInfo; let _content = content; if (modulePath in requireCache) { return requireCache[modulePath].exports; } const mod = { exports: {}, webpackTestSuiteModule: true }; requireCache[modulePath] = mod; const moduleScope = { ...this._moduleScope, require: Object.assign( this.require.bind(this, path.dirname(modulePath)), this.require ), importScripts: (url) => { 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, this.target ); } const args = Object.keys(moduleScope); const argValues = args.map((arg) => moduleScope[arg]); if (!this._runInNewContext) { _content = `Object.assign(global, _globalAssign); ${content}`; } const code = `(function(${args.join(", ")}) {${_content}\n})`; 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 (this.hasWebTarget()) { this.withDocumentCurrentScript(call, subPath); } else { call(); } return mod.exports; }; } /** * Run the code with document.currentScript * @param {() => void} fn fn * @param {string} current currentScript */ withDocumentCurrentScript(fn, current) { const document = this._moduleScope.document; if (document) { const CurrentScript = require("../helpers/CurrentScript"); const oldCurrentScript = document.currentScript; document.currentScript = new CurrentScript(current); try { fn(); } finally { document.currentScript = oldCurrentScript; } } else { fn(); } } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => Promise} esm runner */ createEsmRunner() { const asModule = require("./asModule"); const createEsmContext = () => vm.createContext( { ...this._moduleScope, ...this._esmContext }, { name: "context for esm" } ); let esmContext = null; /** @type {Map} */ const esmCache = new Map(); const { category, name, round } = this.testMeta; const testMetaStr = `${category}-${name}-${round || 0}`; const appendTestMeta = (identifier) => `${identifier}?${testMetaStr}`; /** * @param {string} identifier identifier * @param {string} content content * @returns {vm.SourceTextModule} SourceTextModule */ const getModuleInstance = (identifier, content) => { let instance = esmCache.get(identifier); if (!instance) { instance = new vm.SourceTextModule(content, { identifier: appendTestMeta(identifier), url: appendTestMeta(pathToFileURL(identifier).href), context: esmContext, initializeImportMeta: (meta, _module) => { meta.url = pathToFileURL(identifier).href; if (this.hasNodeTarget()) { meta.filename = identifier; meta.dirname = path.dirname(identifier); } }, importModuleDynamically: async ( specifier, module, importAttributes ) => { const normalizedSpecifier = specifier.startsWith("file:") ? `./${path.relative( path.dirname(identifier), fileURLToPath(specifier) )}` : specifier.replace( /https:\/\/example.com\/public\/path\//, "./" ); const res = await this.require( path.dirname(identifier), normalizedSpecifier, { esmReturnStatus: ESModuleStatus.Evaluated }, importAttributes ); return await asModule( res, module.context, undefined, importAttributes ); } }); esmCache.set(identifier, instance); } return instance; }; return (moduleInfo, context) => { if (!vm.SourceTextModule) { throw new Error( "Running this test requires '--experimental-vm-modules'.\nRun with 'node --experimental-vm-modules node_modules/jest-cli/bin/jest'." ); } // lazy bind esm context if (!esmContext) { esmContext = createEsmContext(); } const { modulePath, content } = moduleInfo; const { esmReturnStatus } = context; const esm = getModuleInstance(modulePath, content); if (esmReturnStatus === ESModuleStatus.Unlinked) return esm; const link = async () => { await esm.link( async (specifier, referencingModule) => // `linker` should return `vm.SourceTextModule` await asModule( await this.require( path.dirname( referencingModule.identifier || fileURLToPath(referencingModule.url) ), specifier, { esmReturnStatus: ESModuleStatus.Unlinked } ), referencingModule.context, { esmReturnStatus: ESModuleStatus.Unlinked } ) ); }; const run = async () => { // Link module dependencies if (major === 10) { if (esm.linkingStatus === ESModuleStatus.Unlinked) { await link(); } if (esm.linkingStatus === ESModuleStatus.Linked) { esm.instantiate(); } } else if (esm.status === ESModuleStatus.Unlinked) { await link(); } // Evaluate the module await esm.evaluate(); if (esmReturnStatus === ESModuleStatus.Evaluated) return esm; const ns = esm.namespace; return ns.default && ns.default instanceof Promise ? ns.default : ns; }; return run(); }; } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} json runner */ createJSONRunner() { return (moduleInfo) => JSON.parse(moduleInfo.content); } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} raw runner */ createRawRunner() { return (moduleInfo) => moduleInfo.content; } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} bytes runner */ createBytesRunner() { return (moduleInfo) => new Uint8Array(Buffer.from(moduleInfo.content)); } /** * @returns {(moduleInfo: ModuleInfo, context: RequireContext) => EXPECTED_ANY} css runner */ createCssRunner() { return (moduleInfo) => { if (this.hasWebTarget()) { const link = this._moduleScope.document.createElement("link"); link.href = moduleInfo.origin; this._moduleScope.document.head.appendChild(link); } return moduleInfo.content; }; } /** * @returns {EXPECTED_ANY} env */ setupEnv() { if (this.jsDom()) { const outputDirectory = this.outputDirectory; const FakeDocument = require("../helpers/FakeDocument"); const createFakeWorker = require("../helpers/createFakeWorker"); const EventSource = require("../helpers/EventSourceForNode"); const document = new FakeDocument(outputDirectory); if (this.testConfig.evaluateScriptOnAttached) { document.onScript = (src) => { this.require(outputDirectory, urlToRelativePath(src)); }; } const fetch = async (url) => { 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, json: async () => JSON.parse(buffer.toString("utf8")) }; } catch (err) { if (err.code === "ENOENT") { return { status: 404, ok: false }; } throw err; } }; const env = { setTimeout, document, CSSStyleSheet: FakeDocument.CSSStyleSheet, 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, TextEncoder: typeof TextEncoder !== "undefined" ? TextEncoder : // eslint-disable-next-line n/prefer-global/text-encoder require("util").TextEncoder, TextDecoder: typeof TextDecoder !== "undefined" ? TextDecoder : // eslint-disable-next-line n/prefer-global/text-decoder require("util").TextDecoder, EventSource, clearTimeout, fetch }; if (typeof Blob !== "undefined") { // node.js >= 18 env.Blob = Blob; } return env; } return {}; } } module.exports.TestRunner = TestRunner;