From c3a63524c71b7e9f212b542c907c4be1c7584ae1 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 16 Dec 2022 16:03:52 +0100 Subject: [PATCH] feat!: make web-worker implementation more compatible with spec (#2431) * fix: make web-worker implementation more compatible with spec * chore: cleanup * test: add more tests for web-worker * chore: debug messageerror in web-worker * chore: relax requirements for web worker * chore: update lockfile * feat(web-worker): refactor into small peaces, add SharedWorker support * chore: update lockfile * chore: cleanup * chore: merge with main * chore: fix reference error --- packages/vite-node/src/client.ts | 2 +- packages/vitest/src/runtime/error.ts | 6 +- packages/vitest/src/utils/base.ts | 4 - packages/web-worker/README.md | 41 ++++- packages/web-worker/package.json | 4 + packages/web-worker/pure.d.ts | 9 +- packages/web-worker/rollup.config.js | 2 + packages/web-worker/src/index.ts | 4 +- packages/web-worker/src/pure.ts | 173 ++-------------------- packages/web-worker/src/runner.ts | 18 +++ packages/web-worker/src/shared-worker.ts | 136 +++++++++++++++++ packages/web-worker/src/types.ts | 19 +++ packages/web-worker/src/utils.ts | 80 ++++++++++ packages/web-worker/src/worker.ts | 136 +++++++++++++++++ pnpm-lock.yaml | 31 ++-- test/web-worker/src/objectWorker.ts | 3 + test/web-worker/src/sharedWorker.ts | 10 ++ test/web-worker/test/clone.test.ts | 136 +++++++++++++++++ test/web-worker/test/init.test.ts | 13 ++ test/web-worker/test/postMessage.test.ts | 11 ++ test/web-worker/test/sharedWorker.spec.ts | 66 +++++++++ test/web-worker/vitest.config.ts | 4 + 22 files changed, 719 insertions(+), 189 deletions(-) create mode 100644 packages/web-worker/src/runner.ts create mode 100644 packages/web-worker/src/shared-worker.ts create mode 100644 packages/web-worker/src/types.ts create mode 100644 packages/web-worker/src/utils.ts create mode 100644 packages/web-worker/src/worker.ts create mode 100644 test/web-worker/src/objectWorker.ts create mode 100644 test/web-worker/src/sharedWorker.ts create mode 100644 test/web-worker/test/clone.test.ts create mode 100644 test/web-worker/test/postMessage.test.ts create mode 100644 test/web-worker/test/sharedWorker.spec.ts diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts index d43dc93c116d..00a2e1176271 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -278,7 +278,7 @@ export class ViteNodeRunner { set: (_, p, value) => { // treat "module.exports =" the same as "exports.default =" to not have nested "default.default", // so "exports.default" becomes the actual module - if (p === 'default' && this.shouldInterop(url, { default: value })) { + if (p === 'default' && this.shouldInterop(modulePath, { default: value })) { exportAll(cjsExports, value) exports.default = value return true diff --git a/packages/vitest/src/runtime/error.ts b/packages/vitest/src/runtime/error.ts index 6417ca86c7cf..8ea6a4c7f802 100644 --- a/packages/vitest/src/runtime/error.ts +++ b/packages/vitest/src/runtime/error.ts @@ -136,13 +136,13 @@ function isReplaceable(obj1: any, obj2: any) { return obj1Type === obj2Type && obj1Type === 'Object' } -export function replaceAsymmetricMatcher(actual: any, expected: any, actualReplaced = new WeakMap(), expectedReplaced = new WeakMap()) { +export function replaceAsymmetricMatcher(actual: any, expected: any, actualReplaced = new WeakSet(), expectedReplaced = new WeakSet()) { if (!isReplaceable(actual, expected)) return { replacedActual: actual, replacedExpected: expected } if (actualReplaced.has(actual) || expectedReplaced.has(expected)) return { replacedActual: actual, replacedExpected: expected } - actualReplaced.set(actual, true) - expectedReplaced.set(expected, true) + actualReplaced.add(actual) + expectedReplaced.add(expected) ChaiUtil.getOwnEnumerableProperties(expected).forEach((key) => { const expectedValue = expected[key] const actualValue = actual[key] diff --git a/packages/vitest/src/utils/base.ts b/packages/vitest/src/utils/base.ts index bfe92430b3de..fe8765cbffb8 100644 --- a/packages/vitest/src/utils/base.ts +++ b/packages/vitest/src/utils/base.ts @@ -37,10 +37,6 @@ export function slash(str: string) { return str.replace(/\\/g, '/') } -export function mergeSlashes(str: string) { - return str.replace(/\/\//g, '/') -} - export const noop = () => { } export function getType(value: unknown): string { diff --git a/packages/web-worker/README.md b/packages/web-worker/README.md index 326a6f4f1c9c..7ab25887c67b 100644 --- a/packages/web-worker/README.md +++ b/packages/web-worker/README.md @@ -2,7 +2,14 @@ > Web Worker support for Vitest testing. Doesn't require JSDom. -Simulates Web Worker, but in the same thread. Supports both `new Worker(url)` and `import from './worker?worker`. +Simulates Web Worker, but in the same thread. + +Supported: + +- `new Worker(path)` +- `new SharedWorker(path)` +- `import MyWorker from './worker?worker'` +- `import MySharedWorker from './worker?sharedworker'` ## Installing @@ -33,18 +40,36 @@ export default defineConfig({ }) ``` +You can also import `defineWebWorkers` from `@vitest/web-worker/pure` to defined workers, whenever you need: + +```js +import { defineWebWorkers } from '@vitest/web-worker/pure' + +if (process.env.SUPPORT_WORKERS) + defineWebWorkers({ clone: 'none' }) +``` + +It accepts options: + +- `clone`: `'native' | 'ponyfill' | 'none'`. Defines how should `Worker` clone message, when transferring data. Applies only to `Worker` communication. `SharedWorker` uses `MessageChannel` from Node's `worker_threads` module, and is not configurable. + +> **Note** +> Requires Node 17, if you want to use native `structuredClone`. Otherwise, it fallbacks to [polyfill](https://github.com/ungap/structured-clone), if not specified as `none`. You can also configure this option with `VITEST_WEB_WORKER_CLONE` environmental variable. + ## Examples ```ts // worker.ts -import '@vitest/web-worker' -import MyWorker from '../worker?worker' - self.onmessage = (e) => { self.postMessage(`${e.data} world`) } +``` +```ts // worker.test.ts +import '@vitest/web-worker' +import MyWorker from '../worker?worker' + let worker = new MyWorker() // new Worker is also supported worker = new Worker(new URL('../src/worker.ts', import.meta.url)) @@ -55,6 +80,10 @@ worker.onmessage = (e) => { } ``` -## Notice +## Notes -- Does not support `onmessage = () => {}`. Please, use `self.onmessage = () => {}`. +- Worker does not support `onmessage = () => {}`. Please, use `self.onmessage = () => {}`. +- Shared worker does not support `onconnect = () => {}`. Please, use `self.onconnect = () => {}`. +- Transferring Buffer will not change its `byteLength`. +- You have access to shared global space as your tests. +- You can debug your worker, using `DEBUG=vitest:web-worker` environmental variable. diff --git a/packages/web-worker/package.json b/packages/web-worker/package.json index cf4e7025133d..9d354054b207 100644 --- a/packages/web-worker/package.json +++ b/packages/web-worker/package.json @@ -37,9 +37,13 @@ "vitest": "*" }, "dependencies": { + "debug": "^4.3.4", "vite-node": "workspace:*" }, "devDependencies": { + "@types/debug": "^4.1.7", + "@types/ungap__structured-clone": "^0.3.0", + "@ungap/structured-clone": "^1.0.1", "rollup": "^2.79.1" } } diff --git a/packages/web-worker/pure.d.ts b/packages/web-worker/pure.d.ts index b54eae9e47bf..ea13ccfd6c5b 100644 --- a/packages/web-worker/pure.d.ts +++ b/packages/web-worker/pure.d.ts @@ -1,3 +1,8 @@ -declare function defineWebWorker(): void; +type CloneOption = 'native' | 'ponyfill' | 'none'; +interface DefineWorkerOptions { + clone: CloneOption; +} -export { defineWebWorker }; +declare function defineWebWorkers(options?: DefineWorkerOptions): void; + +export { defineWebWorkers }; diff --git a/packages/web-worker/rollup.config.js b/packages/web-worker/rollup.config.js index 92cd65b7e548..92aee2ef6e24 100644 --- a/packages/web-worker/rollup.config.js +++ b/packages/web-worker/rollup.config.js @@ -1,6 +1,7 @@ import esbuild from 'rollup-plugin-esbuild' import dts from 'rollup-plugin-dts' import commonjs from '@rollup/plugin-commonjs' +import nodeResolve from '@rollup/plugin-node-resolve' import json from '@rollup/plugin-json' import alias from '@rollup/plugin-alias' import pkg from './package.json' @@ -25,6 +26,7 @@ const plugins = [ ], }), json(), + nodeResolve(), commonjs(), esbuild({ target: 'node14', diff --git a/packages/web-worker/src/index.ts b/packages/web-worker/src/index.ts index 0b401ea47e3f..6f7f32506ab5 100644 --- a/packages/web-worker/src/index.ts +++ b/packages/web-worker/src/index.ts @@ -1,3 +1,3 @@ -import { defineWebWorker } from './pure' +import { defineWebWorkers } from './pure' -defineWebWorker() +defineWebWorkers() diff --git a/packages/web-worker/src/pure.ts b/packages/web-worker/src/pure.ts index 1416c95be0f1..5c47233ab2aa 100644 --- a/packages/web-worker/src/pure.ts +++ b/packages/web-worker/src/pure.ts @@ -1,168 +1,19 @@ -/* eslint-disable no-restricted-imports */ -import { VitestRunner } from 'vitest/node' -import type { WorkerGlobalState } from 'vitest' +import { createWorkerConstructor } from './worker' +import type { DefineWorkerOptions } from './types' +import { assertGlobalExists } from './utils' +import { createSharedWorkerConstructor } from './shared-worker' -function getWorkerState(): WorkerGlobalState { - // @ts-expect-error untyped global - return globalThis.__vitest_worker__ -} - -type Procedure = (...args: any[]) => void - -class Bridge { - private callbacks: Record = {} - - public on(event: string, fn: Procedure) { - this.callbacks[event] ??= [] - this.callbacks[event].push(fn) - } - - public off(event: string, fn: Procedure) { - if (this.callbacks[event]) - this.callbacks[event] = this.callbacks[event].filter(f => f !== fn) - } - - public removeEvents(event: string) { - this.callbacks[event] = [] - } - - public clear() { - this.callbacks = {} - } - - public emit(event: string, ...data: any[]) { - return (this.callbacks[event] || []).map(fn => fn(...data)) - } -} - -interface InlineWorkerContext { - onmessage: Procedure | null - dispatchEvent: (e: Event) => void - addEventListener: (e: string, fn: Procedure) => void - removeEventListener: (e: string, fn: Procedure) => void - postMessage: (data: any) => void - self: InlineWorkerContext - global: InlineWorkerContext - importScripts?: any -} - -class InlineWorkerRunner extends VitestRunner { - constructor(options: any, private context: InlineWorkerContext) { - super(options) - } +export function defineWebWorkers(options?: DefineWorkerOptions) { + if (typeof Worker === 'undefined' || !('__VITEST_WEB_WORKER__' in globalThis.Worker)) { + assertGlobalExists('EventTarget') + assertGlobalExists('MessageEvent') - prepareContext(context: Record) { - const ctx = super.prepareContext(context) - // not supported for now - // need to be async - this.context.self.importScripts = () => {} - return Object.assign(ctx, this.context, { - importScripts: () => {}, - }) + globalThis.Worker = createWorkerConstructor(options) } -} - -export function defineWebWorker() { - if ('Worker' in globalThis) - return - - const { config, rpc, mockMap, moduleCache } = getWorkerState() - - const options = { - fetchModule(id: string) { - return rpc.fetch(id) - }, - resolveId(id: string, importer?: string) { - return rpc.resolveId(id, importer) - }, - moduleCache, - mockMap, - interopDefault: config.deps.interopDefault ?? true, - root: config.root, - base: config.base, - } - - globalThis.Worker = class Worker { - private inside = new Bridge() - private outside = new Bridge() - - private messageQueue: any[] | null = [] - - public onmessage: null | Procedure = null - public onmessageerror: null | Procedure = null - public onerror: null | Procedure = null - - constructor(url: URL | string) { - const context: InlineWorkerContext = { - onmessage: null, - dispatchEvent: (event: Event) => { - this.inside.emit(event.type, event) - return true - }, - addEventListener: this.inside.on.bind(this.inside), - removeEventListener: this.inside.off.bind(this.inside), - postMessage: (data) => { - this.outside.emit('message', { data }) - }, - get self() { - return context - }, - get global() { - return context - }, - } - - this.inside.on('message', (e) => { - context.onmessage?.(e) - }) - - this.outside.on('message', (e) => { - this.onmessage?.(e) - }) - - const runner = new InlineWorkerRunner(options, context) - - const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') - - runner.resolveUrl(id).then(([, fsPath]) => { - runner.executeFile(fsPath).then(() => { - // worker should be new every time, invalidate its sub dependency - moduleCache.invalidateSubDepTree([fsPath, runner.mocker.getMockPath(fsPath)]) - const q = this.messageQueue - this.messageQueue = null - if (q) - q.forEach(this.postMessage, this) - }).catch((e) => { - this.outside.emit('error', e) - this.onerror?.(e) - console.error(e) - }) - }) - } - - dispatchEvent(event: Event) { - this.outside.emit(event.type, event) - return true - } - - addEventListener(event: string, fn: Procedure) { - this.outside.on(event, fn) - } - - removeEventListener(event: string, fn: Procedure) { - this.outside.off(event, fn) - } - postMessage(data: any) { - if (this.messageQueue != null) - this.messageQueue.push(data) - else - this.inside.emit('message', { data }) - } + if (typeof SharedWorker === 'undefined' || !('__VITEST_WEB_WORKER__' in globalThis.SharedWorker)) { + assertGlobalExists('EventTarget') - terminate() { - this.outside.clear() - this.inside.clear() - } + globalThis.SharedWorker = createSharedWorkerConstructor() } } diff --git a/packages/web-worker/src/runner.ts b/packages/web-worker/src/runner.ts new file mode 100644 index 000000000000..d7e7be3373a9 --- /dev/null +++ b/packages/web-worker/src/runner.ts @@ -0,0 +1,18 @@ +import { VitestRunner } from 'vitest/node' + +export class InlineWorkerRunner extends VitestRunner { + constructor(options: any, private context: any) { + super(options) + } + + prepareContext(context: Record) { + const ctx = super.prepareContext(context) + // not supported for now, we can't synchronously load modules + const importScripts = () => { + throw new Error('[vitest] `importScripts` is not supported in Vite workers. Please, consider using `import` instead.') + } + return Object.assign(ctx, this.context, { + importScripts, + }) + } +} diff --git a/packages/web-worker/src/shared-worker.ts b/packages/web-worker/src/shared-worker.ts new file mode 100644 index 000000000000..2de074d21a41 --- /dev/null +++ b/packages/web-worker/src/shared-worker.ts @@ -0,0 +1,136 @@ +import { MessageChannel, type MessagePort as NodeMessagePort } from 'worker_threads' +import type { InlineWorkerContext, Procedure } from './types' +import { InlineWorkerRunner } from './runner' +import { debug, getRunnerOptions } from './utils' + +interface SharedInlineWorkerContext extends Omit { + onconnect: Procedure | null + self: SharedInlineWorkerContext + global: SharedInlineWorkerContext +} + +const convertNodePortToWebPort = (port: NodeMessagePort): MessagePort => { + if (!('addEventListener' in port)) { + Object.defineProperty(port, 'addEventListener', { + value(...args: any[]) { + return this.addListener(...args) + }, + configurable: true, + enumerable: true, + }) + } + if (!('removeEventListener' in port)) { + Object.defineProperty(port, 'removeEventListener', { + value(...args: any[]) { + return this.removeListener(...args) + }, + configurable: true, + enumerable: true, + }) + } + if (!('dispatchEvent' in port)) { + const emit = port.emit.bind(port) + Object.defineProperty(port, 'emit', { + value(event: any) { + if (event.name === 'message') + (port as any).onmessage?.(event) + if (event.name === 'messageerror') + (port as any).onmessageerror?.(event) + return emit(event) + }, + configurable: true, + enumerable: true, + }) + Object.defineProperty(port, 'dispatchEvent', { + value(event: any) { + return this.emit(event) + }, + configurable: true, + enumerable: true, + }) + } + return port as any as MessagePort +} + +export function createSharedWorkerConstructor(): typeof SharedWorker { + const runnerOptions = getRunnerOptions() + + return class SharedWorker extends EventTarget { + static __VITEST_WEB_WORKER__ = true + + private _vw_workerTarget = new EventTarget() + private _vw_name: string + private _vw_workerPort: MessagePort + + public onerror: null | Procedure = null + + public port: MessagePort + + constructor(url: URL | string, options?: WorkerOptions | string) { + super() + + const name = typeof options === 'string' ? options : options?.name + + // should be equal to SharedWorkerGlobalScope + const context: SharedInlineWorkerContext = { + onconnect: null, + name, + close: () => this.port.close(), + dispatchEvent: (event: Event) => { + return this._vw_workerTarget.dispatchEvent(event) + }, + addEventListener: (...args) => { + return this._vw_workerTarget.addEventListener(...args) + }, + removeEventListener: this._vw_workerTarget.removeEventListener, + get self() { + return context + }, + get global() { + return context + }, + } + + const channel = new MessageChannel() + this.port = convertNodePortToWebPort(channel.port1) + this._vw_workerPort = convertNodePortToWebPort(channel.port2) + + this._vw_workerTarget.addEventListener('connect', (e) => { + context.onconnect?.(e) + }) + + const runner = new InlineWorkerRunner(runnerOptions, context) + + const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') + + this._vw_name = id + + runner.resolveUrl(id).then(([, fsPath]) => { + this._vw_name = name ?? fsPath + + debug('initialize shared worker %s', this._vw_name) + + runner.executeFile(fsPath).then(() => { + // worker should be new every time, invalidate its sub dependency + runnerOptions.moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) + this._vw_workerTarget.dispatchEvent( + new MessageEvent('connect', { + ports: [this._vw_workerPort], + }), + ) + debug('shared worker %s successfully initialized', this._vw_name) + }).catch((e) => { + debug('shared worker %s failed to initialize: %o', this._vw_name, e) + const EventConstructor = globalThis.ErrorEvent || globalThis.Event + const error = new EventConstructor('error', { + error: e, + message: e.message, + }) + this.dispatchEvent(error) + this.onerror?.(error) + console.error(e) + }) + }) + } + } +} diff --git a/packages/web-worker/src/types.ts b/packages/web-worker/src/types.ts new file mode 100644 index 000000000000..7c665f5e8222 --- /dev/null +++ b/packages/web-worker/src/types.ts @@ -0,0 +1,19 @@ +export type Procedure = (...args: any[]) => void +export type CloneOption = 'native' | 'ponyfill' | 'none' + +export interface DefineWorkerOptions { + clone: CloneOption +} + +export interface InlineWorkerContext { + onmessage: Procedure | null + name?: string + close: () => void + dispatchEvent: (e: Event) => void + addEventListener: (e: string, fn: Procedure) => void + removeEventListener: (e: string, fn: Procedure) => void + postMessage: (data: any, transfer?: Transferable[] | StructuredSerializeOptions) => void + self: InlineWorkerContext + global: InlineWorkerContext + importScripts?: any +} diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts new file mode 100644 index 000000000000..5209412aed48 --- /dev/null +++ b/packages/web-worker/src/utils.ts @@ -0,0 +1,80 @@ +/* eslint-disable no-restricted-imports */ +import type { WorkerGlobalState } from 'vitest' +import ponyfillStructuredClone from '@ungap/structured-clone' +import createDebug from 'debug' +import type { CloneOption } from './types' + +export const debug = createDebug('vitest:web-worker') + +export function getWorkerState(): WorkerGlobalState { + // @ts-expect-error untyped global + return globalThis.__vitest_worker__ +} + +export function assertGlobalExists(name: string) { + if (!(name in globalThis)) + throw new Error(`[@vitest/web-worker] Cannot initiate a custom Web Worker. "${name}" is not supported in this environment. Please, consider using jsdom or happy-dom environment.`) +} + +function createClonedMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { + const transfer = Array.isArray(transferOrOptions) ? transferOrOptions : transferOrOptions?.transfer + + debug('clone worker message %o', data) + const origin = typeof location === 'undefined' ? undefined : location.origin + + if (typeof structuredClone === 'function' && clone === 'native') { + debug('create message event, using native structured clone') + return new MessageEvent('message', { + data: structuredClone(data, { transfer }), + origin, + }) + } + if (clone !== 'none') { + debug('create message event, using polifylled structured clone') + transfer?.length && console.warn( + '[@vitest/web-worker] `structuredClone` is not supported in this environment. ' + + 'Falling back to polyfill, your transferable options will be lost. ' + + 'Set `VITEST_WEB_WORKER_CLONE` environmental variable to "none", if you don\'t want to loose it,' + + 'or update to Node 17+.', + ) + return new MessageEvent('message', { + data: ponyfillStructuredClone(data, { lossy: true }), + origin, + }) + } + debug('create message event without cloning an object') + return new MessageEvent('message', { + data, + origin, + }) +} + +export function createMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { + try { + return createClonedMessageEvent(data, transferOrOptions, clone) + } + catch (error) { + debug('failed to clone message, dispatch "messageerror" event: %o', error) + return new MessageEvent('messageerror', { + data: error, + }) + } +} + +export function getRunnerOptions() { + const { config, rpc, mockMap, moduleCache } = getWorkerState() + + return { + fetchModule(id: string) { + return rpc.fetch(id) + }, + resolveId(id: string, importer?: string) { + return rpc.resolveId(id, importer) + }, + moduleCache, + mockMap, + interopDefault: config.deps.interopDefault ?? true, + root: config.root, + base: config.base, + } +} diff --git a/packages/web-worker/src/worker.ts b/packages/web-worker/src/worker.ts new file mode 100644 index 000000000000..07f38812d90f --- /dev/null +++ b/packages/web-worker/src/worker.ts @@ -0,0 +1,136 @@ +import type { CloneOption, DefineWorkerOptions, InlineWorkerContext, Procedure } from './types' +import { InlineWorkerRunner } from './runner' +import { createMessageEvent, debug, getRunnerOptions } from './utils' + +export function createWorkerConstructor(options?: DefineWorkerOptions): typeof Worker { + const runnerOptions = getRunnerOptions() + const cloneType = () => (options?.clone ?? process.env.VITEST_WEB_WORKER_CLONE ?? 'native') as CloneOption + + return class Worker extends EventTarget { + static __VITEST_WEB_WORKER__ = true + + private _vw_workerTarget = new EventTarget() + private _vw_insideListeners = new Map() + private _vw_outsideListeners = new Map() + private _vw_name: string + private _vw_messageQueue: any[] | null = [] + + public onmessage: null | Procedure = null + public onmessageerror: null | Procedure = null + public onerror: null | Procedure = null + + constructor(url: URL | string, options?: WorkerOptions) { + super() + + // should be equal to DedicatedWorkerGlobalScope + const context: InlineWorkerContext = { + onmessage: null, + name: options?.name, + close: () => this.terminate(), + dispatchEvent: (event: Event) => { + return this._vw_workerTarget.dispatchEvent(event) + }, + addEventListener: (...args) => { + if (args[1]) + this._vw_insideListeners.set(args[0], args[1]) + return this._vw_workerTarget.addEventListener(...args) + }, + removeEventListener: this._vw_workerTarget.removeEventListener, + postMessage: (...args) => { + if (!args.length) + throw new SyntaxError('"postMessage" requires at least one argument.') + + debug('posting message %o from the worker %s to the main thread', args[0], this._vw_name) + const event = createMessageEvent(args[0], args[1], cloneType()) + this.dispatchEvent(event) + }, + get self() { + return context + }, + get global() { + return context + }, + } + + this._vw_workerTarget.addEventListener('message', (e) => { + context.onmessage?.(e) + }) + + this.addEventListener('message', (e) => { + this.onmessage?.(e) + }) + + this.addEventListener('messageerror', (e) => { + this.onmessageerror?.(e) + }) + + const runner = new InlineWorkerRunner(runnerOptions, context) + + const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') + + this._vw_name = id + + runner.resolveUrl(id).then(([, fsPath]) => { + this._vw_name = options?.name ?? fsPath + + debug('initialize worker %s', this._vw_name) + + runner.executeFile(fsPath).then(() => { + // worker should be new every time, invalidate its sub dependency + runnerOptions.moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) + const q = this._vw_messageQueue + this._vw_messageQueue = null + if (q) + q.forEach(([data, transfer]) => this.postMessage(data, transfer), this) + debug('worker %s successfully initialized', this._vw_name) + }).catch((e) => { + debug('worker %s failed to initialize: %o', this._vw_name, e) + const EventConstructor = globalThis.ErrorEvent || globalThis.Event + const error = new EventConstructor('error', { + error: e, + message: e.message, + }) + this.dispatchEvent(error) + this.onerror?.(error) + console.error(e) + }) + }) + } + + addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void { + if (callback) + this._vw_outsideListeners.set(type, callback) + return super.addEventListener(type, callback, options) + } + + postMessage(...args: [any, StructuredSerializeOptions | Transferable[] | undefined]): void { + if (!args.length) + throw new SyntaxError('"postMessage" requires at least one argument.') + + const [data, transferOrOptions] = args + if (this._vw_messageQueue != null) { + debug('worker %s is not yet initialized, queue message %s', this._vw_name, data) + this._vw_messageQueue.push([data, transferOrOptions]) + return + } + + debug('posting message %o from the main thread to the worker %s', data, this._vw_name) + + const event = createMessageEvent(data, transferOrOptions, cloneType()) + if (event.type === 'messageerror') + this.dispatchEvent(event) + else + this._vw_workerTarget.dispatchEvent(event) + } + + terminate() { + debug('terminating worker %s', this._vw_name) + this._vw_outsideListeners.forEach((fn, type) => { + this.removeEventListener(type, fn) + }) + this._vw_insideListeners.forEach((fn, type) => { + this._vw_workerTarget.removeEventListener(type, fn) + }) + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94159cf4a101..686767d8ccf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -895,11 +895,19 @@ importers: packages/web-worker: specifiers: + '@types/debug': ^4.1.7 + '@types/ungap__structured-clone': ^0.3.0 + '@ungap/structured-clone': ^1.0.1 + debug: ^4.3.4 rollup: ^2.79.1 vite-node: workspace:* dependencies: + debug: 4.3.4 vite-node: link:../vite-node devDependencies: + '@types/debug': 4.1.7 + '@types/ungap__structured-clone': 0.3.0 + '@ungap/structured-clone': 1.0.1 rollup: 2.79.1 packages/ws-client: @@ -1842,11 +1850,6 @@ packages: resolution: {integrity: sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==} dev: true - /@babel/helper-plugin-utils/7.19.0: - resolution: {integrity: sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==} - engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-plugin-utils/7.20.2: resolution: {integrity: sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==} engines: {node: '>=6.9.0'} @@ -3476,7 +3479,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.18.13 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 dev: true /@babel/plugin-transform-react-jsx-source/7.19.6_@babel+core@7.19.6: @@ -3508,7 +3511,7 @@ packages: '@babel/core': 7.18.13 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 '@babel/types': 7.20.0 dev: true @@ -3522,7 +3525,7 @@ packages: '@babel/core': 7.18.13 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.18.13 '@babel/types': 7.20.0 dev: true @@ -3536,7 +3539,7 @@ packages: '@babel/core': 7.19.6 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.19.6 '@babel/types': 7.20.0 dev: true @@ -3550,7 +3553,7 @@ packages: '@babel/core': 7.20.5 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 '@babel/plugin-syntax-jsx': 7.18.6_@babel+core@7.20.5 '@babel/types': 7.20.0 dev: true @@ -7585,6 +7588,10 @@ packages: source-map: 0.6.1 dev: true + /@types/ungap__structured-clone/0.3.0: + resolution: {integrity: sha512-eBWREUhVUGPze+bUW22AgUr05k8u+vETzuYdLYSvWqGTUe0KOf+zVnOB1qER5wMcw8V6D9Ar4DfJmVvD1yu0kQ==} + dev: true + /@types/unist/2.0.6: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: true @@ -7778,6 +7785,10 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@ungap/structured-clone/1.0.1: + resolution: {integrity: sha512-zKVyTt6rELvPXYwcVPTJcPFtY0AckN5A7xWuc7owBqR0FdtuDYhE9MZZUi6IY1kZUQFSXV1B3UOOIyLkVHYd2w==} + dev: true + /@unocss/astro/0.47.6_rollup@2.79.1+vite@4.0.0: resolution: {integrity: sha512-8lR4KwuCeVxOTKk6g6hx6VUHhW1u+hki8oRsJaKEB0s5iUPmY6rCNtb/iaBJdceY11bZMMy5LZHJFTkod/T/zg==} dependencies: diff --git a/test/web-worker/src/objectWorker.ts b/test/web-worker/src/objectWorker.ts new file mode 100644 index 000000000000..706b6350bcaf --- /dev/null +++ b/test/web-worker/src/objectWorker.ts @@ -0,0 +1,3 @@ +self.onmessage = (e) => { + self.postMessage(e.data) +} diff --git a/test/web-worker/src/sharedWorker.ts b/test/web-worker/src/sharedWorker.ts new file mode 100644 index 000000000000..cbe67ecf637c --- /dev/null +++ b/test/web-worker/src/sharedWorker.ts @@ -0,0 +1,10 @@ +self.addEventListener('connect', (event) => { + const e = event as MessageEvent + const port = e.ports[0] + + port.onmessage = (e) => { + port.postMessage(e.data) + } + + port.start() +}) diff --git a/test/web-worker/test/clone.test.ts b/test/web-worker/test/clone.test.ts new file mode 100644 index 000000000000..90b239dc80ce --- /dev/null +++ b/test/web-worker/test/clone.test.ts @@ -0,0 +1,136 @@ +import { version } from 'process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MyWorker from '../src/objectWorker?worker' + +const major = Number(version.split('.')[0].slice(1)) + +describe.runIf(major >= 17)('when node supports structuredClone', () => { + it('uses native structure clone', () => { + expect.assertions(4) + + expect(structuredClone).toBeDefined() + + const worker = new MyWorker() + const buffer = new ArrayBuffer(1) + const obj = { hello: 'world', buffer } + worker.postMessage(obj, [buffer]) + + return new Promise((resolve, reject) => { + worker.onmessage = (e) => { + try { + expect(e).toBeInstanceOf(MessageEvent) + expect(e.data, 'doesn\'t keep reference').not.toBe(obj) + expect(e.data, 'shape is equal').toEqual(obj) + resolve() + } + catch (err) { + reject(err) + } + finally { + worker.terminate() + } + } + }) + }) + + it('throws error, if passing down unserializable data', () => { + expect.assertions(4) + + expect(structuredClone).toBeDefined() + + const worker = new MyWorker() + const obj = { hello: 'world', name() {} } + worker.postMessage(obj) + + return new Promise((resolve, reject) => { + worker.onmessageerror = (e) => { + try { + expect(e.type).toBe('messageerror') + expect(e).toBeInstanceOf(MessageEvent) + expect(e.data.message).toContain( + 'could not be cloned.', + ) + resolve() + } + catch (err) { + reject(err) + } + } + }) + }) +}) + +describe('when passing down custom clone', () => { + const { warn } = console + + beforeEach(() => { + console.warn = warn + process.env.VITEST_WEB_WORKER_CLONE = undefined + }) + + it('uses ponyfill clone', () => { + expect.assertions(4) + + console.warn = vi.fn() + process.env.VITEST_WEB_WORKER_CLONE = 'ponyfill' + + const worker = new MyWorker() + const buffer = new ArrayBuffer(1) + const obj = { hello: 'world' } + worker.postMessage(obj, [buffer]) + + return new Promise((resolve, reject) => { + worker.onmessageerror = (e) => { + reject(e.data) + } + worker.onmessage = (e) => { + try { + expect(e).toBeInstanceOf(MessageEvent) + expect(e.data, 'doesn\'t keep reference').not.toBe(obj) + expect(e.data, 'shape is not equal, don\'t transfer buffer').toEqual({ hello: 'world' }) + expect(console.warn).toBeCalledWith(expect.stringContaining('[@vitest/web-worker] `structuredClone` is not supported in this')) + resolve() + } + catch (err) { + reject(err) + } + finally { + worker.terminate() + } + } + }) + }) + + it('doesn\'t clone, if asked to', () => { + expect.assertions(3) + + console.warn = vi.fn() + process.env.VITEST_WEB_WORKER_CLONE = 'none' + + const worker = new MyWorker() + const buffer = new ArrayBuffer(1) + const obj = { hello: 'world', buffer } + worker.postMessage(obj, [buffer]) + + return new Promise((resolve, reject) => { + worker.onmessageerror = (e) => { + reject(e.data) + } + worker.onmessage = (e) => { + try { + expect(e).toBeInstanceOf(MessageEvent) + expect(e.data, 'keeps reference').toBe(obj) + expect(console.warn).not.toHaveBeenCalled() + resolve() + } + catch (err) { + reject(err) + } + finally { + worker.terminate() + } + } + }) + }) +}) diff --git a/test/web-worker/test/init.test.ts b/test/web-worker/test/init.test.ts index a22872f0c9c7..e6c1bbfd88e8 100644 --- a/test/web-worker/test/init.test.ts +++ b/test/web-worker/test/init.test.ts @@ -54,6 +54,19 @@ it('worker with url', async () => { await testWorker(new Worker(new URL('../src/worker.ts', url))) }) +it('worker with invalid url throws an error', async () => { + const url = import.meta.url + const worker = new Worker(new URL('../src/workerInvalid-path.ts', url)) + const event = await new Promise((resolve) => { + worker.onerror = (e) => { + resolve(e) + } + }) + expect(event).toBeInstanceOf(ErrorEvent) + expect(event.error).toBeInstanceOf(Error) + expect(event.error.message).toContain('Failed to load') +}) + it('self injected into worker and its deps should be equal', async () => { expect.assertions(4) expect(await testSelfWorker(new MySelfWorker())).toBeTruthy() diff --git a/test/web-worker/test/postMessage.test.ts b/test/web-worker/test/postMessage.test.ts new file mode 100644 index 000000000000..a4b082009012 --- /dev/null +++ b/test/web-worker/test/postMessage.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from 'vitest' +import MyWorker from '../src/worker?worker' + +it('throws syntax errorm if no arguments are provided', () => { + const worker = new MyWorker() + + // @ts-expect-error requires at least one argument + expect(() => worker.postMessage()).toThrowError(SyntaxError) + expect(() => worker.postMessage(undefined)).not.toThrowError() + expect(() => worker.postMessage(null)).not.toThrowError() +}) diff --git a/test/web-worker/test/sharedWorker.spec.ts b/test/web-worker/test/sharedWorker.spec.ts new file mode 100644 index 000000000000..993afbf50d8f --- /dev/null +++ b/test/web-worker/test/sharedWorker.spec.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest' +import MySharedWorker from './src/sharedWorker?sharedworker' + +const sendEventMessage = (worker: SharedWorker, msg: any) => { + worker.port.postMessage(msg) + return new Promise((resolve) => { + worker.port.addEventListener('message', function onmessage(e) { + worker.port.removeEventListener('message', onmessage) + resolve(e.data as string) + }) + }) +} + +const sendOnMessage = (worker: SharedWorker, msg: any) => { + worker.port.postMessage(msg) + return new Promise((resolve) => { + worker.port.onmessage = function onmessage(e) { + worker.port.onmessage = null + resolve(e.data as string) + } + }) +} + +it('vite shared worker works', async () => { + expect(MySharedWorker).toBeDefined() + expect(SharedWorker).toBeDefined() + const worker = new MySharedWorker() + expect(worker).toBeInstanceOf(SharedWorker) + + await expect(sendEventMessage(worker, 'event')).resolves.toBe('event') + await expect(sendOnMessage(worker, 'event')).resolves.toBe('event') +}) + +it('shared worker with path works', async () => { + expect(SharedWorker).toBeDefined() + const worker = new SharedWorker(new URL('../src/sharedWorker.ts', import.meta.url)) + expect(worker).toBeTruthy() + + await expect(sendEventMessage(worker, 'event')).resolves.toBe('event') + await expect(sendOnMessage(worker, 'event')).resolves.toBe('event') +}) + +it('throws an error on invalid path', async () => { + expect(SharedWorker).toBeDefined() + const worker = new SharedWorker('./some-invalid-path') + const event = await new Promise((resolve) => { + worker.onerror = (e) => { + resolve(e) + } + }) + expect(event).toBeInstanceOf(ErrorEvent) + expect(event.error).toBeInstanceOf(Error) + expect(event.error.message).toContain('Failed to load') +}) + +it('doesn\'t trigger events, if closed', async () => { + const worker = new MySharedWorker() + worker.port.close() + await new Promise((resolve) => { + worker.port.addEventListener('message', () => { + expect.fail('should not trigger message') + }) + worker.port.postMessage('event') + setTimeout(resolve, 100) + }) +}) diff --git a/test/web-worker/vitest.config.ts b/test/web-worker/vitest.config.ts index d5daed349443..69f70c696d4d 100644 --- a/test/web-worker/vitest.config.ts +++ b/test/web-worker/vitest.config.ts @@ -11,5 +11,9 @@ export default defineConfig({ /packages\/web-worker/, ], }, + onConsoleLog(log) { + if (log.includes('Failed to load')) + return false + }, }, })