From 73753475f541da7cdfada8c0cf0b3d35bc967e0e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 17 Mar 2022 01:58:41 +0300 Subject: [PATCH] feat: web worker support (#726) Co-authored-by: Anthony Fu --- packages/vitest/src/integrations/jest-mock.ts | 5 +- .../src/integrations/snapshot/client.ts | 4 +- packages/vitest/src/integrations/vi.ts | 2 +- packages/vitest/src/node/index.ts | 3 + packages/vitest/src/runtime/context.ts | 5 +- packages/vitest/src/runtime/entry.ts | 6 +- .../vitest/src/{node => runtime}/execute.ts | 10 +- .../vitest/src/{node => runtime}/mocker.ts | 41 ++-- packages/vitest/src/runtime/rpc.ts | 4 +- packages/vitest/src/runtime/run.ts | 10 +- packages/vitest/src/runtime/setup.ts | 8 +- packages/vitest/src/runtime/worker.ts | 17 +- packages/vitest/src/types/mocker.ts | 8 + packages/vitest/src/types/worker.ts | 2 + packages/vitest/src/utils/global.ts | 6 + packages/vitest/src/utils/index.ts | 1 + packages/web-worker/README.md | 60 ++++++ packages/web-worker/index.d.ts | 1 + packages/web-worker/package.json | 37 ++++ packages/web-worker/pure.d.ts | 3 + packages/web-worker/rollup.config.js | 54 ++++++ packages/web-worker/src/index.ts | 3 + packages/web-worker/src/pure.ts | 177 ++++++++++++++++++ packages/web-worker/tsconfig.json | 4 + pnpm-lock.yaml | 36 +++- test/core/types/vite.d.ts | 19 ++ test/web-worker/package.json | 13 ++ test/web-worker/setup.ts | 1 + test/web-worker/src/worker.ts | 3 + test/web-worker/test/init.test.ts | 37 ++++ test/web-worker/vitest.config.ts | 15 ++ 31 files changed, 535 insertions(+), 60 deletions(-) rename packages/vitest/src/{node => runtime}/execute.ts (82%) rename packages/vitest/src/{node => runtime}/mocker.ts (88%) create mode 100644 packages/vitest/src/types/mocker.ts create mode 100644 packages/vitest/src/utils/global.ts create mode 100644 packages/web-worker/README.md create mode 100644 packages/web-worker/index.d.ts create mode 100644 packages/web-worker/package.json create mode 100644 packages/web-worker/pure.d.ts create mode 100644 packages/web-worker/rollup.config.js create mode 100644 packages/web-worker/src/index.ts create mode 100644 packages/web-worker/src/pure.ts create mode 100644 packages/web-worker/tsconfig.json create mode 100644 test/core/types/vite.d.ts create mode 100644 test/web-worker/package.json create mode 100644 test/web-worker/setup.ts create mode 100644 test/web-worker/src/worker.ts create mode 100644 test/web-worker/test/init.test.ts create mode 100644 test/web-worker/vitest.config.ts diff --git a/packages/vitest/src/integrations/jest-mock.ts b/packages/vitest/src/integrations/jest-mock.ts index 6ec15bdb8..8ace568e8 100644 --- a/packages/vitest/src/integrations/jest-mock.ts +++ b/packages/vitest/src/integrations/jest-mock.ts @@ -1,4 +1,3 @@ -import { util } from 'chai' import type { SpyImpl } from 'tinyspy' import * as tinyspy from 'tinyspy' @@ -228,7 +227,9 @@ function enhanceSpy( stub.mockRejectedValueOnce = (val: unknown) => stub.mockImplementationOnce(() => Promise.reject(val)) - util.addProperty(stub, 'mock', () => mockContext) + Object.defineProperty(stub, 'mock', { + get: () => mockContext, + }) stub.willCall(function(this: unknown, ...args) { instances.push(this) diff --git a/packages/vitest/src/integrations/snapshot/client.ts b/packages/vitest/src/integrations/snapshot/client.ts index 3ab414637..fc5b17575 100644 --- a/packages/vitest/src/integrations/snapshot/client.ts +++ b/packages/vitest/src/integrations/snapshot/client.ts @@ -2,7 +2,7 @@ import path from 'pathe' import { expect } from 'chai' import type { SnapshotResult, Test } from '../../types' import { rpc } from '../../runtime/rpc' -import { getNames } from '../../utils' +import { getNames, getWorkerState } from '../../utils' import { equals, iterableEquality, subsetEquality } from '../chai/jest-utils' import { deepMergeSnapshot } from './port/utils' import SnapshotState from './port/state' @@ -34,7 +34,7 @@ export class SnapshotClient { this.testFile = this.test!.file!.filepath this.snapshotState = new SnapshotState( resolveSnapshotPath(this.testFile), - __vitest_worker__!.config.snapshotOptions, + getWorkerState().config.snapshotOptions, ) } } diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 4c5e5363f..5a1953f35 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { parseStacktrace } from '../utils/source-map' -import type { VitestMocker } from '../node/mocker' +import type { VitestMocker } from '../runtime/mocker' import { FakeTimers } from './timers' import type { EnhancedSpy, MaybeMocked, MaybeMockedDeep } from './jest-mock' import { fn, isMockFunction, spies, spyOn } from './jest-mock' diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index 5a716ba40..dd2952893 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -2,3 +2,6 @@ export type { Vitest } from './core' export { createVitest } from './create' export { VitestPlugin } from './plugins' export { startVitest } from './cli-api' + +export { VitestRunner } from '../runtime/execute' +export type { ExecuteOptions } from '../runtime/execute' diff --git a/packages/vitest/src/runtime/context.ts b/packages/vitest/src/runtime/context.ts index ae34ec662..f8548f20d 100644 --- a/packages/vitest/src/runtime/context.ts +++ b/packages/vitest/src/runtime/context.ts @@ -1,4 +1,5 @@ import type { Awaitable, DoneCallback, RuntimeContext, SuiteCollector, TestFunction } from '../types' +import { getWorkerState } from '../utils' export const context: RuntimeContext = { tasks: [], @@ -17,11 +18,11 @@ export async function runWithSuite(suite: SuiteCollector, fn: (() => Awaitable any)>( diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/entry.ts index cad96f36d..11fcf3b86 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/entry.ts @@ -1,5 +1,6 @@ import { promises as fs } from 'fs' import type { BuiltinEnvironment, ResolvedConfig } from '../types' +import { getWorkerState } from '../utils' import { setupGlobalEnv, withEnv } from './setup' import { startTests } from './run' @@ -14,12 +15,13 @@ export async function run(files: string[], config: ResolvedConfig): Promise { await startTests([file], config) }) - __vitest_worker__.filepath = undefined + workerState.filepath = undefined } } diff --git a/packages/vitest/src/node/execute.ts b/packages/vitest/src/runtime/execute.ts similarity index 82% rename from packages/vitest/src/node/execute.ts rename to packages/vitest/src/runtime/execute.ts index 71cdcd9da..bf3ecf99b 100644 --- a/packages/vitest/src/node/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -1,15 +1,15 @@ import { ViteNodeRunner } from 'vite-node/client' import type { ModuleCache, ViteNodeRunnerOptions } from 'vite-node' import { normalizePath } from 'vite' -import type { SuiteMocks } from './mocker' +import type { SuiteMocks } from '../types/mocker' +import { getWorkerState } from '../utils' import { VitestMocker } from './mocker' export interface ExecuteOptions extends ViteNodeRunnerOptions { - files: string[] mockMap: SuiteMocks } -export async function executeInViteNode(options: ExecuteOptions) { +export async function executeInViteNode(options: ExecuteOptions & { files: string[] }) { const runner = new VitestRunner(options) // provide the vite define variable in this context @@ -40,8 +40,10 @@ export class VitestRunner extends ViteNodeRunner { this.setCache(dep, module) }) + const workerState = getWorkerState() + // support `import.meta.vitest` for test entry - if (__vitest_worker__.filepath && normalizePath(__vitest_worker__.filepath) === normalizePath(context.__filename)) { + if (workerState.filepath && normalizePath(workerState.filepath) === normalizePath(context.__filename)) { // @ts-expect-error injected untyped global Object.defineProperty(context.__vite_ssr_import_meta__, 'vitest', { get: () => globalThis.__vitest_index__ }) } diff --git a/packages/vitest/src/node/mocker.ts b/packages/vitest/src/runtime/mocker.ts similarity index 88% rename from packages/vitest/src/node/mocker.ts rename to packages/vitest/src/runtime/mocker.ts index 27ee35718..39686cacb 100644 --- a/packages/vitest/src/node/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -3,21 +3,13 @@ import { isNodeBuiltin } from 'mlly' import { basename, dirname, resolve } from 'pathe' import type { ModuleCache } from 'vite-node' import { toFilePath } from 'vite-node/utils' -import { isWindows, mergeSlashes, normalizeId } from '../utils' +import { getWorkerState, isWindows, mergeSlashes, normalizeId } from '../utils' import { distDir } from '../constants' +import type { PendingSuiteMock } from '../types/mocker' import type { ExecuteOptions } from './execute' -export type SuiteMocks = Record unknown)>> - type Callback = (...args: any[]) => unknown -interface PendingSuiteMock { - id: string - importer: string - type: 'mock' | 'unmock' - factory?: () => unknown -} - function getObjectType(value: unknown): string { return Object.prototype.toString.apply(value).slice(8, -1) } @@ -40,15 +32,14 @@ function mockPrototype(spyOn: typeof import('../integrations/jest-mock')['spyOn' return newProto } -const pendingIds: PendingSuiteMock[] = [] - export class VitestMocker { + private static pendingIds: PendingSuiteMock[] = [] + private static spyModule?: typeof import('../integrations/jest-mock') + private request!: (dep: string) => unknown private root: string - private callbacks: Record unknown)[]> = {} - private spy?: typeof import('../integrations/jest-mock') constructor( public options: ExecuteOptions, @@ -73,7 +64,7 @@ export class VitestMocker { } public getSuiteFilepath(): string { - return __vitest_worker__?.filepath || 'global' + return getWorkerState().filepath || 'global' } public getMocks() { @@ -96,7 +87,7 @@ export class VitestMocker { } private async resolveMocks() { - await Promise.all(pendingIds.map(async(mock) => { + await Promise.all(VitestMocker.pendingIds.map(async(mock) => { const { path, external } = await this.resolvePath(mock.id, mock.importer) if (mock.type === 'unmock') this.unmockPath(path) @@ -104,7 +95,7 @@ export class VitestMocker { this.mockPath(path, external, mock.factory) })) - pendingIds.length = 0 + VitestMocker.pendingIds = [] } private async callFunctionMock(dep: string, mock: () => any) { @@ -166,7 +157,7 @@ export class VitestMocker { } public mockObject(obj: any) { - if (!this.spy) + if (!VitestMocker.spyModule) throw new Error('Internal Vitest error: Spy function is not defined.') const type = getObjectType(obj) @@ -178,7 +169,7 @@ export class VitestMocker { const newObj = { ...obj } - const proto = mockPrototype(this.spy.spyOn, Object.getPrototypeOf(obj)) + const proto = mockPrototype(VitestMocker.spyModule.spyOn, Object.getPrototypeOf(obj)) Object.setPrototypeOf(newObj, proto) // eslint-disable-next-line no-restricted-syntax @@ -187,8 +178,8 @@ export class VitestMocker { const type = getObjectType(obj[k]) if (type.includes('Function') && !obj[k]._isMockFunction) { - this.spy.spyOn(newObj, k).mockImplementation(() => {}) - Object.defineProperty(newObj[k], 'length', { value: 0 }) // tinyspy retains length, but jest doesn't + VitestMocker.spyModule.spyOn(newObj, k).mockImplementation(() => {}) + Object.defineProperty(newObj[k], 'length', { value: 0 }) // tinyspy retains length, but jest doesnt } } return newObj @@ -239,8 +230,8 @@ export class VitestMocker { } private async ensureSpy() { - if (this.spy) return - this.spy = await this.request(resolve(distDir, 'jest-mock.js')) as typeof import('../integrations/jest-mock') + if (VitestMocker.spyModule) return + VitestMocker.spyModule = await this.request(resolve(distDir, 'jest-mock.js')) as typeof import('../integrations/jest-mock') } public async requestWithMock(dep: string) { @@ -268,11 +259,11 @@ export class VitestMocker { } public queueMock(id: string, importer: string, factory?: () => unknown) { - pendingIds.push({ type: 'mock', id, importer, factory }) + VitestMocker.pendingIds.push({ type: 'mock', id, importer, factory }) } public queueUnmock(id: string, importer: string) { - pendingIds.push({ type: 'unmock', id, importer }) + VitestMocker.pendingIds.push({ type: 'unmock', id, importer }) } public withRequest(request: (dep: string) => unknown) { diff --git a/packages/vitest/src/runtime/rpc.ts b/packages/vitest/src/runtime/rpc.ts index f466b3019..2ad6cf98a 100644 --- a/packages/vitest/src/runtime/rpc.ts +++ b/packages/vitest/src/runtime/rpc.ts @@ -1,3 +1,5 @@ +import { getWorkerState } from '../utils' + export const rpc = () => { - return __vitest_worker__!.rpc + return getWorkerState().rpc } diff --git a/packages/vitest/src/runtime/run.ts b/packages/vitest/src/runtime/run.ts index 4726f6508..3499b32fd 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -2,7 +2,7 @@ import { performance } from 'perf_hooks' import type { File, HookListener, ResolvedConfig, Suite, SuiteHooks, Task, TaskResult, TaskState, Test } from '../types' import { vi } from '../integrations/vi' import { getSnapshotClient } from '../integrations/snapshot/chai' -import { getFullName, hasFailed, hasTests, partitionSuiteChildren } from '../utils' +import { getFullName, getWorkerState, hasFailed, hasTests, partitionSuiteChildren } from '../utils' import { getState, setState } from '../integrations/chai/jest-expect' import { takeCoverage } from '../integrations/coverage' import { getFn, getHooks } from './map' @@ -79,7 +79,9 @@ export async function runTest(test: Test) { getSnapshotClient().setTest(test) - __vitest_worker__.current = test + const workerState = getWorkerState() + + workerState.current = test try { await callSuiteHook(test.suite, test, 'beforeEach', [test, test.suite]) @@ -130,7 +132,7 @@ export async function runTest(test: Test) { test.result.duration = performance.now() - start - __vitest_worker__.current = undefined + workerState.current = undefined updateTask(test) } @@ -241,7 +243,7 @@ export async function startTests(paths: string[], config: ResolvedConfig) { } export function clearModuleMocks() { - const { clearMocks, mockReset, restoreMocks } = __vitest_worker__.config + const { clearMocks, mockReset, restoreMocks } = getWorkerState().config // since each function calls another, we can just call one if (restoreMocks) diff --git a/packages/vitest/src/runtime/setup.ts b/packages/vitest/src/runtime/setup.ts index 8ef17f3ef..0c592597a 100644 --- a/packages/vitest/src/runtime/setup.ts +++ b/packages/vitest/src/runtime/setup.ts @@ -2,7 +2,7 @@ import { Console } from 'console' import { Writable } from 'stream' import { environments } from '../integrations/env' import type { ResolvedConfig } from '../types' -import { toArray } from '../utils' +import { getWorkerState, toArray } from '../utils' import * as VitestIndex from '../index' import { rpc } from './rpc' @@ -59,7 +59,7 @@ export function setupConsoleLogSpy() { rpc().onUserConsoleLog({ type: 'stdout', content: stdoutBuffer.map(i => String(i)).join(''), - taskId: __vitest_worker__.current?.id, + taskId: getWorkerState().current?.id, time: stdoutTime || Date.now(), }) } @@ -71,7 +71,7 @@ export function setupConsoleLogSpy() { rpc().onUserConsoleLog({ type: 'stderr', content: stderrBuffer.map(i => String(i)).join(''), - taskId: __vitest_worker__.current?.id, + taskId: getWorkerState().current?.id, time: stderrTime || Date.now(), }) } @@ -121,7 +121,7 @@ export async function runSetupFiles(config: ResolvedConfig) { const files = toArray(config.setupFiles) await Promise.all( files.map(async(file) => { - __vitest_worker__.moduleCache.delete(file) + getWorkerState().moduleCache.delete(file) await import(file) }), ) diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index e2d67b698..4ad38dec5 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,15 +1,16 @@ import { resolve } from 'pathe' import { createBirpc } from 'birpc' -import type { ModuleCache, ResolvedConfig, WorkerContext, WorkerGlobalState, WorkerRPC } from '../types' +import type { ModuleCache, ResolvedConfig, WorkerContext, WorkerRPC } from '../types' import { distDir } from '../constants' -import { executeInViteNode } from '../node/execute' +import { getWorkerState } from '../utils' +import { executeInViteNode } from './execute' import { rpc } from './rpc' let _viteNode: { run: (files: string[], config: ResolvedConfig) => Promise collect: (files: string[], config: ResolvedConfig) => Promise } -let __vitest_worker__: WorkerGlobalState + const moduleCache: Map = new Map() const mockMap = {} @@ -53,8 +54,9 @@ async function startViteNode(ctx: WorkerContext) { } function init(ctx: WorkerContext) { - if (__vitest_worker__ && ctx.config.threads && ctx.config.isolate) - throw new Error(`worker for ${ctx.files.join(',')} already initialized by ${__vitest_worker__.ctx.files.join(',')}. This is probably an internal bug of Vitest.`) + // @ts-expect-error untyped global + if (typeof __vitest_worker__ !== 'undefined' && ctx.config.threads && ctx.config.isolate) + throw new Error(`worker for ${ctx.files.join(',')} already initialized by ${getWorkerState().ctx.files.join(',')}. This is probably an internal bug of Vitest.`) process.stdout.write('\0') @@ -67,6 +69,7 @@ function init(ctx: WorkerContext) { ctx, moduleCache, config, + mockMap, rpc: createBirpc( {}, { @@ -93,7 +96,3 @@ export async function run(ctx: WorkerContext) { const { run } = await startViteNode(ctx) return run(ctx.files, ctx.config) } - -declare global { - let __vitest_worker__: import('vitest').WorkerGlobalState -} diff --git a/packages/vitest/src/types/mocker.ts b/packages/vitest/src/types/mocker.ts new file mode 100644 index 000000000..e138b1e13 --- /dev/null +++ b/packages/vitest/src/types/mocker.ts @@ -0,0 +1,8 @@ +export type SuiteMocks = Record unknown)>> + +export interface PendingSuiteMock { + id: string + importer: string + type: 'mock' | 'unmock' + factory?: () => unknown +} diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 0f318363f..303ecdcc8 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -1,6 +1,7 @@ import type { MessagePort } from 'worker_threads' import type { FetchFunction, ModuleCache, RawSourceMap, ViteNodeResolveId } from 'vite-node' import type { BirpcReturn } from 'birpc' +import type { SuiteMocks } from './mocker' import type { ResolvedConfig } from './config' import type { File, TaskResultPack, Test } from './tasks' import type { SnapshotResult } from './snapshot' @@ -37,4 +38,5 @@ export interface WorkerGlobalState { current?: Test filepath?: string moduleCache: Map + mockMap: SuiteMocks } diff --git a/packages/vitest/src/utils/global.ts b/packages/vitest/src/utils/global.ts new file mode 100644 index 000000000..2d714ad33 --- /dev/null +++ b/packages/vitest/src/utils/global.ts @@ -0,0 +1,6 @@ +import type { WorkerGlobalState } from '../types' + +export function getWorkerState(): WorkerGlobalState { + // @ts-expect-error untyped global + return globalThis.__vitest_worker__ +} diff --git a/packages/vitest/src/utils/index.ts b/packages/vitest/src/utils/index.ts index 89e8ae32c..40a8fe141 100644 --- a/packages/vitest/src/utils/index.ts +++ b/packages/vitest/src/utils/index.ts @@ -7,6 +7,7 @@ import { getNames } from './tasks' export * from './tasks' export * from './path' export * from './base' +export * from './global' export const isWindows = process.platform === 'win32' diff --git a/packages/web-worker/README.md b/packages/web-worker/README.md new file mode 100644 index 000000000..ab7b72061 --- /dev/null +++ b/packages/web-worker/README.md @@ -0,0 +1,60 @@ +# @vitest/addon-web-worker + +> 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`. + +## Installing + +```sh +# with npm +npm install -D @vitest/addon-web-worker + +# with pnpm +pnpm install -D @vitest/addon-web-worker + +# with yarn +yarn install -D @vitest/addon-web-worker +``` + +## Usage + +Just import `@vitest/addon-web-worker` in your test file to test only in current suite. + +Or add `@vitest/addon-web-worker` in your `setupFiles`, if you want to have a global support. + +```ts +import { defineConfig } from 'vitest/node' + +export default defineConfig({ + test: { + setupFiles: ['@vitest/addon-web-worker'], + }, +}) +``` + +## Examples + +```ts +// worker.ts +import '@vitest/addon-web-worker' +import MyWorker from '../worker?worker' + +self.onmessage = (e) => { + self.postMessage(`${e.data} world`) +} + +// worker.test.ts +let worker = new MyWorker() +// new Worker is also supported +worker = new Worker(new URL('../src/worker.ts', import.meta.url)) + +worker.postMessage('hello') +worker.onmessage = (e) => { + // e.data equals to 'hello world' +} +``` + +## Notice + +- Does not support `onmessage = () => {}`. Please, use `self.onmessage = () => {}`. \ No newline at end of file diff --git a/packages/web-worker/index.d.ts b/packages/web-worker/index.d.ts new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/packages/web-worker/index.d.ts @@ -0,0 +1 @@ + diff --git a/packages/web-worker/package.json b/packages/web-worker/package.json new file mode 100644 index 000000000..a4273d9d5 --- /dev/null +++ b/packages/web-worker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@vitest/web-worker", + "version": "1.0.0", + "description": "Web Worker support for testing in Vitest", + "type": "module", + "files": [ + "dist", + "*.d.ts" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "default": "./dist/index.js", + "types": "./index.d.ts" + }, + "./pure": { + "import": "./dist/pure.js", + "default": "./dist/pure.js", + "types": "./pure.d.ts" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "scripts": { + "build": "rimraf dist && rollup -c", + "dev": "rollup -c --watch --watch.include=src/**", + "prepublishOnly": "nr build", + "typecheck": "tsc --noEmit" + }, + "types": "./index.d.ts", + "peerDependencies": { + "vitest": "*" + }, + "devDependencies": { + "rollup": "^2.67.2" + } +} diff --git a/packages/web-worker/pure.d.ts b/packages/web-worker/pure.d.ts new file mode 100644 index 000000000..b54eae9e4 --- /dev/null +++ b/packages/web-worker/pure.d.ts @@ -0,0 +1,3 @@ +declare function defineWebWorker(): void; + +export { defineWebWorker }; diff --git a/packages/web-worker/rollup.config.js b/packages/web-worker/rollup.config.js new file mode 100644 index 000000000..fe3b462be --- /dev/null +++ b/packages/web-worker/rollup.config.js @@ -0,0 +1,54 @@ +import esbuild from 'rollup-plugin-esbuild' +import dts from 'rollup-plugin-dts' +import commonjs from '@rollup/plugin-commonjs' +import json from '@rollup/plugin-json' +import alias from '@rollup/plugin-alias' +import pkg from './package.json' + +const entries = { + index: 'src/index.ts', + pure: 'src/pure.ts', +} + +const external = [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + 'vitest', +] + +const plugins = [ + alias({ + entries: [ + { find: /^node:(.+)$/, replacement: '$1' }, + ], + }), + json(), + commonjs(), + esbuild({ + target: 'node14', + }), +] + +export default () => [ + { + input: entries, + output: { + dir: 'dist', + format: 'esm', + }, + external, + plugins, + }, + { + input: entries, + output: { + dir: process.cwd(), + entryFileNames: '[name].d.ts', + format: 'esm', + }, + external, + plugins: [ + dts({ respectExternal: true }), + ], + }, +] diff --git a/packages/web-worker/src/index.ts b/packages/web-worker/src/index.ts new file mode 100644 index 000000000..0b401ea47 --- /dev/null +++ b/packages/web-worker/src/index.ts @@ -0,0 +1,3 @@ +import { defineWebWorker } from './pure' + +defineWebWorker() diff --git a/packages/web-worker/src/pure.ts b/packages/web-worker/src/pure.ts new file mode 100644 index 000000000..2e18d3bc0 --- /dev/null +++ b/packages/web-worker/src/pure.ts @@ -0,0 +1,177 @@ +/* eslint-disable no-restricted-imports */ + +import { VitestRunner } from 'vitest/node' +import type { WorkerGlobalState } from 'vitest' + +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 + invalidate: string[] + importScripts?: any +} + +class InlineWorkerRunner extends VitestRunner { + constructor(options: any, private context: InlineWorkerContext) { + super(options) + } + + 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: () => {}, + }) + } +} + +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 invalidate: string[] = [] + const context: InlineWorkerContext = { + onmessage: null, + dispatchEvent: (event: Event) => { + this.inside.emit(event.type, event) + return true + }, + addEventListener: this.inside.on, + removeEventListener: this.inside.off, + postMessage: (data) => { + this.outside.emit('message', { data }) + }, + get self() { + return context + }, + get global() { + return context + }, + invalidate, + } + + this.inside.on('message', (e) => { + context.onmessage?.(e) + }) + + this.outside.on('message', (e) => { + this.onmessage?.(e) + }) + + const runner = new InlineWorkerRunner(options, context) + + let id = url instanceof URL ? url.toString() : url + + id = id.replace('?worker_file', '') + + invalidate.push(id) + + runner.executeId(id) + .then(() => { + invalidate.forEach((path) => { + // worker should be new every time + moduleCache.delete(path) + moduleCache.delete(`${path}__mock`) + }) + 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 }) + } + + terminate() { + this.outside.clear() + this.inside.clear() + } + } +} diff --git a/packages/web-worker/tsconfig.json b/packages/web-worker/tsconfig.json new file mode 100644 index 000000000..e2018da90 --- /dev/null +++ b/packages/web-worker/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["./dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec14c7ded..952b49329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,7 +142,7 @@ importers: lit: 2.2.1 devDependencies: '@vitest/ui': link:../../packages/ui - happy-dom: 2.47.2 + happy-dom: 2.49.0 vite: 2.8.6 vitest: link:../../packages/vitest @@ -471,7 +471,7 @@ importers: devDependencies: '@vitejs/plugin-vue': 2.2.4_vite@2.8.6+vue@3.2.26 '@vue/test-utils': 2.0.0-rc.18_vue@3.2.26 - happy-dom: 2.47.2 + happy-dom: 2.49.0 unplugin-auto-import: 0.6.6_2567a1d8483486229eeea0338e7e9b48 unplugin-vue-components: 0.18.0_5f389c9a2ae06d3bb3a1d5490dc2bafe vitest: link:../../packages/vitest @@ -488,7 +488,7 @@ importers: devDependencies: '@vitejs/plugin-vue': 2.2.4_vite@2.8.6+vue@3.2.31 '@vue/test-utils': 2.0.0-rc.18_vue@3.2.31 - happy-dom: 2.47.2 + happy-dom: 2.49.0 vitest: link:../../packages/vitest examples/vue-jsx: @@ -504,7 +504,7 @@ importers: '@vitejs/plugin-vue': 2.2.4_vite@2.8.6+vue@3.2.26 '@vitejs/plugin-vue-jsx': 1.3.8 '@vue/test-utils': 2.0.0-rc.18_vue@3.2.26 - happy-dom: 2.47.2 + happy-dom: 2.49.0 vite: 2.8.6 vitest: link:../../packages/vitest vue: 3.2.26 @@ -674,6 +674,12 @@ importers: vite-node: link:../vite-node ws: 8.5.0 + packages/web-worker: + specifiers: + rollup: ^2.67.2 + devDependencies: + rollup: 2.70.1 + packages/ws-client: specifiers: birpc: ^0.1.0 @@ -776,6 +782,14 @@ importers: test/snapshots: specifiers: {} + test/web-worker: + specifiers: + '@vitest/web-worker': workspace:* + vitest: workspace:* + devDependencies: + '@vitest/web-worker': link:../../packages/web-worker + vitest: link:../../packages/vitest + packages: /@algolia/autocomplete-core/1.5.0: @@ -12413,6 +12427,20 @@ packages: - encoding dev: true + /happy-dom/2.49.0: + resolution: {integrity: sha512-mnPY9LmumUs8EmKyAQjutmFn/XzafvQeQ65w7MsZlHqG6OH3MratBzS0N4AAmuB3+F51KFUbUKNF763i3ZV19Q==} + dependencies: + css.escape: 1.5.1 + he: 1.2.0 + node-fetch: 2.6.7 + sync-request: 6.1.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 1.0.5 + whatwg-mimetype: 2.3.0 + transitivePeerDependencies: + - encoding + dev: true + /has-bigints/1.0.1: resolution: {integrity: sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==} dev: true diff --git a/test/core/types/vite.d.ts b/test/core/types/vite.d.ts new file mode 100644 index 000000000..eeb2fab6a --- /dev/null +++ b/test/core/types/vite.d.ts @@ -0,0 +1,19 @@ +interface ImportMeta { + readonly env: ImportMetaEnv +} + +interface ImportMetaEnv { + [key: string]: string | boolean | undefined + BASE_URL: string + MODE: string + DEV: boolean + PROD: boolean + SSR: boolean +} + +declare module '*?worker' { + const workerConstructor: { + new (): Worker + } + export default workerConstructor +} diff --git a/test/web-worker/package.json b/test/web-worker/package.json new file mode 100644 index 000000000..7ddc5e210 --- /dev/null +++ b/test/web-worker/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vitest/test-global-setup", + "private": true, + "type": "module", + "scripts": { + "test": "vitest", + "coverage": "vitest run --coverage" + }, + "devDependencies": { + "@vitest/web-worker": "workspace:*", + "vitest": "workspace:*" + } +} diff --git a/test/web-worker/setup.ts b/test/web-worker/setup.ts new file mode 100644 index 000000000..dc039a879 --- /dev/null +++ b/test/web-worker/setup.ts @@ -0,0 +1 @@ +import '@vitest/web-worker' diff --git a/test/web-worker/src/worker.ts b/test/web-worker/src/worker.ts new file mode 100644 index 000000000..90d90793c --- /dev/null +++ b/test/web-worker/src/worker.ts @@ -0,0 +1,3 @@ +self.onmessage = (e) => { + self.postMessage(`${e.data} world`) +} diff --git a/test/web-worker/test/init.test.ts b/test/web-worker/test/init.test.ts new file mode 100644 index 000000000..e3fda1c49 --- /dev/null +++ b/test/web-worker/test/init.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest' + +import MyWorker from '../src/worker?worker' + +const testWorker = (worker: Worker) => { + return new Promise((resolve) => { + worker.postMessage('hello') + worker.onmessage = (e) => { + expect(e.data).toBe('hello world') + + resolve() + } + }) +} + +it('worker exists', async() => { + expect(Worker).toBeDefined() +}) + +it('simple worker', async() => { + expect.assertions(1) + + await testWorker(new MyWorker()) +}) + +it('can test workers several times', async() => { + expect.assertions(1) + + await testWorker(new MyWorker()) +}) + +it('worker with url', async() => { + expect.assertions(1) + const url = import.meta.url + + await testWorker(new Worker(new URL('../src/worker.ts', url))) +}) diff --git a/test/web-worker/vitest.config.ts b/test/web-worker/vitest.config.ts new file mode 100644 index 000000000..d5daed349 --- /dev/null +++ b/test/web-worker/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: [ + './setup.ts', + ], + deps: { + external: [ + /packages\/web-worker/, + ], + }, + }, +}) -- 2.51.2