From aba2021e3f6dd6b7bb3bf8a002930650bae15a14 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Sat, 9 May 2020 17:12:27 +0200 Subject: [PATCH] Experimentally implement shared workers --- ava.config.js | 7 +- lib/api.js | 5 +- lib/fork.js | 64 +++++ lib/load-config.js | 2 +- lib/plugin-support/shared-worker-loader.js | 251 ++++++++++++++++++ lib/plugin-support/shared-workers.js | 138 ++++++++++ lib/reporters/default.js | 35 ++- lib/run-status.js | 5 + lib/runner.js | 3 + lib/worker/ipc.js | 197 ++++++++++++-- lib/worker/plugin.js | 120 +++++++++ lib/worker/subprocess.js | 25 +- package-lock.json | 11 +- package.json | 6 +- plugin.d.ts | 81 ++++++ plugin.js | 9 + test-d/plugin.ts | 8 + test/helpers/exec.js | 28 +- .../fixtures/_worker.js | 3 + .../fixtures/package.json | 7 + .../fixtures/test.js | 11 + .../snapshots/test.js.md | 16 ++ .../snapshots/test.js.snap | Bin 0 -> 222 bytes .../cannot-publish-before-available/test.js | 7 + .../is-an-experiment/fixtures/package.json | 1 + .../is-an-experiment/fixtures/test.js | 4 + .../is-an-experiment/snapshots/test.js.md | 11 + .../is-an-experiment/snapshots/test.js.snap | Bin 0 -> 162 bytes test/shared-workers/is-an-experiment/test.js | 10 + .../lifecycle/fixtures/_worker.js | 3 + .../lifecycle/fixtures/available.js | 14 + .../lifecycle/fixtures/package.json | 10 + .../lifecycle/fixtures/teardown.js | 25 ++ test/shared-workers/lifecycle/test.js | 11 + .../requires-newish-node/fixtures/_worker.js | 3 + .../fixtures/package.json | 7 + .../requires-newish-node/fixtures/test.js | 10 + .../requires-newish-node/test.js | 17 ++ .../fixtures/_worker.js | 3 + .../fixtures/package.json | 10 + .../fixtures/test.js | 11 + .../supports-cjs-default-export/test.js | 6 + .../supports-esm-workers/fixtures/_worker.mjs | 3 + .../supports-esm-workers/fixtures/file-url.js | 12 + .../fixtures/package.json | 10 + .../fixtures/posix-path.js | 11 + .../supports-esm-workers/test.js | 12 + .../unsupported-protocol/fixtures/_worker.js | 3 + .../fixtures/in-shared-worker.js | 11 + .../fixtures/in-test-worker.js | 6 + .../fixtures/package.json | 10 + .../unsupported-protocol/snapshots/test.js.md | 17 ++ .../snapshots/test.js.snap | Bin 0 -> 246 bytes .../unsupported-protocol/test.js | 14 + .../fixtures/_plugin.js | 6 + .../fixtures/_worker.js | 12 + .../fixtures/package.json | 7 + .../worker-execution-crash/fixtures/test.js | 10 + .../snapshots/test.js.md | 16 ++ .../snapshots/test.js.snap | Bin 0 -> 215 bytes .../worker-execution-crash/test.js | 8 + .../worker-protocol/fixtures/_declare.js | 54 ++++ .../worker-protocol/fixtures/_plugin.js | 6 + .../worker-protocol/fixtures/_worker.js | 50 ++++ .../worker-protocol/fixtures/other.test.js | 1 + .../worker-protocol/fixtures/package.json | 7 + .../worker-protocol/fixtures/test.js | 14 + .../worker-protocol/snapshots/test.js.md | 48 ++++ .../worker-protocol/snapshots/test.js.snap | Bin 0 -> 469 bytes test/shared-workers/worker-protocol/test.js | 7 + .../fixtures/_factory-function.js | 3 + .../fixtures/_module.js | 1 + .../fixtures/_no-factory-function.js | 0 .../fixtures/factory-function.js | 11 + .../worker-startup-crashes/fixtures/module.js | 11 + .../fixtures/no-factory-function.js | 11 + .../fixtures/package.json | 10 + .../snapshots/test.js.md | 11 + .../snapshots/test.js.snap | Bin 0 -> 164 bytes .../worker-startup-crashes/test.js | 20 ++ .../fixtures/_plugin.js | 9 + .../fixtures/_worker.js | 10 + .../fixtures/package.json | 10 + .../fixtures/test-1.js | 8 + .../fixtures/test-2.js | 8 + .../workers-are-loaded-once/test.js | 9 + xo.config.js | 5 +- 87 files changed, 1645 insertions(+), 42 deletions(-) create mode 100644 lib/plugin-support/shared-worker-loader.js create mode 100644 lib/plugin-support/shared-workers.js create mode 100644 lib/worker/plugin.js create mode 100644 plugin.d.ts create mode 100644 plugin.js create mode 100644 test-d/plugin.ts create mode 100644 test/shared-workers/cannot-publish-before-available/fixtures/_worker.js create mode 100644 test/shared-workers/cannot-publish-before-available/fixtures/package.json create mode 100644 test/shared-workers/cannot-publish-before-available/fixtures/test.js create mode 100644 test/shared-workers/cannot-publish-before-available/snapshots/test.js.md create mode 100644 test/shared-workers/cannot-publish-before-available/snapshots/test.js.snap create mode 100644 test/shared-workers/cannot-publish-before-available/test.js create mode 100644 test/shared-workers/is-an-experiment/fixtures/package.json create mode 100644 test/shared-workers/is-an-experiment/fixtures/test.js create mode 100644 test/shared-workers/is-an-experiment/snapshots/test.js.md create mode 100644 test/shared-workers/is-an-experiment/snapshots/test.js.snap create mode 100644 test/shared-workers/is-an-experiment/test.js create mode 100644 test/shared-workers/lifecycle/fixtures/_worker.js create mode 100644 test/shared-workers/lifecycle/fixtures/available.js create mode 100644 test/shared-workers/lifecycle/fixtures/package.json create mode 100644 test/shared-workers/lifecycle/fixtures/teardown.js create mode 100644 test/shared-workers/lifecycle/test.js create mode 100644 test/shared-workers/requires-newish-node/fixtures/_worker.js create mode 100644 test/shared-workers/requires-newish-node/fixtures/package.json create mode 100644 test/shared-workers/requires-newish-node/fixtures/test.js create mode 100644 test/shared-workers/requires-newish-node/test.js create mode 100644 test/shared-workers/supports-cjs-default-export/fixtures/_worker.js create mode 100644 test/shared-workers/supports-cjs-default-export/fixtures/package.json create mode 100644 test/shared-workers/supports-cjs-default-export/fixtures/test.js create mode 100644 test/shared-workers/supports-cjs-default-export/test.js create mode 100644 test/shared-workers/supports-esm-workers/fixtures/_worker.mjs create mode 100644 test/shared-workers/supports-esm-workers/fixtures/file-url.js create mode 100644 test/shared-workers/supports-esm-workers/fixtures/package.json create mode 100644 test/shared-workers/supports-esm-workers/fixtures/posix-path.js create mode 100644 test/shared-workers/supports-esm-workers/test.js create mode 100644 test/shared-workers/unsupported-protocol/fixtures/_worker.js create mode 100644 test/shared-workers/unsupported-protocol/fixtures/in-shared-worker.js create mode 100644 test/shared-workers/unsupported-protocol/fixtures/in-test-worker.js create mode 100644 test/shared-workers/unsupported-protocol/fixtures/package.json create mode 100644 test/shared-workers/unsupported-protocol/snapshots/test.js.md create mode 100644 test/shared-workers/unsupported-protocol/snapshots/test.js.snap create mode 100644 test/shared-workers/unsupported-protocol/test.js create mode 100644 test/shared-workers/worker-execution-crash/fixtures/_plugin.js create mode 100644 test/shared-workers/worker-execution-crash/fixtures/_worker.js create mode 100644 test/shared-workers/worker-execution-crash/fixtures/package.json create mode 100644 test/shared-workers/worker-execution-crash/fixtures/test.js create mode 100644 test/shared-workers/worker-execution-crash/snapshots/test.js.md create mode 100644 test/shared-workers/worker-execution-crash/snapshots/test.js.snap create mode 100644 test/shared-workers/worker-execution-crash/test.js create mode 100644 test/shared-workers/worker-protocol/fixtures/_declare.js create mode 100644 test/shared-workers/worker-protocol/fixtures/_plugin.js create mode 100644 test/shared-workers/worker-protocol/fixtures/_worker.js create mode 100644 test/shared-workers/worker-protocol/fixtures/other.test.js create mode 100644 test/shared-workers/worker-protocol/fixtures/package.json create mode 100644 test/shared-workers/worker-protocol/fixtures/test.js create mode 100644 test/shared-workers/worker-protocol/snapshots/test.js.md create mode 100644 test/shared-workers/worker-protocol/snapshots/test.js.snap create mode 100644 test/shared-workers/worker-protocol/test.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/_factory-function.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/_module.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/_no-factory-function.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/factory-function.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/module.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/no-factory-function.js create mode 100644 test/shared-workers/worker-startup-crashes/fixtures/package.json create mode 100644 test/shared-workers/worker-startup-crashes/snapshots/test.js.md create mode 100644 test/shared-workers/worker-startup-crashes/snapshots/test.js.snap create mode 100644 test/shared-workers/worker-startup-crashes/test.js create mode 100644 test/shared-workers/workers-are-loaded-once/fixtures/_plugin.js create mode 100644 test/shared-workers/workers-are-loaded-once/fixtures/_worker.js create mode 100644 test/shared-workers/workers-are-loaded-once/fixtures/package.json create mode 100644 test/shared-workers/workers-are-loaded-once/fixtures/test-1.js create mode 100644 test/shared-workers/workers-are-loaded-once/fixtures/test-2.js create mode 100644 test/shared-workers/workers-are-loaded-once/test.js diff --git a/ava.config.js b/ava.config.js index 363e4279f1..813350fe01 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,4 +1,9 @@ +const skipTests = []; +if (process.versions.node < '12.17.0') { + skipTests.push('!test/shared-workers/!(requires-newish-node)/**'); +} + export default { - files: ['test/**', '!test/**/{fixtures,helpers}/**'], + files: ['test/**', '!test/**/{fixtures,helpers}/**', ...skipTests], ignoredByWatcher: ['{coverage,docs,media,test-d,test-tap}/**'] }; diff --git a/lib/api.js b/lib/api.js index eec26e952d..d1c0ca8c30 100644 --- a/lib/api.js +++ b/lib/api.js @@ -17,6 +17,7 @@ const RunStatus = require('./run-status'); const fork = require('./fork'); const serializeError = require('./serialize-error'); const {getApplicableLineNumbers} = require('./line-numbers'); +const sharedWorkers = require('./plugin-support/shared-workers'); function resolveModules(modules) { return arrify(modules).map(name => { @@ -231,6 +232,7 @@ class Api extends Emittery { const worker = fork(file, options, apiOptions.nodeArguments); runStatus.observeWorker(worker, file, {selectingLines: lineNumbers.length > 0}); + const deregistered = sharedWorkers.observeWorkerProcess(worker, runStatus); pendingWorkers.add(worker); worker.promise.then(() => { @@ -238,7 +240,8 @@ class Api extends Emittery { }); restartTimer(); - return worker.promise; + await worker.promise; + await deregistered; }, {concurrency, stopOnError: false}); } catch (error) { if (error && error.name === 'AggregateError') { diff --git a/lib/fork.js b/lib/fork.js index bf583c801f..10e9510b4a 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -20,7 +20,55 @@ const serializeOptions = useAdvanced ? options => JSON.parse(JSON.stringify(options)) : // Use JSON serialization to remove non-clonable values. options => options; +class SharedWorkerChannel extends Emittery { + constructor({channelId, filename, initialData}, sendToFork) { + super(); + + this.id = channelId; + this.filename = filename; + this.initialData = initialData; + this.sendToFork = sendToFork; + } + + signalReady() { + this.sendToFork({ + type: 'shared-worker-ready', + channelId: this.id + }); + } + + signalError() { + this.sendToFork({ + type: 'shared-worker-error', + channelId: this.id + }); + } + + emitMessage({messageId, replyTo, data}) { + this.emit('message', { + messageId, + replyTo, + data + }); + } + + forwardMessageToFork({messageId, replyTo, data}) { + this.sendToFork({ + type: 'shared-worker-message', + channelId: this.id, + messageId, + replyTo, + data + }); + } +} + +let forkCounter = 0; + module.exports = (file, options, execArgv = process.execArgv) => { + const forkId = `fork/${++forkCounter}`; + const sharedWorkerChannels = new Map(); + let finished = false; const emitter = new Emittery(); @@ -33,6 +81,7 @@ module.exports = (file, options, execArgv = process.execArgv) => { options = { baseDir: process.cwd(), file, + forkId, ...options }; @@ -76,6 +125,16 @@ module.exports = (file, options, execArgv = process.execArgv) => { case 'ready-for-options': send({type: 'options', options: serializeOptions(options)}); break; + case 'shared-worker-connect': { + const channel = new SharedWorkerChannel(message.ava, send); + sharedWorkerChannels.set(channel.id, channel); + emitter.emit('connectSharedWorker', channel); + break; + } + + case 'shared-worker-message': + sharedWorkerChannels.get(message.ava.channelId).emitMessage(message.ava); + break; case 'ping': send({type: 'pong'}); break; @@ -106,6 +165,7 @@ module.exports = (file, options, execArgv = process.execArgv) => { return { file, + forkId, promise, exit() { @@ -117,6 +177,10 @@ module.exports = (file, options, execArgv = process.execArgv) => { send({type: 'peer-failed'}); }, + onConnectSharedWorker(listener) { + return emitter.on('connectSharedWorker', listener); + }, + onStateChange(listener) { return emitter.on('stateChange', listener); } diff --git a/lib/load-config.js b/lib/load-config.js index 77fd7e92f4..e0a7cc19eb 100644 --- a/lib/load-config.js +++ b/lib/load-config.js @@ -7,7 +7,7 @@ const pkgConf = require('pkg-conf'); const NO_SUCH_FILE = Symbol('no ava.config.js file'); const MISSING_DEFAULT_EXPORT = Symbol('missing default export'); -const EXPERIMENTS = new Set(['disableSnapshotsInHooks', 'reverseTeardowns']); +const EXPERIMENTS = new Set(['disableSnapshotsInHooks', 'reverseTeardowns', 'sharedWorkers']); // *Very* rudimentary support for loading ava.config.js files containing an `export default` statement. const evaluateJsConfig = configFile => { diff --git a/lib/plugin-support/shared-worker-loader.js b/lib/plugin-support/shared-worker-loader.js new file mode 100644 index 0000000000..701fc532a2 --- /dev/null +++ b/lib/plugin-support/shared-worker-loader.js @@ -0,0 +1,251 @@ +const {EventEmitter, on} = require('events'); +const {workerData, parentPort} = require('worker_threads'); // eslint-disable-line node/no-unsupported-features/node-builtins +const pkg = require('../../package.json'); + +// Used to forward messages received over the `parentPort`. Every subscription +// adds a listener, so do not enforce any maximums. +const events = new EventEmitter().setMaxListeners(0); + +// Map of active test workers, used in receiveMessages() to get a reference to +// the TestWorker instance, and relevant release functions. +const activeTestWorkers = new Map(); + +class TestWorker { + constructor(id, file) { + this.id = id; + this.file = file; + } + + defer(fn) { + let released = false; + const release = async () => { + if (released) { + return; + } + + released = true; + if (activeTestWorkers.has(this.id)) { + activeTestWorkers.get(this.id).releaseFns.delete(release); + } + + await fn(); + }; + + activeTestWorkers.get(this.id).releaseFns.add(release); + + return release; + } + + publish(data) { + return publishMessage(this, data); + } + + async * subscribe() { + yield * receiveMessages(this); + } +} + +class ReceivedMessage { + constructor(testWorker, id, data) { + this.testWorker = testWorker; + this.id = id; + this.data = data; + } + + reply(data) { + return publishMessage(this.testWorker, data, this.id); + } +} + +// Ensure that, no matter how often it's received, we have a stable message +// object. +const messageCache = new WeakMap(); + +async function * receiveMessages(fromTestWorker, replyTo) { + for await (const [message] of on(events, 'message')) { + if (fromTestWorker !== undefined) { + if (message.type === 'deregister-test-worker' && message.id === fromTestWorker.id) { + return; + } + + if (message.type === 'message' && message.testWorkerId !== fromTestWorker.id) { + continue; + } + } + + if (message.type !== 'message') { + continue; + } + + if (replyTo === undefined && message.replyTo !== undefined) { + continue; + } + + if (replyTo !== undefined && message.replyTo !== replyTo) { + continue; + } + + const active = activeTestWorkers.get(message.testWorkerId); + // It is possible for a message to have been buffering for so long β€” perhaps + // due to the caller waiting before iterating to the next message β€” that the + // test worker has been deregistered. Ignore such messages. + // + // (This is really hard to write a test for, however!) + if (active === undefined) { + continue; + } + + let received = messageCache.get(message); + if (received === undefined) { + received = new ReceivedMessage(active.instance, message.messageId, message.data); + messageCache.set(message, received); + } + + yield received; + } +} + +let messageCounter = 0; +const messageIdPrefix = `${workerData.id}/message`; +const nextMessageId = () => `${messageIdPrefix}/${++messageCounter}`; + +function publishMessage(testWorker, data, replyTo) { + const id = nextMessageId(); + parentPort.postMessage({ + type: 'message', + messageId: id, + testWorkerId: testWorker.id, + data, + replyTo + }); + + return { + id, + async * replies() { + yield * receiveMessages(testWorker, id); + } + }; +} + +function broadcastMessage(data) { + const id = nextMessageId(); + parentPort.postMessage({ + type: 'broadcast', + messageId: id, + data + }); + + return { + id, + async * replies() { + yield * receiveMessages(undefined, id); + } + }; +} + +async function loadFactory() { + try { + const mod = require(workerData.filename); + if (typeof mod === 'function') { + return mod; + } + + return mod.default; + } catch (error) { + if (error && (error.code === 'ERR_REQUIRE_ESM' || (error.code === 'MODULE_NOT_FOUND' && workerData.filename.startsWith('file://')))) { + const {default: factory} = await import(workerData.filename); // eslint-disable-line node/no-unsupported-features/es-syntax + return factory; + } + + throw error; + } +} + +let signalAvailable = () => { + parentPort.postMessage({type: 'available'}); + signalAvailable = () => {}; +}; + +let fatal; +loadFactory(workerData.filename).then(factory => { + if (typeof factory !== 'function') { + throw new TypeError(`Missing default factory function export for shared worker plugin at ${workerData.filename}`); + } + + factory({ + negotiateProtocol(supported) { + if (!supported.includes('experimental')) { + fatal = new Error(`This version of AVA (${pkg.version}) is not compatible with shared worker plugin at ${workerData.filename}`); + throw fatal; + } + + const produceTestWorker = instance => events.emit('testWorker', instance); + + parentPort.on('message', async message => { + if (message.type === 'register-test-worker') { + const {id, file} = message; + const instance = new TestWorker(id, file); + + activeTestWorkers.set(id, {instance, releaseFns: new Set()}); + + produceTestWorker(instance); + } + + if (message.type === 'deregister-test-worker') { + const {id} = message; + const {releaseFns} = activeTestWorkers.get(id); + activeTestWorkers.delete(id); + + // Run possibly asynchronous release functions serially, in reverse + // order. Any error will crash the worker. + for await (const fn of [...releaseFns].reverse()) { + await fn(); + } + + parentPort.postMessage({ + type: 'deregistered-test-worker', + id + }); + } + + // Wait for a turn of the event loop, to allow new subscriptions to be + // set up in response to the previous message. + setImmediate(() => events.emit('message', message)); + }); + + return { + initialData: workerData.initialData, + protocol: 'experimental', + + ready() { + signalAvailable(); + return this; + }, + + broadcast(data) { + return broadcastMessage(data); + }, + + async * subscribe() { + yield * receiveMessages(); + }, + + async * testWorkers() { + for await (const [worker] of on(events, 'testWorker')) { + yield worker; + } + } + }; + } + }); +}).catch(error => { + if (fatal === undefined) { + fatal = error; + } +}).finally(() => { + if (fatal !== undefined) { + process.nextTick(() => { + throw fatal; + }); + } +}); diff --git a/lib/plugin-support/shared-workers.js b/lib/plugin-support/shared-workers.js new file mode 100644 index 0000000000..8bc1785721 --- /dev/null +++ b/lib/plugin-support/shared-workers.js @@ -0,0 +1,138 @@ +const events = require('events'); +const serializeError = require('../serialize-error'); + +let Worker; +try { + ({Worker} = require('worker_threads')); // eslint-disable-line node/no-unsupported-features/node-builtins +} catch {} + +const LOADER = require.resolve('./shared-worker-loader'); + +let sharedWorkerCounter = 0; +const launchedWorkers = new Map(); + +const waitForAvailable = async worker => { + for await (const [message] of events.on(worker, 'message')) { + if (message.type === 'available') { + return; + } + } +}; + +function launchWorker({filename, initialData}) { + if (launchedWorkers.has(filename)) { + return launchedWorkers.get(filename); + } + + const id = `shared-worker/${++sharedWorkerCounter}`; + const worker = new Worker(LOADER, { + // Ensure the worker crashes for unhandled rejections, rather than allowing undefined behavior. + execArgv: ['--unhandled-rejections=strict'], + workerData: { + filename, + id, + initialData + } + }); + const launched = { + statePromises: { + available: waitForAvailable(worker), + error: events.once(worker, 'error').then(([error]) => error) // eslint-disable-line promise/prefer-await-to-then + }, + exited: false, + worker + }; + + launchedWorkers.set(filename, launched); + worker.once('exit', () => { + launched.exited = true; + }); + + return launched; +} + +async function observeWorkerProcess(fork, runStatus) { + let registrationCount = 0; + let signalDeregistered; + const deregistered = new Promise(resolve => { + signalDeregistered = resolve; + }); + + fork.promise.finally(() => { + if (registrationCount === 0) { + signalDeregistered(); + } + }); + + fork.onConnectSharedWorker(async channel => { + const launched = launchWorker(channel); + + const handleChannelMessage = ({messageId, replyTo, data}) => { + launched.worker.postMessage({ + type: 'message', + testWorkerId: fork.forkId, + messageId, + replyTo, + data + }); + }; + + const handleWorkerMessage = async message => { + if (message.type === 'broadcast' || (message.type === 'message' && message.testWorkerId === fork.forkId)) { + const {messageId, replyTo, data} = message; + channel.forwardMessageToFork({messageId, replyTo, data}); + } + + if (message.type === 'deregistered-test-worker' && message.id === fork.forkId) { + launched.worker.off('message', handleWorkerMessage); + + registrationCount--; + if (registrationCount === 0) { + signalDeregistered(); + } + } + }; + + launched.statePromises.error.then(error => { // eslint-disable-line promise/prefer-await-to-then + signalDeregistered(); + launched.worker.off('message', handleWorkerMessage); + runStatus.emitStateChange({type: 'shared-worker-error', err: serializeError('Shared worker error', true, error)}); + channel.signalError(); + }); + + try { + await launched.statePromises.available; + + registrationCount++; + launched.worker.postMessage({ + type: 'register-test-worker', + id: fork.forkId, + file: fork.file + }); + + fork.promise.finally(() => { + launched.worker.postMessage({ + type: 'deregister-test-worker', + id: fork.forkId + }); + + channel.off('message', handleChannelMessage); + }); + + launched.worker.on('message', handleWorkerMessage); + channel.on('message', handleChannelMessage); + channel.signalReady(); + } catch { + return; + } finally { + // Attaching listeners has the side-effect of referencing the worker. + // Explicitly unreference it now so it does not prevent the main process + // from exiting. + launched.worker.unref(); + } + }); + + return deregistered; +} + +exports.observeWorkerProcess = observeWorkerProcess; diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 6aa86bb79d..01e14c8655 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -195,6 +195,7 @@ class Reporter { this.internalErrors = []; this.knownFailures = []; this.lineNumberErrors = []; + this.sharedWorkerErrors = []; this.uncaughtExceptions = []; this.unhandledRejections = []; this.unsavedSnapshots = []; @@ -340,6 +341,19 @@ class Reporter { break; } + case 'shared-worker-error': { + this.sharedWorkerErrors.push(event); + + if (this.verbose) { + this.lineWriter.ensureEmptyLine(); + this.lineWriter.writeLine(colors.error(`${figures.cross} Error in shared worker`)); + this.lineWriter.writeLine(); + this.writeErr(event); + } + + break; + } + case 'snapshot-error': this.unsavedSnapshots.push(event); break; @@ -710,7 +724,7 @@ class Reporter { } if (this.failures.length > 0) { - const writeTrailingLines = this.internalErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0; + const writeTrailingLines = this.internalErrors.length > 0 || this.sharedWorkerErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0; const lastFailure = this.failures[this.failures.length - 1]; for (const event of this.failures) { @@ -734,7 +748,7 @@ class Reporter { if (!this.verbose) { if (this.internalErrors.length > 0) { - const writeTrailingLines = this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0; + const writeTrailingLines = this.sharedWorkerErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0; const last = this.internalErrors[this.internalErrors.length - 1]; for (const event of this.internalErrors) { @@ -756,6 +770,23 @@ class Reporter { } } + if (this.sharedWorkerErrors.length > 0) { + const writeTrailingLines = this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0; + + const last = this.sharedWorkerErrors[this.sharedWorkerErrors.length - 1]; + for (const evt of this.sharedWorkerErrors) { + this.lineWriter.writeLine(colors.error(`${figures.cross} Error in shared worker`)); + this.lineWriter.writeLine(); + this.writeErr(evt.err); + if (evt !== last || writeTrailingLines) { + this.lineWriter.writeLine(); + this.lineWriter.writeLine(); + } + + wroteSomething = true; + } + } + if (this.uncaughtExceptions.length > 0) { const writeTrailingLines = this.unhandledRejections.length > 0; diff --git a/lib/run-status.js b/lib/run-status.js index 5726da58cb..35bbdcbb37 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -27,6 +27,7 @@ class RunStatus extends Emittery { passedKnownFailingTests: 0, passedTests: 0, selectedTests: 0, + sharedWorkerErrors: 0, skippedTests: 0, timeouts: 0, todoTests: 0, @@ -93,6 +94,9 @@ class RunStatus extends Emittery { this.addPendingTest(event); } + break; + case 'shared-worker-error': + stats.sharedWorkerErrors++; break; case 'test-failed': stats.failedTests++; @@ -164,6 +168,7 @@ class RunStatus extends Emittery { this.stats.failedHooks > 0 || this.stats.failedTests > 0 || this.stats.failedWorkers > 0 || + this.stats.sharedWorkerErrors > 0 || this.stats.timeouts > 0 || this.stats.uncaughtExceptions > 0 || this.stats.unhandledRejections > 0 diff --git a/lib/runner.js b/lib/runner.js index dbb076b930..37c7211abe 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -42,6 +42,7 @@ class Runner extends Emittery { serial: [], todo: [] }; + this.waitForReady = []; const uniqueTestTitles = new Set(); this.registerUniqueTitle = title => { @@ -444,6 +445,8 @@ class Runner extends Emittery { }); } + await Promise.all(this.waitForReady); + if (concurrentTests.length === 0 && serialTests.length === 0) { this.emit('finish'); // Don't run any hooks if there are no tests to run. diff --git a/lib/worker/ipc.js b/lib/worker/ipc.js index 0f172c6c90..78fe4c30ae 100644 --- a/lib/worker/ipc.js +++ b/lib/worker/ipc.js @@ -1,30 +1,13 @@ 'use strict'; -const Emittery = require('emittery'); +const events = require('events'); +const pEvent = require('p-event'); const {controlFlow} = require('../ipc-flow-control'); +const {get: getOptions} = require('./options'); -const emitter = new Emittery(); -process.on('message', message => { - if (!message.ava) { - return; - } - - switch (message.ava.type) { - case 'options': - emitter.emit('options', message.ava.options); - break; - case 'peer-failed': - emitter.emit('peerFailed'); - break; - case 'pong': - emitter.emit('pong'); - break; - default: - break; - } -}); +const selectAvaMessage = type => message => message.ava && message.ava.type === type; -exports.options = emitter.once('options'); -exports.peerFailed = emitter.once('peerFailed'); +exports.options = pEvent(process, 'message', selectAvaMessage('options')).then(message => message.ava.options); +exports.peerFailed = pEvent(process, 'message', selectAvaMessage('peer-failed')); const bufferedSend = controlFlow(process); function send(evt) { @@ -33,18 +16,27 @@ function send(evt) { exports.send = send; +let refs = 1; +function ref() { + if (++refs === 1) { + process.channel.ref(); + } +} + function unref() { - process.channel.unref(); + if (refs > 0 && --refs === 0) { + process.channel.unref(); + } } exports.unref = unref; let pendingPings = Promise.resolve(); async function flush() { - process.channel.ref(); + ref(); const promise = pendingPings.then(async () => { // eslint-disable-line promise/prefer-await-to-then send({type: 'ping'}); - await emitter.once('pong'); + await pEvent(process, 'message', selectAvaMessage('pong')); if (promise === pendingPings) { unref(); } @@ -54,3 +46,156 @@ async function flush() { } exports.flush = flush; + +let channelCounter = 0; +let messageCounter = 0; + +const channelEmitters = new Map(); +function createChannelEmitter(channelId) { + if (channelEmitters.size === 0) { + process.on('message', message => { + if (!message.ava) { + return; + } + + const {channelId, type, ...payload} = message.ava; + if ( + type === 'shared-worker-error' || + type === 'shared-worker-message' || + type === 'shared-worker-ready' + ) { + const emitter = channelEmitters.get(channelId); + if (emitter !== undefined) { + emitter.emit(type, payload); + } + } + }); + } + + const emitter = new events.EventEmitter(); + channelEmitters.set(channelId, emitter); + return [emitter, () => channelEmitters.delete(channelId)]; +} + +function registerSharedWorker(filename, initialData) { + const channelId = `${getOptions().forkId}/channel/${++channelCounter}`; + const [channelEmitter, unsubscribe] = createChannelEmitter(channelId); + + let forcedUnref = false; + let refs = 0; + const forceUnref = () => { + if (forcedUnref) { + return; + } + + forcedUnref = true; + if (refs > 0) { + unref(); + } + }; + + const refChannel = () => { + if (!forcedUnref && ++refs === 1) { + ref(); + } + }; + + const unrefChannel = () => { + if (!forcedUnref && refs > 0 && --refs === 0) { + unref(); + } + }; + + send({ + type: 'shared-worker-connect', + channelId, + filename, + initialData + }); + + let currentlyAvailable = false; + let error = null; + + refChannel(); + const ready = pEvent(channelEmitter, 'shared-worker-ready').then(() => { // eslint-disable-line promise/prefer-await-to-then + currentlyAvailable = error === null; + }).finally(unrefChannel); + + const messageEmitters = new Set(); + const handleMessage = message => { + // Wait for a turn of the event loop, to allow new subscriptions to be set + // up in response to the previous message. + setImmediate(() => { + for (const emitter of messageEmitters) { + emitter.emit('message', message); + } + }); + }; + + channelEmitter.on('shared-worker-message', handleMessage); + + pEvent(channelEmitter, 'shared-worker-error').then(() => { // eslint-disable-line promise/prefer-await-to-then + unsubscribe(); + forceUnref(); + + error = new Error('The shared worker is no longer available'); + currentlyAvailable = false; + for (const emitter of messageEmitters) { + emitter.emit('error', error); + } + }); + + return { + forceUnref, + ready, + channel: { + available: ready, + + get currentlyAvailable() { + return currentlyAvailable; + }, + + async * receive() { + if (error !== null) { + throw error; + } + + const emitter = new events.EventEmitter(); + messageEmitters.add(emitter); + try { + refChannel(); + for await (const [message] of events.on(emitter, 'message')) { + yield message; + } + } finally { + unrefChannel(); + messageEmitters.delete(emitter); + } + }, + + post(data, replyTo) { + if (error !== null) { + throw error; + } + + if (!currentlyAvailable) { + throw new Error('Shared worker is not yet available'); + } + + const messageId = `${channelId}/message/${++messageCounter}`; + send({ + type: 'shared-worker-message', + channelId, + messageId, + replyTo, + data + }); + + return messageId; + } + } + }; +} + +exports.registerSharedWorker = registerSharedWorker; + diff --git a/lib/worker/plugin.js b/lib/worker/plugin.js new file mode 100644 index 0000000000..e3a033b53b --- /dev/null +++ b/lib/worker/plugin.js @@ -0,0 +1,120 @@ +const pkg = require('../../package.json'); +const subprocess = require('./subprocess'); +const options = require('./options'); + +const workers = new Map(); +const workerTeardownFns = new WeakMap(); + +function createSharedWorker(filename, initialData, teardown) { + const channel = subprocess.registerSharedWorker(filename, initialData, teardown); + + class ReceivedMessage { + constructor(id, data) { + this.id = id; + this.data = data; + } + + reply(data) { + return publishMessage(data, this.id); + } + } + + // Ensure that, no matter how often it's received, we have a stable message + // object. + const messageCache = new WeakMap(); + async function * receiveMessages(replyTo) { + for await (const evt of channel.receive()) { + if (replyTo === undefined && evt.replyTo !== undefined) { + continue; + } + + if (replyTo !== undefined && evt.replyTo !== replyTo) { + continue; + } + + let message = messageCache.get(evt); + if (message === undefined) { + message = new ReceivedMessage(evt.messageId, evt.data); + messageCache.set(evt, message); + } + + yield message; + } + } + + function publishMessage(data, replyTo) { + const id = channel.post(data, replyTo); + + return { + id, + async * replies() { + yield * receiveMessages(id); + } + }; + } + + return { + available: channel.available, + protocol: 'experimental', + + get currentlyAvailable() { + return channel.currentlyAvailable; + }, + + publish(data) { + return publishMessage(data); + }, + + async * subscribe() { + yield * receiveMessages(); + } + }; +} + +const supportsSharedWorkers = process.versions.node >= '12.17.0'; + +function registerSharedWorker({ + filename, + initialData, + supportedProtocols, + teardown +}) { + if (!options.get().experiments.sharedWorkers) { + throw new Error('Shared workers are experimental. Opt in to them in your AVA configuration'); + } + + if (!supportsSharedWorkers) { + throw new Error('Shared workers require Node.js 12.17 or newer'); + } + + if (!supportedProtocols.includes('experimental')) { + throw new Error(`This version of AVA (${pkg.version}) does not support any of the desired shared worker protocols: ${supportedProtocols.join()}`); + } + + let worker = workers.get(filename); + if (worker === undefined) { + worker = createSharedWorker(filename, initialData, async () => { + // Run possibly asynchronous teardown functions serially, in reverse + // order. Any error will crash the worker. + const teardownFns = workerTeardownFns.get(worker); + if (teardownFns !== undefined) { + for await (const fn of [...teardownFns].reverse()) { + await fn(); + } + } + }); + workers.set(filename, worker); + } + + if (teardown !== undefined) { + if (workerTeardownFns.has(worker)) { + workerTeardownFns.get(worker).push(teardown); + } else { + workerTeardownFns.set(worker, [teardown]); + } + } + + return worker; +} + +exports.registerSharedWorker = registerSharedWorker; diff --git a/lib/worker/subprocess.js b/lib/worker/subprocess.js index f84508c4c2..abd9aabab7 100644 --- a/lib/worker/subprocess.js +++ b/lib/worker/subprocess.js @@ -32,6 +32,8 @@ ipc.options.then(async options => { const dependencyTracking = require('./dependency-tracker'); const lineNumberSelection = require('./line-numbers'); + const sharedWorkerTeardowns = []; + async function exit(code) { if (!process.exitCode) { process.exitCode = code; @@ -89,7 +91,7 @@ ipc.options.then(async options => { exit(1); }); - runner.on('finish', () => { + runner.on('finish', async () => { try { const {cannotSave, touchedFiles} = runner.saveSnapshotState(); if (cannotSave) { @@ -103,6 +105,14 @@ ipc.options.then(async options => { return; } + try { + await Promise.all(sharedWorkerTeardowns.map(fn => fn())); + } catch (error) { + ipc.send({type: 'uncaught-exception', err: serializeError('Shared worker teardown error', false, error, runner.file)}); + exit(1); + return; + } + nowAndTimers.setImmediate(() => { currentlyUnhandled() .filter(rejection => !attributedRejections.has(rejection.promise)) @@ -129,6 +139,19 @@ ipc.options.then(async options => { return runner; }; + exports.registerSharedWorker = (filename, initialData, teardown) => { + const {channel, forceUnref, ready} = ipc.registerSharedWorker(filename, initialData); + runner.waitForReady.push(ready); + sharedWorkerTeardowns.push(async () => { + try { + await teardown(); + } finally { + forceUnref(); + } + }); + return channel; + }; + // Store value to prevent required modules from modifying it. const testPath = options.file; diff --git a/package-lock.json b/package-lock.json index 9ae84a8d35..49b493000f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4798,6 +4798,12 @@ "istanbul-lib-report": "^3.0.0" } }, + "it-first": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/it-first/-/it-first-1.0.2.tgz", + "integrity": "sha512-hU5ObR14987PR7l0J7dfWAgKYiWoKbXcoXKqhQDGgHSZML6UPmHSS9ILBGucZkoA2B152kEqEOllS4tVQq11fg==", + "dev": true + }, "jackspeak": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-1.4.0.tgz", @@ -6266,7 +6272,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", - "dev": true, "requires": { "p-timeout": "^3.1.0" } @@ -6274,8 +6279,7 @@ "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, "p-limit": { "version": "2.3.0", @@ -6311,7 +6315,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, "requires": { "p-finally": "^1.0.0" } diff --git a/package.json b/package.json index 3d17e8526a..5472ef77d0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "lib", "*.js", "!*.config.js", - "index.d.ts" + "index.d.ts", + "*.d.ts" ], "keywords": [ "πŸ¦„", @@ -93,6 +94,7 @@ "mem": "^6.1.0", "ms": "^2.1.2", "ora": "^4.0.5", + "p-event": "^4.2.0", "p-map": "^4.0.0", "picomatch": "^2.2.2", "pkg-conf": "^3.1.0", @@ -122,7 +124,7 @@ "esm": "^3.2.25", "execa": "^4.0.3", "get-stream": "^5.1.0", - "p-event": "^4.2.0", + "it-first": "^1.0.2", "proxyquire": "^2.1.3", "react": "^16.13.1", "react-test-renderer": "^16.13.1", diff --git a/plugin.d.ts b/plugin.d.ts new file mode 100644 index 0000000000..5fe0658341 --- /dev/null +++ b/plugin.d.ts @@ -0,0 +1,81 @@ +export namespace SharedWorker { + export type ProtocolIdentifier = 'experimental'; + + /* eslint-disable @typescript-eslint/method-signature-style */ + export type FactoryOptions = { + negotiateProtocol (supported: readonly ['experimental']): Experimental.Protocol; + // Add overloads for additional protocols. + }; + /* eslint-enable @typescript-eslint/method-signature-style */ + + export type Factory = (options: FactoryOptions) => void; + + export namespace Experimental { + export type Protocol = { + readonly initialData: Data; + readonly protocol: 'experimental'; + broadcast: (data: Data) => BroadcastMessage; + ready: () => Protocol; + subscribe: () => AsyncIterableIterator>; + testWorkers: () => AsyncIterableIterator>; + }; + + export type BroadcastMessage = { + readonly id: string; + replies: () => AsyncIterableIterator>; + }; + + export type PublishedMessage = { + readonly id: string; + replies: () => AsyncIterableIterator>; + }; + + export type ReceivedMessage = { + readonly data: Data; + readonly id: string; + readonly testWorker: TestWorker; + reply: (data: Data) => PublishedMessage; + }; + + export type TestWorker = { + readonly id: string; + readonly file: string; + defer: void> (fn: ReleaseFn) => ReleaseFn; + publish: (data: Data) => PublishedMessage; + subscribe: () => AsyncIterableIterator>; + }; + } + + export namespace Plugin { + export type RegistrationOptions = { + readonly filename: string; + readonly initialData?: Data; + readonly supportedProtocols: readonly Identifier[]; + readonly teardown?: () => void; + }; + + export namespace Experimental { + export type Protocol = { + readonly available: Promise; + readonly currentlyAvailable: boolean; + readonly protocol: 'experimental'; + publish: (data: Data) => PublishedMessage; + subscribe: () => AsyncIterableIterator>; + }; + + export type PublishedMessage = { + readonly id: string; + replies: () => AsyncIterableIterator>; + }; + + export type ReceivedMessage = { + readonly data: Data; + readonly id: string; + reply: (data: Data) => PublishedMessage; + }; + } + } +} + +export function registerSharedWorker(options: SharedWorker.Plugin.RegistrationOptions<'experimental', Data>): SharedWorker.Plugin.Experimental.Protocol; +// Add overloads for additional protocols. diff --git a/plugin.js b/plugin.js new file mode 100644 index 0000000000..f8f4c10493 --- /dev/null +++ b/plugin.js @@ -0,0 +1,9 @@ +'use strict'; +const path = require('path'); + +// Ensure the same AVA install is loaded by the test file as by the test worker +if (process.env.AVA_PATH && process.env.AVA_PATH !== __dirname) { + module.exports = require(path.join(process.env.AVA_PATH, 'plugin')); +} else { + module.exports = require('./lib/worker/plugin'); +} diff --git a/test-d/plugin.ts b/test-d/plugin.ts new file mode 100644 index 0000000000..6690258e70 --- /dev/null +++ b/test-d/plugin.ts @@ -0,0 +1,8 @@ +import {expectType} from 'tsd'; +import * as plugin from '../plugin'; + +expectType(plugin.registerSharedWorker({filename: '', supportedProtocols: ['experimental']})); + +const factory: plugin.SharedWorker.Factory = ({negotiateProtocol}) => { + expectType(negotiateProtocol(['experimental'])); +}; diff --git a/test/helpers/exec.js b/test/helpers/exec.js index 8c9dec619b..401c3d68df 100644 --- a/test/helpers/exec.js +++ b/test/helpers/exec.js @@ -28,6 +28,16 @@ const compareStatObjects = (a, b) => { return 1; }; +const NO_FORWARD_PREFIX = Buffer.from('πŸ€—', 'utf8'); + +const forwardErrorOutput = async from => { + for await (const message of from) { + if (NO_FORWARD_PREFIX.compare(message, 0, 4) !== 0) { + process.stderr.write(message); + } + } +}; + exports.fixture = async (args, options = {}) => { const cwd = path.join(path.dirname(test.meta.file), 'fixtures'); const running = execa.node(cliPath, args, defaultsDeep({ @@ -42,19 +52,25 @@ exports.fixture = async (args, options = {}) => { // Besides buffering stderr, if this environment variable is set, also pipe // to stderr. This can be useful when debugging the tests. if (process.env.DEBUG_TEST_AVA) { - running.stderr.pipe(process.stderr); + // Running.stderr.pipe(process.stderr); + forwardErrorOutput(running.stderr); } const errors = new WeakMap(); + const logs = new WeakMap(); const stats = { failed: [], failedHooks: [], passed: [], + sharedWorkerErrors: [], skipped: [], uncaughtExceptions: [], unsavedSnapshots: [], getError(statObject) { return errors.get(statObject); + }, + getLogs(statObject) { + return logs.get(statObject); } }; @@ -81,6 +97,12 @@ exports.fixture = async (args, options = {}) => { break; } + case 'shared-worker-error': { + const {message, name, stack} = statusEvent.err; + stats.sharedWorkerErrors.push({message, name, stack}); + break; + } + case 'snapshot-error': { const {testFile} = statusEvent; stats.unsavedSnapshots.push({file: normalizePath(cwd, testFile)}); @@ -89,7 +111,9 @@ exports.fixture = async (args, options = {}) => { case 'test-passed': { const {title, testFile} = statusEvent; - stats.passed.push({title, file: normalizePath(cwd, testFile)}); + const statObject = {title, file: normalizePath(cwd, testFile)}; + stats.passed.push(statObject); + logs.set(statObject, statusEvent.logs); break; } diff --git a/test/shared-workers/cannot-publish-before-available/fixtures/_worker.js b/test/shared-workers/cannot-publish-before-available/fixtures/_worker.js new file mode 100644 index 0000000000..6a922a3799 --- /dev/null +++ b/test/shared-workers/cannot-publish-before-available/fixtures/_worker.js @@ -0,0 +1,3 @@ +module.exports = async ({negotiateProtocol}) => { + negotiateProtocol(['experimental']).ready(); +}; diff --git a/test/shared-workers/cannot-publish-before-available/fixtures/package.json b/test/shared-workers/cannot-publish-before-available/fixtures/package.json new file mode 100644 index 0000000000..e677ba9479 --- /dev/null +++ b/test/shared-workers/cannot-publish-before-available/fixtures/package.json @@ -0,0 +1,7 @@ +{ + "ava": { + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/cannot-publish-before-available/fixtures/test.js b/test/shared-workers/cannot-publish-before-available/fixtures/test.js new file mode 100644 index 0000000000..232965bfe7 --- /dev/null +++ b/test/shared-workers/cannot-publish-before-available/fixtures/test.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); + +test('cannot publish before ready', t => { + const worker = plugin.registerSharedWorker({ + filename: require.resolve('./_worker'), + supportedProtocols: ['experimental'] + }); + + t.throws(() => worker.publish(), {message: 'Shared worker is not yet available'}); +}); diff --git a/test/shared-workers/cannot-publish-before-available/snapshots/test.js.md b/test/shared-workers/cannot-publish-before-available/snapshots/test.js.md new file mode 100644 index 0000000000..ed28f9f1f9 --- /dev/null +++ b/test/shared-workers/cannot-publish-before-available/snapshots/test.js.md @@ -0,0 +1,16 @@ +# Snapshot report for `test/shared-workers/cannot-publish-before-available/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## shared worker plugins work + +> Snapshot 1 + + [ + { + file: 'test.js', + title: 'cannot publish before ready', + }, + ] diff --git a/test/shared-workers/cannot-publish-before-available/snapshots/test.js.snap b/test/shared-workers/cannot-publish-before-available/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..d88b3e6242b31c201978473e8dbaf66c0ce176c8 GIT binary patch literal 222 zcmZ<^b5sb+Uh+eM@cDn<8;g2)6biUHW=h^TnB&IhrY5V9%W~vUPfk { + const result = await exec.fixture(); + t.snapshot(result.stats.passed); +}); diff --git a/test/shared-workers/is-an-experiment/fixtures/package.json b/test/shared-workers/is-an-experiment/fixtures/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/shared-workers/is-an-experiment/fixtures/package.json @@ -0,0 +1 @@ +{} diff --git a/test/shared-workers/is-an-experiment/fixtures/test.js b/test/shared-workers/is-an-experiment/fixtures/test.js new file mode 100644 index 0000000000..9f6cd0dd53 --- /dev/null +++ b/test/shared-workers/is-an-experiment/fixtures/test.js @@ -0,0 +1,4 @@ +const plugin = require('ava/plugin'); +plugin.registerSharedWorker({ + supportedProtocols: ['experimental'] +}); diff --git a/test/shared-workers/is-an-experiment/snapshots/test.js.md b/test/shared-workers/is-an-experiment/snapshots/test.js.md new file mode 100644 index 0000000000..e264b32e4d --- /dev/null +++ b/test/shared-workers/is-an-experiment/snapshots/test.js.md @@ -0,0 +1,11 @@ +# Snapshot report for `test/shared-workers/is-an-experiment/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## opt-in is required + +> Snapshot 1 + + 'Shared workers are experimental. Opt in to them in your AVA configuration' diff --git a/test/shared-workers/is-an-experiment/snapshots/test.js.snap b/test/shared-workers/is-an-experiment/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..265ada949054d019f938146d2cb17b9b298fff9a GIT binary patch literal 162 zcmZ<^b5sb~vkJ$@TQ7>&=y}N4XsOoU&XU zvjWrdvf@0G`sOv&J9avy&2#eH=j7>@rPmQTGd^m`1BquVCd`>XecH^ps3{SXX3l44 M_+_=KtsH1E0IQuo-v9sr literal 0 HcmV?d00001 diff --git a/test/shared-workers/is-an-experiment/test.js b/test/shared-workers/is-an-experiment/test.js new file mode 100644 index 0000000000..9e66cb783b --- /dev/null +++ b/test/shared-workers/is-an-experiment/test.js @@ -0,0 +1,10 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('opt-in is required', async t => { + const result = await t.throwsAsync(exec.fixture()); + t.is(result.exitCode, 1); + t.is(result.stats.uncaughtExceptions.length, 1); + t.snapshot(result.stats.uncaughtExceptions[0].message); +}); + diff --git a/test/shared-workers/lifecycle/fixtures/_worker.js b/test/shared-workers/lifecycle/fixtures/_worker.js new file mode 100644 index 0000000000..56bbd6c582 --- /dev/null +++ b/test/shared-workers/lifecycle/fixtures/_worker.js @@ -0,0 +1,3 @@ +exports.default = ({negotiateProtocol}) => { + negotiateProtocol(['experimental']).ready(); +}; diff --git a/test/shared-workers/lifecycle/fixtures/available.js b/test/shared-workers/lifecycle/fixtures/available.js new file mode 100644 index 0000000000..40cf02228e --- /dev/null +++ b/test/shared-workers/lifecycle/fixtures/available.js @@ -0,0 +1,14 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); + +const worker = plugin.registerSharedWorker({ + filename: require.resolve('./_worker.js'), + supportedProtocols: ['experimental'] +}); + +const availableImmediately = worker.currentlyAvailable; + +test('the shared worker becomes available before tests start', t => { + t.false(availableImmediately); + t.true(worker.currentlyAvailable); +}); diff --git a/test/shared-workers/lifecycle/fixtures/package.json b/test/shared-workers/lifecycle/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/lifecycle/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/lifecycle/fixtures/teardown.js b/test/shared-workers/lifecycle/fixtures/teardown.js new file mode 100644 index 0000000000..ecb73b30df --- /dev/null +++ b/test/shared-workers/lifecycle/fixtures/teardown.js @@ -0,0 +1,25 @@ +const assert = require('assert'); +const test = require('ava'); +const plugin = require('ava/plugin'); + +let calledLast = false; +plugin.registerSharedWorker({ + filename: require.resolve('./_worker.js'), + supportedProtocols: ['experimental'], + teardown() { + assert(calledLast); + console.log('πŸ€—TEARDOWN CALLED'); + } +}); + +plugin.registerSharedWorker({ + filename: require.resolve('./_worker.js'), + supportedProtocols: ['experimental'], + teardown() { + calledLast = true; + } +}); + +test('pass', t => { + t.pass(); +}); diff --git a/test/shared-workers/lifecycle/test.js b/test/shared-workers/lifecycle/test.js new file mode 100644 index 0000000000..611580ce2f --- /dev/null +++ b/test/shared-workers/lifecycle/test.js @@ -0,0 +1,11 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('availability', async t => { + await t.notThrowsAsync(exec.fixture(['available.js'])); +}); + +test('teardown', async t => { + const result = await exec.fixture('teardown.js'); + t.true(result.stderr.includes('TEARDOWN CALLED')); +}); diff --git a/test/shared-workers/requires-newish-node/fixtures/_worker.js b/test/shared-workers/requires-newish-node/fixtures/_worker.js new file mode 100644 index 0000000000..6a922a3799 --- /dev/null +++ b/test/shared-workers/requires-newish-node/fixtures/_worker.js @@ -0,0 +1,3 @@ +module.exports = async ({negotiateProtocol}) => { + negotiateProtocol(['experimental']).ready(); +}; diff --git a/test/shared-workers/requires-newish-node/fixtures/package.json b/test/shared-workers/requires-newish-node/fixtures/package.json new file mode 100644 index 0000000000..e677ba9479 --- /dev/null +++ b/test/shared-workers/requires-newish-node/fixtures/package.json @@ -0,0 +1,7 @@ +{ + "ava": { + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/requires-newish-node/fixtures/test.js b/test/shared-workers/requires-newish-node/fixtures/test.js new file mode 100644 index 0000000000..a4859f3ee9 --- /dev/null +++ b/test/shared-workers/requires-newish-node/fixtures/test.js @@ -0,0 +1,10 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); +const {available} = plugin.registerSharedWorker({ + supportedProtocols: ['experimental'], + filename: require.resolve('./_worker') +}); + +test('worker becomes available', async t => { + await t.notThrowsAsync(available); +}); diff --git a/test/shared-workers/requires-newish-node/test.js b/test/shared-workers/requires-newish-node/test.js new file mode 100644 index 0000000000..2663dbb773 --- /dev/null +++ b/test/shared-workers/requires-newish-node/test.js @@ -0,0 +1,17 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('requires node.js >= 12.17', async t => { + const result = await exec.fixture().catch(error => error); + + t.log(result.stdout); + + if (process.versions.node >= '12.17.0') { + t.is(result.exitCode, 0); + } else { + t.is(result.exitCode, 1); + t.is(result.stats.uncaughtExceptions.length, 1); + // Don't snapshot since it can't easily be updated anyway. + t.is(result.stats.uncaughtExceptions[0].message, 'Shared workers require Node.js 12.17 or newer'); + } +}); diff --git a/test/shared-workers/supports-cjs-default-export/fixtures/_worker.js b/test/shared-workers/supports-cjs-default-export/fixtures/_worker.js new file mode 100644 index 0000000000..56bbd6c582 --- /dev/null +++ b/test/shared-workers/supports-cjs-default-export/fixtures/_worker.js @@ -0,0 +1,3 @@ +exports.default = ({negotiateProtocol}) => { + negotiateProtocol(['experimental']).ready(); +}; diff --git a/test/shared-workers/supports-cjs-default-export/fixtures/package.json b/test/shared-workers/supports-cjs-default-export/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/supports-cjs-default-export/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/supports-cjs-default-export/fixtures/test.js b/test/shared-workers/supports-cjs-default-export/fixtures/test.js new file mode 100644 index 0000000000..96ab50cf0d --- /dev/null +++ b/test/shared-workers/supports-cjs-default-export/fixtures/test.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); + +const worker = plugin.registerSharedWorker({ + filename: require.resolve('./_worker.js'), + supportedProtocols: ['experimental'] +}); + +test('the shared worker becomes available', async t => { + await t.notThrowsAsync(worker.available); +}); diff --git a/test/shared-workers/supports-cjs-default-export/test.js b/test/shared-workers/supports-cjs-default-export/test.js new file mode 100644 index 0000000000..b4744df0ba --- /dev/null +++ b/test/shared-workers/supports-cjs-default-export/test.js @@ -0,0 +1,6 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('can load ESM workers', async t => { + await t.notThrowsAsync(exec.fixture()); +}); diff --git a/test/shared-workers/supports-esm-workers/fixtures/_worker.mjs b/test/shared-workers/supports-esm-workers/fixtures/_worker.mjs new file mode 100644 index 0000000000..1c7f791b8a --- /dev/null +++ b/test/shared-workers/supports-esm-workers/fixtures/_worker.mjs @@ -0,0 +1,3 @@ +export default ({negotiateProtocol}) => { + negotiateProtocol(['experimental']).ready(); +}; diff --git a/test/shared-workers/supports-esm-workers/fixtures/file-url.js b/test/shared-workers/supports-esm-workers/fixtures/file-url.js new file mode 100644 index 0000000000..87aa78f2d7 --- /dev/null +++ b/test/shared-workers/supports-esm-workers/fixtures/file-url.js @@ -0,0 +1,12 @@ +const url = require('url'); +const test = require('ava'); +const plugin = require('ava/plugin'); + +const worker = plugin.registerSharedWorker({ + filename: url.pathToFileURL(require.resolve('./_worker.mjs')).toString(), + supportedProtocols: ['experimental'] +}); + +test('the shared worker becomes available', async t => { + await t.notThrowsAsync(worker.available); +}); diff --git a/test/shared-workers/supports-esm-workers/fixtures/package.json b/test/shared-workers/supports-esm-workers/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/supports-esm-workers/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/supports-esm-workers/fixtures/posix-path.js b/test/shared-workers/supports-esm-workers/fixtures/posix-path.js new file mode 100644 index 0000000000..c4f980fbd0 --- /dev/null +++ b/test/shared-workers/supports-esm-workers/fixtures/posix-path.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); + +const worker = plugin.registerSharedWorker({ + filename: require.resolve('./_worker.mjs'), + supportedProtocols: ['experimental'] +}); + +test('the shared worker becomes available', async t => { + await t.notThrowsAsync(worker.available); +}); diff --git a/test/shared-workers/supports-esm-workers/test.js b/test/shared-workers/supports-esm-workers/test.js new file mode 100644 index 0000000000..208e1745d3 --- /dev/null +++ b/test/shared-workers/supports-esm-workers/test.js @@ -0,0 +1,12 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('can load ESM workers from file URL', async t => { + await t.notThrowsAsync(exec.fixture(['file-url.js'])); +}); + +if (process.platform !== 'win32') { + test('can load ESM workers from absolute posix path', async t => { + await t.notThrowsAsync(exec.fixture(['posix-path.js'])); + }); +} diff --git a/test/shared-workers/unsupported-protocol/fixtures/_worker.js b/test/shared-workers/unsupported-protocol/fixtures/_worker.js new file mode 100644 index 0000000000..0c7c6c3c94 --- /dev/null +++ b/test/shared-workers/unsupported-protocol/fixtures/_worker.js @@ -0,0 +1,3 @@ +module.exports = async ({negotiateProtocol}) => { + negotiateProtocol(['πŸ™ˆ']).ready(); +}; diff --git a/test/shared-workers/unsupported-protocol/fixtures/in-shared-worker.js b/test/shared-workers/unsupported-protocol/fixtures/in-shared-worker.js new file mode 100644 index 0000000000..7c1acc5313 --- /dev/null +++ b/test/shared-workers/unsupported-protocol/fixtures/in-shared-worker.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); +plugin.registerSharedWorker({ + filename: require.resolve('./_worker'), + supportedProtocols: ['experimental'] +}); + +test('shared worker should cause tests to fail', t => { + t.fail(); +}); + diff --git a/test/shared-workers/unsupported-protocol/fixtures/in-test-worker.js b/test/shared-workers/unsupported-protocol/fixtures/in-test-worker.js new file mode 100644 index 0000000000..e45268cafd --- /dev/null +++ b/test/shared-workers/unsupported-protocol/fixtures/in-test-worker.js @@ -0,0 +1,6 @@ +const plugin = require('ava/plugin'); + +plugin.registerSharedWorker({ + supportedProtocols: ['πŸ™ˆ'], + filename: __filename +}); diff --git a/test/shared-workers/unsupported-protocol/fixtures/package.json b/test/shared-workers/unsupported-protocol/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/unsupported-protocol/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/unsupported-protocol/snapshots/test.js.md b/test/shared-workers/unsupported-protocol/snapshots/test.js.md new file mode 100644 index 0000000000..c3ce2a63b3 --- /dev/null +++ b/test/shared-workers/unsupported-protocol/snapshots/test.js.md @@ -0,0 +1,17 @@ +# Snapshot report for `test/shared-workers/unsupported-protocol/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## must negotiate a supported protocol in the shared worker + +> Snapshot 1 + + 'This version of AVA (VERSION) is not compatible with shared worker plugin at FILE' + +## must negotiate a supported protocol in the test worker + +> Snapshot 1 + + 'This version of AVA (VERSION) does not support any of the desired shared worker protocols: πŸ™ˆ' diff --git a/test/shared-workers/unsupported-protocol/snapshots/test.js.snap b/test/shared-workers/unsupported-protocol/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..b47ac99b1a22056efb4c1d273a4a23b7accba5e0 GIT binary patch literal 246 zcmV { + const result = await t.throwsAsync(exec.fixture(['in-test-worker.js'])); + const [uncaught] = result.stats.uncaughtExceptions; + t.snapshot(uncaught.message.replace(/\([^)]+\)/, '(VERSION)')); +}); + +test('must negotiate a supported protocol in the shared worker', async t => { + const result = await t.throwsAsync(exec.fixture(['in-shared-worker.js'])); + const [error] = result.stats.sharedWorkerErrors; + t.snapshot(error.message.replace(/\([^)]+\)/, '(VERSION)').replace(/(shared worker plugin at).+$/, '$1 FILE')); +}); diff --git a/test/shared-workers/worker-execution-crash/fixtures/_plugin.js b/test/shared-workers/worker-execution-crash/fixtures/_plugin.js new file mode 100644 index 0000000000..614a4146a7 --- /dev/null +++ b/test/shared-workers/worker-execution-crash/fixtures/_plugin.js @@ -0,0 +1,6 @@ +const plugin = require('ava/plugin'); + +module.exports = plugin.registerSharedWorker({ + filename: require.resolve('./_worker'), + supportedProtocols: ['experimental'] +}); diff --git a/test/shared-workers/worker-execution-crash/fixtures/_worker.js b/test/shared-workers/worker-execution-crash/fixtures/_worker.js new file mode 100644 index 0000000000..991848ab4c --- /dev/null +++ b/test/shared-workers/worker-execution-crash/fixtures/_worker.js @@ -0,0 +1,12 @@ +module.exports = async ({negotiateProtocol}) => { + const protocol = negotiateProtocol(['experimental']); + protocol.ready(); + + crash(protocol.subscribe()); +}; + +async function crash(messages) { + for await (const message of messages) { + throw new Error(message.data); + } +} diff --git a/test/shared-workers/worker-execution-crash/fixtures/package.json b/test/shared-workers/worker-execution-crash/fixtures/package.json new file mode 100644 index 0000000000..e677ba9479 --- /dev/null +++ b/test/shared-workers/worker-execution-crash/fixtures/package.json @@ -0,0 +1,7 @@ +{ + "ava": { + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/worker-execution-crash/fixtures/test.js b/test/shared-workers/worker-execution-crash/fixtures/test.js new file mode 100644 index 0000000000..5f6831958a --- /dev/null +++ b/test/shared-workers/worker-execution-crash/fixtures/test.js @@ -0,0 +1,10 @@ +const test = require('ava'); +const plugin = require('./_plugin'); + +test('shared workers crash', async t => { + const replies = plugin.publish('πŸ™ˆ').replies(); + await t.throwsAsync(replies.next(), {message: 'The shared worker is no longer available'}); + await t.throwsAsync(plugin.subscribe().next(), {message: 'The shared worker is no longer available'}); + t.false(plugin.currentlyAvailable); + t.throws(() => plugin.publish(), {message: 'The shared worker is no longer available'}); +}); diff --git a/test/shared-workers/worker-execution-crash/snapshots/test.js.md b/test/shared-workers/worker-execution-crash/snapshots/test.js.md new file mode 100644 index 0000000000..f4b74a5e79 --- /dev/null +++ b/test/shared-workers/worker-execution-crash/snapshots/test.js.md @@ -0,0 +1,16 @@ +# Snapshot report for `test/shared-workers/worker-execution-crash/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## shared worker plugins work + +> Snapshot 1 + + [ + { + file: 'test.js', + title: 'shared workers crash', + }, + ] diff --git a/test/shared-workers/worker-execution-crash/snapshots/test.js.snap b/test/shared-workers/worker-execution-crash/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..66f79d715499e771d499662964bd565af97848b8 GIT binary patch literal 215 zcmZ<^b5sbNNy*d9pBUheO?eQsC*3iJftgO$hEX=HIAHX}A zL)n;Fd9{MFcT}36R?p$MNl`J2rib<{Yul=_nPs8J5)0*x8m6ubZT4$eZ(R|gxKm@J P1v5kcNy)&+K-U5QNH|T? literal 0 HcmV?d00001 diff --git a/test/shared-workers/worker-execution-crash/test.js b/test/shared-workers/worker-execution-crash/test.js new file mode 100644 index 0000000000..21f9625251 --- /dev/null +++ b/test/shared-workers/worker-execution-crash/test.js @@ -0,0 +1,8 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('shared worker plugins work', async t => { + const result = await t.throwsAsync(exec.fixture()); + t.snapshot(result.stats.passed); + t.is(result.stats.sharedWorkerErrors[0].message, 'πŸ™ˆ'); +}); diff --git a/test/shared-workers/worker-protocol/fixtures/_declare.js b/test/shared-workers/worker-protocol/fixtures/_declare.js new file mode 100644 index 0000000000..1703a98a2d --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/_declare.js @@ -0,0 +1,54 @@ +/* eslint-disable ava/no-ignored-test-files */ +const crypto = require('crypto'); +const test = require('ava'); +const plugin = require('./_plugin'); + +module.exports = testFile => { + test('becomes available', async t => { + await t.notThrowsAsync(plugin.available); + }); + + test('replies', async t => { + const expected1 = new Uint8Array(crypto.randomBytes(16)); + const expected2 = new Uint8Array(crypto.randomBytes(16)); + for await (const reply1 of plugin.publish(expected1).replies()) { + t.deepEqual(reply1.data, expected1); + for await (const reply2 of reply1.reply(expected2).replies()) { + t.deepEqual(reply2.data, expected2); + return; + } + } + }); + + test('broadcasts', async t => { + for await (const message of plugin.subscribe()) { + if ('broadcast' in message.data && message.data.broadcast === testFile) { + const expected = new Uint8Array(crypto.randomBytes(16)); + for await (const reply of message.reply(expected).replies()) { + t.deepEqual(reply.data, expected); + return; + } + } + } + }); + + test('workers are registered', async t => { + for await (const message of plugin.subscribe()) { + if ('hello' in message.data) { + t.is(message.data.hello, testFile); + const expected = new Uint8Array(crypto.randomBytes(16)); + for await (const reply of plugin.publish(expected).replies()) { + t.deepEqual(reply.data, expected); + break; + } + + plugin.publish('πŸ‘‹'); + } + + if ('bye' in message.data && message.data.bye === testFile) { + t.is(message.data.byeCount, 1); + return; + } + } + }); +}; diff --git a/test/shared-workers/worker-protocol/fixtures/_plugin.js b/test/shared-workers/worker-protocol/fixtures/_plugin.js new file mode 100644 index 0000000000..614a4146a7 --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/_plugin.js @@ -0,0 +1,6 @@ +const plugin = require('ava/plugin'); + +module.exports = plugin.registerSharedWorker({ + filename: require.resolve('./_worker'), + supportedProtocols: ['experimental'] +}); diff --git a/test/shared-workers/worker-protocol/fixtures/_worker.js b/test/shared-workers/worker-protocol/fixtures/_worker.js new file mode 100644 index 0000000000..4471296b60 --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/_worker.js @@ -0,0 +1,50 @@ +module.exports = async ({negotiateProtocol}) => { + const protocol = negotiateProtocol(['experimental']); + + // When we're ready to receive workers or messages. + protocol.ready(); + // Calling it twice is harmless. + protocol.ready(); + + echo(protocol.subscribe()); + handleWorkers(protocol); +}; + +const handled = new WeakSet(); + +async function echo(messages) { + for await (const message of messages) { + if (!handled.has(message)) { + handled.add(message); + echo(message.reply(message.data).replies()); + } + } +} + +async function handleWorkers(protocol) { + for await (const testWorker of protocol.testWorkers()) { + testWorker.defer(() => { + protocol.broadcast({cleanup: testWorker.file}); + }); + + let byeCount = 0; + const bye = testWorker.defer(() => { + byeCount++; + setImmediate(() => { + protocol.broadcast({bye: testWorker.file, byeCount}); + }); + }); + + testWorker.publish({hello: testWorker.file}); + echo(protocol.broadcast({broadcast: testWorker.file}).replies()); + echo(testWorker.subscribe()); + + for await (const message of testWorker.subscribe()) { + if (message.data === 'πŸ‘‹') { + bye(); + bye(); // Second call is a no-op. + break; + } + } + } +} diff --git a/test/shared-workers/worker-protocol/fixtures/other.test.js b/test/shared-workers/worker-protocol/fixtures/other.test.js new file mode 100644 index 0000000000..74474e2881 --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/other.test.js @@ -0,0 +1 @@ +require('./_declare')(__filename); diff --git a/test/shared-workers/worker-protocol/fixtures/package.json b/test/shared-workers/worker-protocol/fixtures/package.json new file mode 100644 index 0000000000..e677ba9479 --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/package.json @@ -0,0 +1,7 @@ +{ + "ava": { + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/worker-protocol/fixtures/test.js b/test/shared-workers/worker-protocol/fixtures/test.js new file mode 100644 index 0000000000..ffd952641e --- /dev/null +++ b/test/shared-workers/worker-protocol/fixtures/test.js @@ -0,0 +1,14 @@ +const path = require('path'); +const test = require('ava'); +const plugin = require('./_plugin'); + +require('./_declare')(__filename); + +test('test workers are released when they exit', async t => { + for await (const message of plugin.subscribe()) { + if ('cleanup' in message.data) { + t.is(message.data.cleanup, path.resolve(__dirname, 'other.test.js')); + return; + } + } +}); diff --git a/test/shared-workers/worker-protocol/snapshots/test.js.md b/test/shared-workers/worker-protocol/snapshots/test.js.md new file mode 100644 index 0000000000..5e24da323e --- /dev/null +++ b/test/shared-workers/worker-protocol/snapshots/test.js.md @@ -0,0 +1,48 @@ +# Snapshot report for `test/shared-workers/worker-protocol/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## shared worker plugins work + +> Snapshot 1 + + [ + { + file: 'other.test.js', + title: 'becomes available', + }, + { + file: 'other.test.js', + title: 'broadcasts', + }, + { + file: 'other.test.js', + title: 'replies', + }, + { + file: 'other.test.js', + title: 'workers are registered', + }, + { + file: 'test.js', + title: 'becomes available', + }, + { + file: 'test.js', + title: 'broadcasts', + }, + { + file: 'test.js', + title: 'replies', + }, + { + file: 'test.js', + title: 'test workers are released when they exit', + }, + { + file: 'test.js', + title: 'workers are registered', + }, + ] diff --git a/test/shared-workers/worker-protocol/snapshots/test.js.snap b/test/shared-workers/worker-protocol/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..733b1304877a21add607e74a1390b38f0b88e30c GIT binary patch literal 469 zcmV;`0V@7MRzVR$cJ?MdPM>RXjF z^`tIiHuJUfAs&3fy8#wJ2MF*J?0{e3033r4+^cYRR)}bu^PZJTCB_5BhZ!H?{Pcb( z>_)x0)&R?LOY5XkTkChFj@DhJuGTZ9p4MBXzSe@JcB(dOPeEru$9mY1_qn-5Ey=A% zEtktW{f%TFT$rhyOaCGH53072I=OTXNgZTn>gLiTB!9qPGxc&Qa~Si$gkz+BE^Q$B z3VxXBP%b@0as)2TbU2q*@KKC`Y1c@D=V?)2WeJTns%{eh+F6Vf>isCDvP+^SDU-FO z%qU$M%cr!`q*l5@Sr~0ZaS}BX8tC|-DblFjiZYoEbgnOGEs1HcLqBe&VwnWaLQp8E z6K9eHwO?Puf3K77Yw(-SxUY%*pKf2{##IzPKiq_(jN0L5msUeu&~`{$u^iBPFWloD L7Gl!Dj|Bh#*d^gG literal 0 HcmV?d00001 diff --git a/test/shared-workers/worker-protocol/test.js b/test/shared-workers/worker-protocol/test.js new file mode 100644 index 0000000000..47b0fc7d38 --- /dev/null +++ b/test/shared-workers/worker-protocol/test.js @@ -0,0 +1,7 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('shared worker plugins work', async t => { + const result = await exec.fixture(); + t.snapshot(result.stats.passed); +}); diff --git a/test/shared-workers/worker-startup-crashes/fixtures/_factory-function.js b/test/shared-workers/worker-startup-crashes/fixtures/_factory-function.js new file mode 100644 index 0000000000..8fb5d8ac47 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/_factory-function.js @@ -0,0 +1,3 @@ +module.exports = () => { + throw new Error('πŸ™ˆ'); +}; diff --git a/test/shared-workers/worker-startup-crashes/fixtures/_module.js b/test/shared-workers/worker-startup-crashes/fixtures/_module.js new file mode 100644 index 0000000000..d5dca3b6ee --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/_module.js @@ -0,0 +1 @@ +throw new Error('πŸ™Š'); diff --git a/test/shared-workers/worker-startup-crashes/fixtures/_no-factory-function.js b/test/shared-workers/worker-startup-crashes/fixtures/_no-factory-function.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/shared-workers/worker-startup-crashes/fixtures/factory-function.js b/test/shared-workers/worker-startup-crashes/fixtures/factory-function.js new file mode 100644 index 0000000000..48987b6357 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/factory-function.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); +plugin.registerSharedWorker({ + filename: require.resolve('./_factory-function'), + supportedProtocols: ['experimental'] +}); + +test('shared worker should cause tests to fail', t => { + t.fail(); +}); + diff --git a/test/shared-workers/worker-startup-crashes/fixtures/module.js b/test/shared-workers/worker-startup-crashes/fixtures/module.js new file mode 100644 index 0000000000..15a44e101e --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/module.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); +plugin.registerSharedWorker({ + filename: require.resolve('./_module'), + supportedProtocols: ['experimental'] +}); + +test('shared worker should cause tests to fail', t => { + t.fail(); +}); + diff --git a/test/shared-workers/worker-startup-crashes/fixtures/no-factory-function.js b/test/shared-workers/worker-startup-crashes/fixtures/no-factory-function.js new file mode 100644 index 0000000000..260353f921 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/no-factory-function.js @@ -0,0 +1,11 @@ +const test = require('ava'); +const plugin = require('ava/plugin'); +plugin.registerSharedWorker({ + filename: require.resolve('./_no-factory-function'), + supportedProtocols: ['experimental'] +}); + +test('shared worker should cause tests to fail', t => { + t.fail(); +}); + diff --git a/test/shared-workers/worker-startup-crashes/fixtures/package.json b/test/shared-workers/worker-startup-crashes/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/worker-startup-crashes/snapshots/test.js.md b/test/shared-workers/worker-startup-crashes/snapshots/test.js.md new file mode 100644 index 0000000000..c191033de1 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/snapshots/test.js.md @@ -0,0 +1,11 @@ +# Snapshot report for `test/shared-workers/worker-startup-crashes/test.js` + +The actual snapshot is saved in `test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## handles crashes in when there is no factory function + +> Snapshot 1 + + 'Missing default factory function export for shared worker plugin at FILE' diff --git a/test/shared-workers/worker-startup-crashes/snapshots/test.js.snap b/test/shared-workers/worker-startup-crashes/snapshots/test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..b947c2c6033b3e1c2a352a394077bfbcc4adc554 GIT binary patch literal 164 zcmZ<^b5sbP{qW1V1t&3=!C7RnnnOkEe+?ANf~>akE^xrwc@zv4!hgJ&-v O7iE~TX|rt^&}slGxIaAr literal 0 HcmV?d00001 diff --git a/test/shared-workers/worker-startup-crashes/test.js b/test/shared-workers/worker-startup-crashes/test.js new file mode 100644 index 0000000000..e3a12059a3 --- /dev/null +++ b/test/shared-workers/worker-startup-crashes/test.js @@ -0,0 +1,20 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('handles crashes in factory function', async t => { + const result = await t.throwsAsync(exec.fixture(['factory-function.js'])); + const [error] = result.stats.sharedWorkerErrors; + t.is(error.message, 'πŸ™ˆ'); +}); + +test('handles crashes in when there is no factory function', async t => { + const result = await t.throwsAsync(exec.fixture(['no-factory-function.js'])); + const [error] = result.stats.sharedWorkerErrors; + t.snapshot(error.message.replace(/(shared worker plugin at).+$/, '$1 FILE')); +}); + +test('handles crashes in loading worker module', async t => { + const result = await t.throwsAsync(exec.fixture(['module.js'])); + const [error] = result.stats.sharedWorkerErrors; + t.is(error.message, 'πŸ™Š'); +}); diff --git a/test/shared-workers/workers-are-loaded-once/fixtures/_plugin.js b/test/shared-workers/workers-are-loaded-once/fixtures/_plugin.js new file mode 100644 index 0000000000..4a513868e4 --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/fixtures/_plugin.js @@ -0,0 +1,9 @@ +const plugin = require('ava/plugin'); +const itFirst = require('it-first'); + +const worker = plugin.registerSharedWorker({ + filename: require.resolve('./_worker'), + supportedProtocols: ['experimental'] +}); + +exports.random = itFirst(worker.subscribe()); diff --git a/test/shared-workers/workers-are-loaded-once/fixtures/_worker.js b/test/shared-workers/workers-are-loaded-once/fixtures/_worker.js new file mode 100644 index 0000000000..9e6683bd52 --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/fixtures/_worker.js @@ -0,0 +1,10 @@ +const crypto = require('crypto'); + +module.exports = async ({negotiateProtocol}) => { + const protocol = negotiateProtocol(['experimental']).ready(); + + const random = crypto.randomBytes(16).toString('hex'); + for await (const testWorker of protocol.testWorkers()) { + testWorker.publish({random}); + } +}; diff --git a/test/shared-workers/workers-are-loaded-once/fixtures/package.json b/test/shared-workers/workers-are-loaded-once/fixtures/package.json new file mode 100644 index 0000000000..2eb5f5a646 --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "ava": { + "files": [ + "*" + ], + "nonSemVerExperiments": { + "sharedWorkers": true + } + } +} diff --git a/test/shared-workers/workers-are-loaded-once/fixtures/test-1.js b/test/shared-workers/workers-are-loaded-once/fixtures/test-1.js new file mode 100644 index 0000000000..ab3f260b6b --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/fixtures/test-1.js @@ -0,0 +1,8 @@ +const test = require('ava'); +const {random} = require('./_plugin'); + +test('the shared worker produces a random value', async t => { + const {data} = await random; + t.log(data); + t.pass(); +}); diff --git a/test/shared-workers/workers-are-loaded-once/fixtures/test-2.js b/test/shared-workers/workers-are-loaded-once/fixtures/test-2.js new file mode 100644 index 0000000000..ab3f260b6b --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/fixtures/test-2.js @@ -0,0 +1,8 @@ +const test = require('ava'); +const {random} = require('./_plugin'); + +test('the shared worker produces a random value', async t => { + const {data} = await random; + t.log(data); + t.pass(); +}); diff --git a/test/shared-workers/workers-are-loaded-once/test.js b/test/shared-workers/workers-are-loaded-once/test.js new file mode 100644 index 0000000000..2179389576 --- /dev/null +++ b/test/shared-workers/workers-are-loaded-once/test.js @@ -0,0 +1,9 @@ +const test = require('@ava/test'); +const exec = require('../../helpers/exec'); + +test('shared workers are loaded only once', async t => { + const result = await exec.fixture(); + const logs = result.stats.passed.map(object => result.stats.getLogs(object)); + t.is(logs.length, 2); + t.deepEqual(logs[0], logs[1]); +}); diff --git a/xo.config.js b/xo.config.js index 75eb391c7e..075150c627 100644 --- a/xo.config.js +++ b/xo.config.js @@ -15,7 +15,7 @@ module.exports = { }, overrides: [ { - files: '*.d.ts', + files: 'index.d.ts', rules: { '@typescript-eslint/member-ordering': 'off', '@typescript-eslint/method-signature-style': 'off', @@ -46,7 +46,8 @@ module.exports = { { files: ['test-tap/fixture/**/*.js', 'test/**/fixtures/**/*.js'], rules: { - 'import/no-extraneous-dependencies': 'off' + 'import/no-extraneous-dependencies': 'off', + 'import/no-unresolved': 'off' } } ],