From 580b07281952a1c502bb5685a3e948d6f4a5fafc Mon Sep 17 00:00:00 2001 From: Brandon Casey Date: Fri, 25 Mar 2022 01:12:14 -0400 Subject: [PATCH] feat: parallel test running for node and the browser --- package.json | 1 + src/cli/run.js | 144 ++++++++++++++---------- src/core.js | 190 +++++++++++++++++++++++++++++++- src/core/config.js | 8 +- src/globals.js | 1 + src/html-reporter/urlparams.js | 26 ++++- src/test.js | 9 ++ src/workers/base-worker.mjs | 59 ++++++++++ src/workers/iframe-run.html | 22 ++++ src/workers/iframe-worker.mjs | 85 ++++++++++++++ src/workers/node-worker-run.mjs | 14 +++ src/workers/node-worker.mjs | 38 +++++++ src/workers/web-worker-run.mjs | 10 ++ src/workers/web-worker.mjs | 37 +++++++ src/workers/worker-factory.js | 55 +++++++++ src/workers/worker-run.mjs | 79 +++++++++++++ 16 files changed, 716 insertions(+), 62 deletions(-) create mode 100644 src/workers/base-worker.mjs create mode 100644 src/workers/iframe-run.html create mode 100644 src/workers/iframe-worker.mjs create mode 100644 src/workers/node-worker-run.mjs create mode 100644 src/workers/node-worker.mjs create mode 100644 src/workers/web-worker-run.mjs create mode 100644 src/workers/web-worker.mjs create mode 100644 src/workers/worker-factory.js create mode 100644 src/workers/worker-run.mjs diff --git a/package.json b/package.json index 1f318df50..f5a56d0b7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "files": [ "bin/", "src/cli/", + "src/workers/", "qunit/qunit.js", "qunit/qunit.css", "LICENSE.txt" diff --git a/src/cli/run.js b/src/cli/run.js index 4e5fdb7f8..78086453f 100644 --- a/src/cli/run.js +++ b/src/cli/run.js @@ -10,6 +10,7 @@ const { findReporter } = require( "./find-reporter" ); const DEBOUNCE_WATCH_LENGTH = 60; const DEBOUNCE_RESTART_LENGTH = 200 - DEBOUNCE_WATCH_LENGTH; +const os = require( "os" ); const changedPendingPurge = []; @@ -20,15 +21,27 @@ async function run( args, options ) { // Default to non-zero exit code to avoid false positives process.exitCode = 1; - const files = utils.getFilesFromArgs( args ); - QUnit = requireQUnit(); + let globalConfig = {}; + + // TODO: Enable mode where QUnit is not auto-injected, but other setup is + // still done automatically. + if ( global.QUnit && global.QUnit.config ) { + globalConfig = global.QUnit.config; + } + global.QUnit = QUnit; + + Object.keys( globalConfig ).forEach( function( key ) { + QUnit.config[ key ] = globalConfig[ key ]; + } ); + if ( options.filter ) { QUnit.config.filter = options.filter; } const seed = options.seed; + if ( seed ) { if ( seed === true ) { QUnit.config.seed = Math.random().toString( 36 ).slice( 2 ); @@ -39,65 +52,80 @@ async function run( args, options ) { console.log( `Running tests with seed: ${QUnit.config.seed}` ); } - // TODO: Enable mode where QUnit is not auto-injected, but other setup is - // still done automatically. - global.QUnit = QUnit; + if ( !options.noReporter ) { + findReporter( options.reporter, QUnit.reporters ).init( QUnit ); + } - options.requires.forEach( requireFromCWD ); + if ( !QUnit.config.isWorker ) { + QUnit.config.maxThreads = os.cpus().length; + QUnit.config.workerType = "NodeWorker"; + QUnit.config.files = args; + + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const nodeWorkerModule = await import( "../workers/node-worker.mjs" ); + const NodeWorker = nodeWorkerModule.default; + + QUnit.WorkerFactory.registerWorkerClass( NodeWorker ); + + } else { + options.requires.forEach( requireFromCWD ); + + const files = utils.getFilesFromArgs( args ); + + for ( let i = 0; i < files.length; i++ ) { + const filePath = path.resolve( process.cwd(), files[ i ] ); + delete require.cache[ filePath ]; + + // Node.js 12.0.0 has node_module_version=72 + // https://nodejs.org/en/download/releases/ + const nodeVint = process.config.variables.node_module_version; - findReporter( options.reporter, QUnit.reporters ).init( QUnit ); - - for ( let i = 0; i < files.length; i++ ) { - const filePath = path.resolve( process.cwd(), files[ i ] ); - delete require.cache[ filePath ]; - - // Node.js 12.0.0 has node_module_version=72 - // https://nodejs.org/en/download/releases/ - const nodeVint = process.config.variables.node_module_version; - - try { - - // QUnit supports passing ESM files to the 'qunit' command when used on - // Node.js 12 or later. The dynamic import() keyword supports both CommonJS files - // (.js, .cjs) and ESM files (.mjs), so we could simply use that unconditionally on - // newer Node versions, regardless of the given file path. - // - // But: - // - Node.js 12 emits a confusing "ExperimentalWarning" when using import(), - // even if just to load a non-ESM file. So we should try to avoid it on non-ESM. - // - This Node.js feature is still considered experimental so to avoid unexpected - // breakage we should continue using require(). Consider flipping once stable and/or - // as part of QUnit 3.0. - // - Plugins and CLI bootstrap scripts may be hooking into require.extensions to modify - // or transform code as it gets loaded. For compatibility with that, we should - // support that until at least QUnit 3.0. - // - File extensions are not sufficient to differentiate between CJS and ESM. - // Use of ".mjs" is optional, as a package may configure Node to default to ESM - // and optionally use ".cjs" for CJS files. - // - // https://nodejs.org/docs/v12.7.0/api/modules.html#modules_addenda_the_mjs_extension - // https://nodejs.org/docs/v12.7.0/api/esm.html#esm_code_import_code_expressions - // https://github.com/qunitjs/qunit/issues/1465 try { - require( filePath ); - } catch ( e ) { - if ( ( e.code === "ERR_REQUIRE_ESM" || - ( e instanceof SyntaxError && - e.message === "Cannot use import statement outside a module" ) ) && - ( !nodeVint || nodeVint >= 72 ) ) { - - // filePath is an absolute file path here (per path.resolve above). - // On Windows, Node.js enforces that absolute paths via ESM use valid URLs, - // e.g. file-protocol) https://github.com/qunitjs/qunit/issues/1667 - await import( url.pathToFileURL( filePath ) ); // eslint-disable-line node/no-unsupported-features/es-syntax - } else { - throw e; + + // QUnit supports passing ESM files to the 'qunit' command when used on + // Node.js 12 or later. The dynamic import() keyword supports both CommonJS files + // (.js, .cjs) and ESM files (.mjs), so we could simply use that unconditionally on + // newer Node versions, regardless of the given file path. + // + // But: + // - Node.js 12 emits a confusing "ExperimentalWarning" when using import(), + // even if just to load a non-ESM file. So we should try to avoid it on non-ESM. + // - This Node.js feature is still considered experimental so to avoid unexpected + // breakage we should continue using require(). Consider flipping once stable and/or + // as part of QUnit 3.0. + // - Plugins and CLI bootstrap scripts may be hooking into require.extensions to modify + // or transform code as it gets loaded. For compatibility with that, we should + // support that until at least QUnit 3.0. + // - File extensions are not sufficient to differentiate between CJS and ESM. + // Use of ".mjs" is optional, as a package may configure Node to default to ESM + // and optionally use ".cjs" for CJS files. + // + // https://nodejs.org/docs/v12.7.0/api/modules.html#modules_addenda_the_mjs_extension + // https://nodejs.org/docs/v12.7.0/api/esm.html#esm_code_import_code_expressions + // https://github.com/qunitjs/qunit/issues/1465 + try { + require( filePath ); + } catch ( e ) { + if ( ( e.code === "ERR_REQUIRE_ESM" || + ( e instanceof SyntaxError && + e.message === "Cannot use import statement outside a module" ) ) && + ( !nodeVint || nodeVint >= 72 ) ) { + + // filePath is an absolute file path here (per path.resolve above). + // On Windows, Node.js enforces that absolute paths via ESM use valid URLs, + // e.g. file-protocol) https://github.com/qunitjs/qunit/issues/1667 + await import( url.pathToFileURL( filePath ) ); // eslint-disable-line node/no-unsupported-features/es-syntax + } else { + throw e; + } } + } catch ( e ) { + const error = new Error( + `Failed to load file ${files[ i ]}\n${e.name}: ${e.message}` + ); + error.stack = e.stack; + QUnit.onUncaughtException( error ); } - } catch ( e ) { - const error = new Error( `Failed to load file ${files[ i ]}\n${e.name}: ${e.message}` ); - error.stack = e.stack; - QUnit.onUncaughtException( error ); } } @@ -141,7 +169,9 @@ async function run( args, options ) { } } ); - QUnit.start(); + if ( !QUnit.config.isWorker ) { + QUnit.start(); + } } run.restart = function( args ) { diff --git a/src/core.js b/src/core.js index d95a361bd..8996d5aa3 100644 --- a/src/core.js +++ b/src/core.js @@ -1,4 +1,4 @@ -import { window, document, setTimeout } from "./globals"; +import { window, document, setTimeout, performance } from "./globals"; import equiv from "./equiv"; import dump from "./dump"; @@ -19,6 +19,29 @@ import ProcessingQueue from "./core/processing-queue"; import { on, emit } from "./events"; import onWindowError from "./core/onerror"; import onUncaughtException from "./core/on-uncaught-exception"; +import WorkerFactory from "./workers/worker-factory.js"; +import IframeWorker from "./workers/iframe-worker.mjs"; +import WebWorker from "./workers/web-worker.mjs"; + +WorkerFactory.registerWorkerClass( IframeWorker ); +WorkerFactory.registerWorkerClass( WebWorker ); +const configFilters = [ "filter", "testId", "moduleId", "module" ]; + +const getFilter = ( qunitConfig ) => configFilters.reduce( function( acc, key ) { + if ( Object.hasOwnProperty.call( qunitConfig, key ) ) { + const value = qunitConfig[ key ]; + + if ( Array.isArray( value ) && value.length ) { + acc = acc || {}; + acc[ key ] = value; + } else if ( typeof value === "string" && value ) { + acc = acc || {}; + acc[ key ] = value; + } + } + + return acc; +}, null ); const QUnit = {}; @@ -39,9 +62,147 @@ QUnit.isLocal = ( window && window.location && window.location.protocol === "fil // Expose the current QUnit version QUnit.version = "@VERSION"; +const getChannelReportListener = function( startTimeMs, doneCount, completeCallback ) { + const done = {}; + const runEnd = {}; + const counts = {}; + + return function( e ) { + const { type, key } = e.data; + let { details } = e.data; + + counts[ type ] = counts[ type ] || {}; + counts[ type ][ key ] = counts[ type ][ key ] || 0; + counts[ type ][ key ]++; + + // only use the first being/runStart + if ( ( key === "begin" || key === "runStart" ) && counts[ type ][ key ] >= 2 ) { + return; + } + + if ( key === "done" || key === "runEnd" ) { + if ( key === "done" ) { + Object.keys( details ).forEach( function( k ) { + done[ k ] = done[ k ] || 0; + done[ k ] += details[ k ]; + } ); + + details = done; + } else { + const stats = e.data.stats; + + Object.keys( stats ).forEach( function( key ) { + QUnit.config.stats[ key ] += stats[ key ]; + } ); + + if ( Object.keys( runEnd ).length === 0 ) { + Object.keys( details ).forEach( function( k ) { + runEnd[ k ] = details[ k ]; + } ); + + runEnd.childSuites = new Array( details.childSuites.length ); + runEnd.testCounts = {}; + } + runEnd.runtime = performance.now() - startTimeMs; + + Object.keys( details.testCounts ).forEach( function( k ) { + runEnd.testCounts[ k ] = runEnd.testCounts[ k ] || 0; + runEnd.testCounts[ k ] += details.testCounts[ k ]; + } ); + + if ( details.status !== "passed" ) { + runEnd.status = details.status; + } + + details.childSuites.forEach( ( suite, i ) => { + if ( !isNaN( suite.runtime ) ) { + runEnd.childSuites[ i ] = suite; + } + } ); + + details = runEnd; + } + + // wait for the last done and runEnd + if ( counts[ type ][ key ] !== doneCount ) { + return; + } + } + + if ( type === "qunit-event" ) { + runLoggingCallbacks( key, details ); + } else { + emit( key, details ); + } + + if ( key === "done" ) { + completeCallback(); + } + }; +}; + +const startWorkers = function( maxThreads, className, files ) { + const startTimeMs = performance.now(); + const factory = new WorkerFactory( { + maxThreads, + className, + files + } ); + + factory.createWorker().then( function(worker) { + return worker.task( { + type: "get-info", + filter: getFilter( QUnit.config ) + } ); + } ).then( ( {testIds, modules} ) => { + QUnit.config.modules = modules; + + const maxWorkersToCreate = maxThreads; + // we already created one worker for getting testIds + // so subtract that. + const workersToCreate = (testIds.length > maxWorkersToCreate ? + maxWorkersToCreate : testIds.length) - 1; + + if (workersToCreate > 0) { + return factory.createWorkers(workersToCreate) + .then(() => Promise.resolve(testIds)); + } else { + return Promise.resolve(testIds); + } + + }).then((testIds) => new Promise( (resolve /* reject */ ) => { + + for ( let i = 0; i < testIds.length; i++ ) { + const testId = testIds[ i ]; + const wIndex = i % factory.workers.length; + + factory.workers[ wIndex ].send( { type: "testId", testId } ); + } + + if ( !testIds.length ) { + // will cause failOnZeroTests if option is set. + ProcessingQueue.advance(); + resolve(); + } else { + const channelReportListener = getChannelReportListener( + startTimeMs, + factory.workers.length, + resolve + ); + + for ( let i = 0; i < factory.workers.length; i++ ) { + factory.workers[ i ].listen( channelReportListener ); + factory.workers[ i ].send( { type: "start" } ); + } + } + } ) ).then( function() { + factory.dispose(); + } ); +}; + extend( QUnit, { config, - + WorkerFactory, dump, equiv, reporters, @@ -63,6 +224,31 @@ extend( QUnit, { only: test.only, start: function( count ) { + if ( !QUnit.config.isWorker ) { + if ( QUnit.parseUrlParams ) { + QUnit.parseUrlParams(); + } + const maxThreads = QUnit.config.maxThreads ? + parseFloat( QUnit.config.maxThreads ) : + (window.navigator && window.navigator.hardwareConcurrency); + const workerType = QUnit.config.workerType ? + QUnit.config.workerType : + WorkerFactory.workerClasses[ 0 ].name; + + if (!WorkerFactory.workerClasses.some((c) => c.name === workerType)) { + throw new Error( "QUnit.config.workerType must be a valid worker" + + `class name: ${WorkerFactory.workerClasses.map((c) => c.name).join(', ')}` ); + } + + if ( typeof maxThreads !== 'number' || maxThreads <= 0 ) { + throw new Error( "QUnit.config.workerThreads must be a number " + + "greater than or equal to zero" ) + } + + QUnit.config.queue.length = 0; + startWorkers( maxThreads, workerType, QUnit.config.files ); + return; + } if ( config.current ) { throw new Error( "QUnit.start cannot be called inside a test context." ); diff --git a/src/core/config.js b/src/core/config.js index 92394a874..0983b8b8e 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -7,6 +7,7 @@ import { extend } from "./utilities"; * `config` initialized at top of scope */ const config = { + autostart: false, // The queue of tests to run queue: [], @@ -100,7 +101,12 @@ const config = { callbacks: {}, // The storage module to use for reordering tests - storage: localSessionStorage + storage: localSessionStorage, + + // determines if this is a worker or not + isWorker: false, + + files: [] }; // take a predefined QUnit.config and extend the defaults diff --git a/src/globals.js b/src/globals.js index e36e8de75..588ed0b04 100644 --- a/src/globals.js +++ b/src/globals.js @@ -5,6 +5,7 @@ export const self = globalThis.self; export const console = globalThis.console; export const setTimeout = globalThis.setTimeout; export const clearTimeout = globalThis.clearTimeout; +export const performance = globalThis.performance; export const document = window && window.document; export const navigator = window && window.navigator; diff --git a/src/html-reporter/urlparams.js b/src/html-reporter/urlparams.js index 15e890a9d..ba3023cea 100644 --- a/src/html-reporter/urlparams.js +++ b/src/html-reporter/urlparams.js @@ -32,6 +32,13 @@ import { window } from "../globals"; QUnit.config.seed = urlParams.seed; } + const maxThreadsValue = []; + const hardwareConcurrency = window.navigator && window.navigator.hardwareConcurrency || 4; + + for (let i = 0; i < hardwareConcurrency; i++) { + maxThreadsValue.push(`${i + 1}`); + } + // Add URL-parameter-mapped config values with UI form rendering data QUnit.config.urlConfig.push( { @@ -50,10 +57,23 @@ import { window } from "../globals"; label: "No try-catch", tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging " + "exceptions in IE reasonable. Stored as query-strings." + }, + { + id: "workerType", + label: "Worker type", + value: [ "IframeWorker", "WebWorker" ], + tooltip: "The type of worker to run tests in. Default is IframeWorker." + }, + { + id: "maxThreads", + label: "Max Worker threads", + value: maxThreadsValue, + tooltip: "The max number of workers to use. Zero to run only on the main thread. " + + "default is navigator.hardwareConcurrency" } ); - QUnit.begin( function() { + QUnit.parseUrlParams = function() { var i, option, urlConfig = QUnit.config.urlConfig; @@ -69,7 +89,9 @@ import { window } from "../globals"; QUnit.config[ option ] = urlParams[ option ]; } } - } ); + }; + + QUnit.begin( QUnit.parseUrlParams ); function getUrlParams() { var i, param, name, value; diff --git a/src/test.js b/src/test.js index 8f390ba5c..795c2fc46 100644 --- a/src/test.js +++ b/src/test.js @@ -499,6 +499,13 @@ Test.prototype = { } function runTest() { + if ( !test.valid() ) { + test.skip = true; + test.testReport.valid = false; + incrementTestsIgnored( test.module ); + return []; + } + return [ function() { return test.before(); @@ -529,6 +536,8 @@ Test.prototype = { ]; } + runTest.test = test; + const previousFailCount = config.storage && +config.storage.getItem( "qunit-test-" + this.module.name + "-" + this.testName ); diff --git a/src/workers/base-worker.mjs b/src/workers/base-worker.mjs new file mode 100644 index 000000000..d3187f3be --- /dev/null +++ b/src/workers/base-worker.mjs @@ -0,0 +1,59 @@ +class BaseWorker { + constructor() { + this.listeners_ = []; + this.busy_ = true; + } + + waitReady() { + return this.readyPromise_; + } + + busy() { + return this.busy_; + } + + send(data) { + this.port_.postMessage(data); + } + + listen(fn) { + this.listeners_.push(fn); + this.port_.addEventListener('message', fn); + } + + unlisten(fn) { + const i = this.listeners_.indexOf(fn); + + if (i !== -1) { + this.listeners_.splice(i, 1); + } + this.port_.removeEventListener('message', fn); + } + + task(data) { + this.busy_ = true; + + return new Promise((resolve, reject) => { + this.handleMessage_ = (e) => { + this.port_.removeEventListener('message', this.handleMessage_); + this.handleMessage_ = null; + this.busy_ = false; + resolve(e.data); + }; + + this.port_.addEventListener('message', this.handleMessage_); + this.port_.postMessage(data); + }); + } + + + dispose() { + if (this.handleMessage_) { + this.port_.removeEventListener('message', this.handleMessage_); + } + this.port_.close(); + this.port_ = null; + } +} + +export default BaseWorker; diff --git a/src/workers/iframe-run.html b/src/workers/iframe-run.html new file mode 100644 index 000000000..d95c4de22 --- /dev/null +++ b/src/workers/iframe-run.html @@ -0,0 +1,22 @@ + + + + + +
+ + + diff --git a/src/workers/iframe-worker.mjs b/src/workers/iframe-worker.mjs new file mode 100644 index 000000000..635c80773 --- /dev/null +++ b/src/workers/iframe-worker.mjs @@ -0,0 +1,85 @@ +import BaseWorker from './base-worker.mjs'; + +const sandboxPermissions = [ + 'allow-downloads', + 'allow-forms', + 'allow-modals', + 'allow-orientation-lock', + 'allow-pointer-lock', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-presentation', + 'allow-scripts', + 'allow-top-navigation' +]; + +class IframeWorker extends BaseWorker { + constructor(files) { + super(); + this.fixture_ = document.getElementById('qunit-iframe-worker-fixture'); + + if (!this.fixture_) { + this.fixture_ = document.createElement('div'); + + this.fixture_.id = 'qunit-iframe-worker-fixture'; + this.fixture_.style.display = 'none'; + + document.body.appendChild(this.fixture_); + } + + this.readyPromise_ = new Promise((resolve, reject) => { + this.iframe_ = document.createElement('iframe'); + this.iframe_.setAttribute('loading', 'eager'); + this.iframe_.setAttribute('sandbox', sandboxPermissions.join(' ')); + this.iframe_.src = window.location.origin + .replace(window.location.port, window.location.port === '8080' ? '8081' : '8080') + '/node_modules/qunit/src/workers/iframe-run.html'; + + this.loadListener_ = (e) => { + this.iframe_.removeEventListener('load', this.loadListener_, false); + this.loadListener_ = null; + resolve(); + }; + + this.iframe_.addEventListener('load', this.loadListener_, false); + this.fixture_.appendChild(this.iframe_); + }).then(() => new Promise((resolve, reject) => { + const {port1, port2} = new MessageChannel(); + + this.port_ = port1; + this.initMessage_ = (e) => { + this.port_.removeEventListener('message', this.initMessage_); + this.initMessage_ = null; + this.busy_ = false; + resolve(); + }; + this.port_.addEventListener('message', this.initMessage_); + this.port_.start(); + + this.iframe_.contentWindow.postMessage({port: port2, imports: files}, '*', [port2]); + })); + } + + dispose() { + if (this.initMessage_) { + this.port_.removeEventListener('message', this.initMessage_); + this.initMessage_ = null; + } + + super.dispose(); + + if (this.loadListener_) { + this.iframe_.removeEventListener('load', this.loadListener_, false); + } + + this.iframe_.remove(); + this.iframe_ = null; + + + if (!this.fixture_.firstChild) { + this.fixture_.remove(); + } + this.fixture_ = null; + } +} + +export default IframeWorker; diff --git a/src/workers/node-worker-run.mjs b/src/workers/node-worker-run.mjs new file mode 100644 index 000000000..a3b12336f --- /dev/null +++ b/src/workers/node-worker-run.mjs @@ -0,0 +1,14 @@ +import { parentPort } from 'worker_threads'; +import workerRun from './worker-run.mjs'; +import QUnitRun from '../cli/run.js'; + +workerRun({ + handleInitData(initData) { + return QUnitRun(initData.imports, { + requires: [], + noReporter: true + }); + }, + globalPort: parentPort +}); + diff --git a/src/workers/node-worker.mjs b/src/workers/node-worker.mjs new file mode 100644 index 000000000..b5f0102d5 --- /dev/null +++ b/src/workers/node-worker.mjs @@ -0,0 +1,38 @@ +import { Worker, MessageChannel } from 'worker_threads'; +import BaseWorker from './base-worker.mjs'; +import path from 'node:path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +class NodeWorker extends BaseWorker { + constructor(files) { + super(); + + this.readyPromise_ = new Promise((resolve, reject) => { + this.worker_ = new Worker(path.join(__dirname, 'node-worker-run.mjs')); + const {port1, port2} = new MessageChannel(); + + this.port_ = port1; + this.initMessage_ = (e) => { + this.port_.off('message', this.initMessage_); + this.initMessage_ = null; + this.busy_ = false; + resolve(); + }; + this.port_.on('message', this.initMessage_); + this.worker_.postMessage({port: port2, imports: files}, [port2]); + }); + } + + dispose() { + if (this.initMessage_) { + this.port_.off('message', this.initMessage_); + this.initMessage_ = null; + + } + this.worker_.terminate(); + this.worker_ = null; + } +} + +export default NodeWorker; diff --git a/src/workers/web-worker-run.mjs b/src/workers/web-worker-run.mjs new file mode 100644 index 000000000..2505ff0ab --- /dev/null +++ b/src/workers/web-worker-run.mjs @@ -0,0 +1,10 @@ +import workerRun from './worker-run.mjs'; + +workerRun({ + parentPort: null, + handleInitData: (initData) => { + return import('/node_modules/qunit/qunit/qunit.js').then(function() { + return Promise.all(initData.imports.map((importFile) => Promise.resolve(import(importFile)))) + }); + } +}); diff --git a/src/workers/web-worker.mjs b/src/workers/web-worker.mjs new file mode 100644 index 000000000..51043936b --- /dev/null +++ b/src/workers/web-worker.mjs @@ -0,0 +1,37 @@ +import BaseWorker from './base-worker.mjs'; + +class WebWorker extends BaseWorker { + constructor(files) { + super(); + this.readyPromise_ = new Promise((resolve, reject) => { + this.worker_ = new Worker('/node_modules/qunit/src/workers/web-worker-run.mjs', {name: 'qunit-web-worker', type: 'module'}); + + const {port1, port2} = new MessageChannel(); + + this.port_ = port1; + this.initMessage_ = (e) => { + this.port_.removeEventListener('message', this.initMessage_); + this.initMessage_ = null; + this.busy_ = false; + resolve(); + }; + this.port_.addEventListener('message', this.initMessage_); + this.port_.start(); + this.worker_.postMessage({port: port2, imports: files}, [port2]); + }); + } + + dispose() { + if (this.initMessage_) { + this.port_.removeEventListener('message', this.initMessage_); + this.initMessage_ = null; + } + this.worker_.terminate(); + URL.revokeObjectURL(this.objectUrl_); + this.objectUrl_ = null; + this.worker_ = null; + + } +} + +export default WebWorker; diff --git a/src/workers/worker-factory.js b/src/workers/worker-factory.js new file mode 100644 index 000000000..05e2718b6 --- /dev/null +++ b/src/workers/worker-factory.js @@ -0,0 +1,55 @@ +class WorkerFactory { + static workerClasses = []; + static registerWorkerClass(WorkerClass) { + WorkerFactory.workerClasses.push(WorkerClass); + } + + constructor(options) { + options = Object.assign({ + maxThreads: 4, + files: [], + }, options || {}); + + this.workers = []; + this.files = options.files; + this.maxThreads = options.maxThreads; + + const WorkerClass = WorkerFactory.workerClasses.find((c) => c.name === options.className); + + if (!WorkerClass) { + throw Error('Must provide a valid workerType option to WorkerFactory'); + } + + this.WorkerClass = WorkerClass; + } + + createWorker() { + if (this.workers.length > this.maxThreads) { + throw new Error('Maximum number of workers already created'); + } + const worker = new this.WorkerClass(this.files); + + this.workers.push(worker); + + return worker.waitReady().then(() => Promise.resolve(worker)); + }; + + createWorkers(count) { + const promises = []; + + for (let i = 0; i < count; i++) { + promises.push(this.createWorker()); + } + + return Promise.all(promises) + } + + dispose() { + this.workers.forEach(function(worker) { + worker.dispose(); + }); + + this.workers.length = 0; + } +} +export default WorkerFactory; diff --git a/src/workers/worker-run.mjs b/src/workers/worker-run.mjs new file mode 100644 index 000000000..2e00a94f5 --- /dev/null +++ b/src/workers/worker-run.mjs @@ -0,0 +1,79 @@ +const workerRun = function({handleInitData, globalPort = globalThis}) { + const loggingKeys = ['begin', 'done', 'moduleDone', 'moduleStart', 'testDone', 'testStart', 'log']; + const jsReporterKeys = ['testStart', 'testEnd', 'suiteStart', 'suiteEnd', 'runStart', 'runEnd']; + + globalThis.QUnit = globalThis.QUnit || {}; + globalThis.QUnit.config = globalThis.QUnit.config || {}; + globalThis.QUnit.config.autostart = false; + globalThis.QUnit.config.isWorker = true; + + let port; + const initQUnit = function() { + loggingKeys.forEach(function(key) { + QUnit[key](function(details) { + port.postMessage({type: 'qunit-event', key, details, moduleIds: QUnit.config.moduleId}); + }); + }); + + jsReporterKeys.forEach(function(key) { + QUnit.on(key, function(details) { + const message = { + type: 'js-reporter', + key, + details, + moduleIds: QUnit.config.moduleId + }; + + // QUnit also uses stats for reporting + if (key === 'runEnd') { + message.stats = QUnit.config.stats; + } + port.postMessage(message); + }); + }); + }; + + const handleMessage = function(e) { + const message = e.data; + + if (message.type === 'testId') { + QUnit.config.testId = QUnit.config.testId || []; + return QUnit.config.testId.push(e.data.testId); + } + + if (message.type === 'get-info') { + if (message.filter) { + Object.keys(message.filter).forEach(function(key) { + QUnit.config[key] = message.filter[key]; + }); + } + const testIds = QUnit.config.queue.reduce((acc, v) => { + if (v && v.test && v.test.valid()) { + acc.push(v.test.testId); + } + return acc; + }, []); + + return port.postMessage({type: 'info', testIds, modules: QUnit.config.modules}); + } + + if (message.type === 'start') { + return QUnit.start() + } + }; + + const handleInitialMessage = function(e) { + globalPort.removeEventListener('message', handleInitialMessage); + + port = e && e.data && e.data.port || e.port; + port.addEventListener('message', handleMessage); + port.start() + handleInitData(e.data || e).then(function() { + initQUnit(); + port.postMessage('init'); + }); + }; + globalPort.addEventListener('message', handleInitialMessage); +}; + +export default workerRun;