Skip to content

Commit

Permalink
feat!: make web-worker implementation more compatible with spec (#2431)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sheremet-va committed Dec 16, 2022
1 parent 084e929 commit c3a6352
Show file tree
Hide file tree
Showing 22 changed files with 719 additions and 189 deletions.
2 changes: 1 addition & 1 deletion packages/vite-node/src/client.ts
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
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
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
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
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
})
}
}

0 comments on commit c3a6352

Please sign in to comment.