From ab12f2077c9e8d3edca8b0dfa6b98c8e9bcb5004 Mon Sep 17 00:00:00 2001 From: Timo Tijhof Date: Mon, 26 Aug 2024 01:08:25 +0100 Subject: [PATCH] Build: Add native ESM distribution --- Gruntfile.js | 6 + demos/bundlers.mjs | 2 +- package.json | 18 ++- rollup.config.js | 108 +++++++++------ src/core/export-wrapper-bundler-require.js | 7 + src/core/export-wrapper-nodejs-module.js | 45 +++++++ src/core/export.js | 51 ------- src/core/qunit-commonjs.js | 12 ++ src/core/qunit.js | 150 +++++++++++++++++---- src/core/version.js | 1 + 10 files changed, 281 insertions(+), 119 deletions(-) create mode 100644 src/core/export-wrapper-bundler-require.js create mode 100644 src/core/export-wrapper-nodejs-module.js delete mode 100644 src/core/export.js create mode 100644 src/core/qunit-commonjs.js diff --git a/Gruntfile.js b/Gruntfile.js index 173841ece..6cad1e85e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -27,6 +27,12 @@ module.exports = function (grunt) { 'src-css': { src: 'src/core/qunit.css', dest: 'qunit/qunit.css' + }, + 'src-export-wrappers': { + src: 'src/core/export-*.js', + expand: true, + flatten: true, + dest: 'qunit/' } }, search: { diff --git a/demos/bundlers.mjs b/demos/bundlers.mjs index 650363a97..5b6287b35 100644 --- a/demos/bundlers.mjs +++ b/demos/bundlers.mjs @@ -7,7 +7,7 @@ const dirname = path.dirname(url.fileURLToPath(import.meta.url)); const DIR = path.join(dirname, 'bundlers'); // Prepare -cp.execSync('npm install --no-audit --update-notifier=false', { cwd: DIR, encoding: 'utf8' }); +// cp.execSync('npm install --no-audit --update-notifier=false', { cwd: DIR, encoding: 'utf8' }); await import('./bundlers/build.mjs'); const tmpJsFiles = fs.readdirSync(path.join(DIR, 'tmp')) diff --git a/package.json b/package.json index cc27a314e..7a40c613f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,20 @@ "LICENSE.txt" ], "main": "qunit/qunit.js", + "exports": { + ".": { + "node": { + "import": "./qunit/export-wrapper-nodejs-module.js", + "default": "./qunit/qunit.js" + }, + "module": { + "import": "./qunit/qunit.module.js", + "default": "./qunit/export-wrapper-bundler-require.js" + }, + "import": "./qunit/qunit.module.js", + "default": "./qunit/qunit.js" + } + }, "engines": { "node": ">=18" }, @@ -82,8 +96,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..e3faab9e4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,44 +8,74 @@ 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/qunit.module.js', + format: 'es', + exports: 'named', + banner: banner + }, + plugins: [ + replace(replacementOptions), + nodeResolve(), + commonjs(), + babel({ + babelHelpers: 'bundled', + babelrc: false, + presets: [ + ['@babel/preset-env', { + targets: { + safari: 10, + node: 18 + } + }] + ] + }) + ] + } +]; diff --git a/src/core/export-wrapper-bundler-require.js b/src/core/export-wrapper-bundler-require.js new file mode 100644 index 000000000..156607187 --- /dev/null +++ b/src/core/export-wrapper-bundler-require.js @@ -0,0 +1,7 @@ +// 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('./qunit.module.js'); +module.exports = QUnit; diff --git a/src/core/export-wrapper-nodejs-module.js b/src/core/export-wrapper-nodejs-module.js new file mode 100644 index 000000000..55a9c94e4 --- /dev/null +++ b/src/core/export-wrapper-nodejs-module.js @@ -0,0 +1,45 @@ +// 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. +// +// The "real" ESM file is reserved for bundlers, web distribution, +// and other consumers of package.json#exports.module. +// +// 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/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.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';