diff --git a/Gruntfile.js b/Gruntfile.js index 173841ece..bece57b63 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -27,6 +27,18 @@ module.exports = function (grunt) { 'src-css': { src: 'src/core/qunit.css', dest: 'qunit/qunit.css' + }, + 'src-export-cjs-wrappers': { + src: 'src/core/qunit-wrapper-bundler-require.js', + expand: true, + flatten: true, + dest: 'qunit/' + }, + 'src-export-esm-wrappers': { + src: 'src/core/qunit-wrapper-nodejs-module.js', + expand: true, + flatten: true, + dest: 'qunit/esm/' } }, search: { diff --git a/package.json b/package.json index 069491168..9ebb9687a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,23 @@ "LICENSE.txt" ], "main": "qunit/qunit.js", + "exports": { + ".": { + "node": { + "import": "./qunit/esm/qunit-wrapper-nodejs-module.js", + "default": "./qunit/qunit.js" + }, + "module": { + "import": "./qunit/esm/qunit.module.js", + "default": "./qunit/qunit-wrapper-bundler-require.js" + }, + "import": "./qunit/esm/qunit.module.js", + "default": "./qunit/qunit.js" + }, + "./qunit/qunit.css": { + "default": "./qunit/qunit.css" + } + }, "engines": { "node": ">=18" }, @@ -82,8 +99,8 @@ "tap-min": "^3.0.0" }, "scripts": { - "build": "rollup -c && grunt copy:src-css", - "build-coverage": "rollup -c --environment BUILD_TARGET:coverage && grunt copy:src-css", + "build": "rollup -c && grunt copy", + "build-coverage": "rollup -c --environment BUILD_TARGET:coverage && grunt copy", "build-dev": "node build/dev.js", "benchmark": "npm install --silent --no-audit --prefix test/benchmark/ && node test/benchmark/micro.js", "lint": "eslint --cache .", diff --git a/rollup.config.js b/rollup.config.js index 3ae0b3a20..925eeee37 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,44 +8,76 @@ const replace = require('@rollup/plugin-replace'); const { replacements } = require('./build/dist-replace.js'); const isCoverage = process.env.BUILD_TARGET === 'coverage'; -module.exports = { - input: 'src/core/qunit.js', - output: { - file: 'qunit/qunit.js', - sourcemap: isCoverage, - format: 'iife', - exports: 'none', +const banner = `/*! + * QUnit @VERSION + * https://qunitjs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + */`; - // eslint-disable-next-line no-multi-str - banner: '/*!\n\ - * QUnit @VERSION\n\ - * https://qunitjs.com/\n\ - *\n\ - * Copyright OpenJS Foundation and other contributors\n\ - * Released under the MIT license\n\ - * https://jquery.org/license\n\ - */' - }, - plugins: [ - replace({ - preventAssignment: true, - delimiters: ['', ''], - ...replacements - }), - nodeResolve(), - commonjs(), - babel({ - babelHelpers: 'bundled', - babelrc: false, - presets: [ - ['@babel/preset-env', { - targets: { - ie: 11, - safari: 7, - node: 18 - } - }] - ] - }) - ] +const replacementOptions = { + preventAssignment: true, + delimiters: ['', ''], + ...replacements }; + +module.exports = [ + { + input: 'src/core/qunit-commonjs.js', + output: { + file: 'qunit/qunit.js', + sourcemap: isCoverage, + format: 'iife', + exports: 'none', + banner: banner + }, + plugins: [ + replace(replacementOptions), + nodeResolve(), + commonjs(), + babel({ + babelHelpers: 'bundled', + babelrc: false, + presets: [ + ['@babel/preset-env', { + targets: { + ie: 11, + safari: 7, + node: 18 + } + }] + ] + }) + ] + }, + { + input: 'src/core/qunit.js', + output: { + file: 'qunit/esm/qunit.module.js', + format: 'es', + exports: 'named', + banner: banner + }, + plugins: [ + replace(replacementOptions), + nodeResolve(), + commonjs(), + babel({ + babelHelpers: 'bundled', + babelrc: false, + presets: [ + ['@babel/preset-env', { + // No need to support IE11 in the ESM version, + // which means this will leave ES6 features mostly unchanged. + targets: { + safari: 10, + node: 18 + } + }] + ] + }) + ] + } +]; diff --git a/src/cli/require-qunit.js b/src/cli/require-qunit.js index 9e4823152..27ca7c007 100644 --- a/src/cli/require-qunit.js +++ b/src/cli/require-qunit.js @@ -1,3 +1,6 @@ +const path = require('path'); +const { Module } = require('module'); + // Depending on the exact usage, QUnit could be in one of several places, this // function handles finding it. module.exports = function requireQUnit (resolve = require.resolve) { @@ -15,12 +18,30 @@ module.exports = function requireQUnit (resolve = require.resolve) { // If the user (accidentally) ran the CLI command from their global // install, then we prefer to stil use the qunit library file from the // current project's dependency. - const localQUnitPath = resolve('qunit', { + // + // NOTE: We can't use require.resolve() because, despite it taking a 'paths' + // option, the resolution algorithm [1] is poisoned by current filename (i.e. + // this src/cli/require-qunit.js file). The documentation doesn't say it, + // but in practice the "paths" option only overrides how step 6 (LOAD_NODE_MODULES) + // traverses directories. It does not influence step 5 (LOAD_PACKAGE_SELF) which + // looks explicilty relative to the current file (regardless of `process.cwd`, + // and regardless of `paths` passed to require.resolve). This wasn't an issue + // until QUnit 3.0 because LOAD_PACKAGE_SELF only looks for cases where + // package.json uses "exports", which QUnit 3.0 adopted for ESM support. + // + // If this uses `requires.resolve(, paths:[cwd])` instead of + // `Module.createRequire(cwd).resolve()`, then this would always return + // the 'qunit' copy that this /src/cli/require-qunit.js file came from, + // regardless of the process.cwd(), which defeats the purpose of looking + // relative to process.cwd(). + // + // This is covered by /test/cli/require-qunit-test.js + // + // [1]: https://nodejs.org/docs/latest-v18.x/api/modules.html#all-together + const localQUnitPath = Module.createRequire( + path.join(process.cwd(), 'fake.js') + ).resolve('qunit'); - // Support: Node 10. Explicitly check "node_modules" to avoid a bug. - // Fixed in Node 12+. See https://github.com/nodejs/node/issues/35367. - paths: [process.cwd() + '/node_modules', process.cwd()] - }); delete require.cache[localQUnitPath]; return require(localQUnitPath); } catch (e) { diff --git a/src/core/export.js b/src/core/export.js deleted file mode 100644 index 4ec44ce79..000000000 --- a/src/core/export.js +++ /dev/null @@ -1,51 +0,0 @@ -/* global module, exports */ -import { window, document, globalThis } from './globals.js'; - -/** - * Available exports: - * - * globalThis: - * - browser (globalThis === window) - * - Web Worker (globalThis === self) - * - Node.js - * - SpiderMonkey (mozjs) - * - Rhino 7.14+ - * - any other embedded JS engine - * - * CommonJS module.exports (commonjs2): - * - Node.js - * - * CommonJS exports (commonjs, https://wiki.commonjs.org/wiki/Modules): - * - Rhino - */ -export default function exportQUnit (QUnit) { - if (window && document) { - // QUnit may be defined when it is preconfigured but then only QUnit and QUnit.config may be defined. - if (globalThis.QUnit && globalThis.QUnit.version) { - throw new Error('QUnit has already been defined.'); - } - } - - // For Node.js - if (typeof module !== 'undefined' && module && module.exports) { - module.exports = QUnit; - - // For consistency with CommonJS environments' exports - module.exports.QUnit = QUnit; - } - - // For CommonJS with exports, but without module.exports, like Rhino - if (typeof exports !== 'undefined' && exports) { - exports.QUnit = QUnit; - } - - // Ensure the global is available in all environments. - // - // For backward compatibility, we only enforce load-once in browsers above. - // In other environments QUnit is accessible via import/require() and may - // load multiple times. Callers may decide whether their secondary instance - // should be global or not. - if (!globalThis.QUnit || !globalThis.QUnit.version) { - globalThis.QUnit = QUnit; - } -} diff --git a/src/core/qunit-commonjs.js b/src/core/qunit-commonjs.js new file mode 100644 index 000000000..d64a2540d --- /dev/null +++ b/src/core/qunit-commonjs.js @@ -0,0 +1,12 @@ +/* global module, exports */ +import QUnit from './qunit.js'; + +// For Node.js +if (typeof module !== 'undefined' && module && module.exports) { + module.exports = QUnit; +} + +// For CommonJS with exports, but without module.exports, like Rhino +if (typeof exports !== 'undefined' && exports) { + exports.QUnit = QUnit; +} diff --git a/src/core/qunit-wrapper-bundler-require.js b/src/core/qunit-wrapper-bundler-require.js new file mode 100644 index 000000000..08a5a7b3d --- /dev/null +++ b/src/core/qunit-wrapper-bundler-require.js @@ -0,0 +1,9 @@ +/* eslint-env node */ + +// In a single bundler invocation, if different parts or dependencies +// of a project mix ESM and CJS, avoid a split-brain state by making +// sure both import and re-use the same instance via this wrapper. +// +// Bundlers generally allow requiring an ESM file from CommonJS. +const { QUnit } = require('./esm/qunit.module.js'); +module.exports = QUnit; diff --git a/src/core/qunit-wrapper-nodejs-module.js b/src/core/qunit-wrapper-nodejs-module.js new file mode 100644 index 000000000..870e6b070 --- /dev/null +++ b/src/core/qunit-wrapper-nodejs-module.js @@ -0,0 +1,42 @@ +// In a single Node.js process, if different parts or dependencies +// of a project mix ESM and CJS, avoid a split-brain state by making +// sure both import and re-use the same instance via this wrapper. +// +// Node.js 18+ can import a CommonJS file from ESM. +import QUnit from '../qunit.js'; + +export const { + assert, + begin, + config, + diff, + done, + dump, + equiv, + hooks, + is, + isLocal, + log, + module, + moduleDone, + moduleStart, + objectType, + on, + only, + onUncaughtException, + pushFailure, + reporters, + skip, + stack, + start, + test, + testDone, + testStart, + todo, + urlParams, + version +} = QUnit; + +export { QUnit }; + +export default QUnit; diff --git a/src/core/qunit.js b/src/core/qunit.js index 17db97cab..39c0f9a82 100644 --- a/src/core/qunit.js +++ b/src/core/qunit.js @@ -6,7 +6,7 @@ import diff from './diff.js'; import dump from './dump.js'; import equiv from './equiv.js'; import { on } from './events.js'; -import { window, document } from './globals.js'; +import { globalThis, window, document } from './globals.js'; import hooks from './hooks.js'; import { module, unnamedModule } from './module.js'; import onUncaughtException from './on-uncaught-exception.js'; @@ -21,57 +21,155 @@ import version from './version.js'; // Imports that help with init import { initBrowser } from './browser/browser-runner.js'; -import exportQUnit from './export.js'; +// Finalise internal state and exports before we export the API config.currentModule = unnamedModule; config._pq = new ProcessingQueue(); -const QUnit = { +const assert = Assert.prototype; + +const isLocal = (window && window.location && window.location.protocol === 'file:'); + +const begin = createRegisterCallbackFunction('begin'); +const done = createRegisterCallbackFunction('done'); +const log = createRegisterCallbackFunction('log'); +const moduleDone = createRegisterCallbackFunction('moduleDone'); +const moduleStart = createRegisterCallbackFunction('moduleStart'); +const testDone = createRegisterCallbackFunction('testDone'); +const testStart = createRegisterCallbackFunction('testStart'); - // Figure out if we're running the tests from a server or not - isLocal: (window && window.location && window.location.protocol === 'file:'), +const only = test.only; +const skip = test.skip; +const todo = test.todo; - // Expose the current QUnit version - version, +// Export the API +// +// * ESM export +// - Node.js +// - browser +// - any other ESM-capable JS engine +// +// * globalThis +// - browser (globalThis === window) +// - Web Worker (globalThis === self) +// - Node.js +// - SpiderMonkey (mozjs) +// - Rhino 7.14+ +// - any other embedded JS engine +// +// The following are handled by the separate export-commonjs.js file: +// +// * CommonJS module.exports (commonjs2) +// - Node.js +// +// * CommonJS exports (commonjs, https://wiki.commonjs.org/wiki/Modules): +// - Rhino +export { + assert, + begin, config, + diff, + done, + dump, + equiv, + hooks, + is, + isLocal, + log, + module, + moduleDone, + moduleStart, + objectType, + on, + only, + onUncaughtException, + pushFailure, + reporters, + skip, stack, + start, + test, + testDone, + testStart, + todo, urlParams, + version +}; +const QUnit = { + assert, + begin, + config, diff, + done, dump, equiv, - reporters, hooks, is, - on, + isLocal, + log, + module, + moduleDone, + moduleStart, objectType, + on, + only, onUncaughtException, pushFailure, - - begin: createRegisterCallbackFunction('begin'), - done: createRegisterCallbackFunction('done'), - log: createRegisterCallbackFunction('log'), - moduleDone: createRegisterCallbackFunction('moduleDone'), - moduleStart: createRegisterCallbackFunction('moduleStart'), - testDone: createRegisterCallbackFunction('testDone'), - testStart: createRegisterCallbackFunction('testStart'), - - assert: Assert.prototype, - module, + reporters, + skip, + stack, start, test, - - // alias other test flavors for easy access - todo: test.todo, - skip: test.skip, - only: test.only + testDone, + testStart, + todo, + urlParams, + version }; // Inject the exported QUnit API for use by reporters in start() config._QUnit = QUnit; -exportQUnit(QUnit); +// Support: require('qunit').QUnit +// +// For interop and consistency between Node.js `module.exports = QUnit` +// and CommonJS environments `exports.QUnit = QUnit`, the below will +// effectively assign `module.exports.QUnit = QUnit` as well. +QUnit.QUnit = QUnit; + +// Support: named import +// +// import { QUnit } from 'qunit' +// +export { QUnit }; + +// Support: default import +// +// import QUnit from 'qunit' +// +export default QUnit; + +if (window && document) { + // In browsers, throw if QUnit is loaded a second time. + // This must not throw if a global called "QUnit" exists for preconfigurion, + // in that case we simply upgrade/replace it with the proper export. + // Such preconfig global would only have QUnit.config set, not e.g. QUnit.version. + if (globalThis.QUnit && globalThis.QUnit.version) { + throw new Error('QUnit has already been defined.'); + } +} + +// Ensure the global is available in all environments. +// +// For backward compatibility, we only enforce load-once in browsers above. +// In other environments QUnit is accessible via import/require() and may +// load multiple times, including different versions from different sources. +// Callers decide whether to make their secondary instance global or not. +if (!globalThis.QUnit || !globalThis.QUnit.version) { + globalThis.QUnit = QUnit; +} if (window && document) { initBrowser(QUnit, window, document); diff --git a/src/core/version.js b/src/core/version.js index bbd37b695..27c1c9db9 100644 --- a/src/core/version.js +++ b/src/core/version.js @@ -1,2 +1,3 @@ +// Expose the current QUnit version // Replaced by /rollup.config.js using /build/dist-replace.js export default '@VERSION';