Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: make web-worker implementation more compatible with spec #2431

Merged
merged 11 commits into from
Dec 16, 2022
2 changes: 1 addition & 1 deletion packages/vite-node/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/vitest/src/runtime/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 0 additions & 4 deletions packages/vitest/src/utils/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 35 additions & 6 deletions packages/web-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand All @@ -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.
4 changes: 4 additions & 0 deletions packages/web-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 7 additions & 2 deletions packages/web-worker/pure.d.ts
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 2 additions & 0 deletions packages/web-worker/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,6 +26,7 @@ const plugins = [
],
}),
json(),
nodeResolve(),
commonjs(),
esbuild({
target: 'node14',
Expand Down
4 changes: 2 additions & 2 deletions packages/web-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { defineWebWorker } from './pure'
import { defineWebWorkers } from './pure'

defineWebWorker()
defineWebWorkers()
173 changes: 12 additions & 161 deletions packages/web-worker/src/pure.ts
Original file line number Diff line number Diff line change
@@ -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<string, Procedure[]> = {}

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<string, any>) {
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()
}
}
18 changes: 18 additions & 0 deletions packages/web-worker/src/runner.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) {
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,
})
}
}
Loading