From c8eb68420d1b45b7c5d3089c895ced4705024857 Mon Sep 17 00:00:00 2001 From: blurfx Date: Thu, 12 Oct 2023 16:18:24 +0000 Subject: [PATCH] feat: add custom vitest environment for jsdom --- package-lock.json | 26 +++++ package.json | 2 + test/vitest/env/index.ts | 154 ++++++++++++++++++++++++++++++ test/vitest/env/package-lock.json | 10 ++ test/vitest/env/package.json | 5 + vitest.config.ts | 4 +- 6 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 test/vitest/env/index.ts create mode 100644 test/vitest/env/package-lock.json create mode 100644 test/vitest/env/package.json diff --git a/package-lock.json b/package-lock.json index bf216227f..a92644717 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@microsoft/api-extractor": "^7.19.4", "@types/benchmark": "^2.1.1", "@types/google-protobuf": "^3.15.5", + "@types/jsdom": "^21.1.3", "@types/long": "^4.0.1", "@types/yargs": "^17.0.0", "@typescript-eslint/eslint-plugin": "^5.30.6", @@ -48,6 +49,7 @@ "vite": "^4.4.9", "vite-tsconfig-paths": "^4.2.1", "vitest": "^0.34.5", + "vitest-environment-custom-jsdom": "file:test/vitest/env", "webpack": "^5.88.2", "webpack-bundle-analyzer": "^4.7.0", "webpack-cli": "^4.10.0", @@ -3442,6 +3444,17 @@ "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", "dev": true }, + "node_modules/@types/jsdom": { + "version": "21.1.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.3.tgz", + "integrity": "sha512-1zzqSP+iHJYV4lB3lZhNBa012pubABkj9yG/GuXuf6LZH1cSPIJBqFDrm5JX65HHt6VOnNYdTui/0ySerRbMgA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.9", "dev": true, @@ -3550,6 +3563,12 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.3.tgz", + "integrity": "sha512-THo502dA5PzG/sfQH+42Lw3fvmYkceefOspdCwpHRul8ik2Jv1K8I5OZz1AT3/rs46kwgMCe9bSBmDLYkkOMGg==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.2.2", "dev": true, @@ -12075,6 +12094,10 @@ } } }, + "node_modules/vitest-environment-custom-jsdom": { + "resolved": "test/vitest/env", + "link": true + }, "node_modules/vitest/node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -12822,6 +12845,9 @@ "optional": true } } + }, + "test/vitest/env": { + "dev": true } } } diff --git a/package.json b/package.json index 6c50bb70f..6c7be06db 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@microsoft/api-extractor": "^7.19.4", "@types/benchmark": "^2.1.1", "@types/google-protobuf": "^3.15.5", + "@types/jsdom": "^21.1.3", "@types/long": "^4.0.1", "@types/yargs": "^17.0.0", "@typescript-eslint/eslint-plugin": "^5.30.6", @@ -75,6 +76,7 @@ "vite": "^4.4.9", "vite-tsconfig-paths": "^4.2.1", "vitest": "^0.34.5", + "vitest-environment-custom-jsdom": "file:test/vitest/env", "webpack": "^5.88.2", "webpack-bundle-analyzer": "^4.7.0", "webpack-cli": "^4.10.0", diff --git a/test/vitest/env/index.ts b/test/vitest/env/index.ts new file mode 100644 index 000000000..372fc8646 --- /dev/null +++ b/test/vitest/env/index.ts @@ -0,0 +1,154 @@ +import { CookieJar, JSDOM, ResourceLoader, VirtualConsole } from 'jsdom'; +import { Environment } from 'vitest' +import { populateGlobal } from 'vitest/environments' + +function catchWindowErrors(window: Window) { + let userErrorListenerCount = 0 + function throwUnhandlerError(e: ErrorEvent) { + if (userErrorListenerCount === 0 && e.error != null) + process.emit('uncaughtException', e.error) + } + const addEventListener = window.addEventListener.bind(window) + const removeEventListener = window.removeEventListener.bind(window) + window.addEventListener('error', throwUnhandlerError) + window.addEventListener = function (...args: Parameters) { + if (args[0] === 'error') + userErrorListenerCount++ + return addEventListener.apply(this, args) + } + window.removeEventListener = function (...args: Parameters) { + if (args[0] === 'error' && userErrorListenerCount) + userErrorListenerCount-- + return removeEventListener.apply(this, args) + } + return function clearErrorHandlers() { + window.removeEventListener('error', throwUnhandlerError) + } +} + +const ALLOWED_KEYS = [ + 'Uint8Array' +] + +export default ({ + name: 'jsdom', + transformMode: 'web', + async setupVM({ jsdom = {} }) { + const { + html = '', + userAgent, + url = 'http://localhost:3000', + contentType = 'text/html', + pretendToBeVisual = true, + includeNodeLocations = false, + runScripts = 'dangerously', + resources, + console = false, + cookieJar = false, + ...restOptions + } = jsdom as any + const dom = new JSDOM( + html, + { + pretendToBeVisual, + resources: resources ?? (userAgent ? new ResourceLoader({ userAgent }) : undefined), + runScripts, + url, + virtualConsole: (console && globalThis.console) ? new VirtualConsole().sendTo(globalThis.console) : undefined, + cookieJar: cookieJar ? new CookieJar() : undefined, + includeNodeLocations, + contentType, + userAgent, + ...restOptions, + }, + ) + const clearWindowErrors = catchWindowErrors(dom.window as any) + + // TODO: browser doesn't expose Buffer, but a lot of dependencies use it + dom.window.Buffer = Buffer + + // inject web globals if they missing in JSDOM but otherwise available in Nodejs + // https://nodejs.org/dist/latest/docs/api/globals.html + const globalNames = [ + 'structuredClone', + 'fetch', + 'Request', + 'Response', + 'BroadcastChannel', + 'MessageChannel', + 'MessagePort', + ] as const + for (const name of globalNames) { + const value = globalThis[name] + if ( + typeof value !== 'undefined' + && typeof dom.window[name] === 'undefined' + ) + dom.window[name] = value + } + + return { + getVmContext() { + return dom.getInternalVMContext() + }, + teardown() { + clearWindowErrors() + dom.window.close() + }, + } + }, + async setup(global, { jsdom = {} }) { + const { + CookieJar, + JSDOM, + ResourceLoader, + VirtualConsole, + } = await require('jsdom') as typeof import('jsdom') + const { + html = '', + userAgent, + url = 'http://localhost:3000', + contentType = 'text/html', + pretendToBeVisual = true, + includeNodeLocations = false, + runScripts = 'dangerously', + resources, + console = false, + cookieJar = false, + ...restOptions + } = jsdom as any + const dom = new JSDOM( + html, + { + pretendToBeVisual, + resources: resources ?? (userAgent ? new ResourceLoader({ userAgent }) : undefined), + runScripts, + url, + virtualConsole: (console && global.console) ? new VirtualConsole().sendTo(global.console) : undefined, + cookieJar: cookieJar ? new CookieJar() : undefined, + includeNodeLocations, + contentType, + userAgent, + ...restOptions, + }, + ) + + const { keys, originals } = populateGlobal(global, dom.window, { bindFunctions: true }); + + ALLOWED_KEYS.forEach((key) => { + delete global[key]; + global[key] = originals.get(key); + }); + + const clearWindowErrors = catchWindowErrors(global) + + return { + teardown(global) { + clearWindowErrors() + dom.window.close() + keys.forEach(key => delete global[key]) + originals.forEach((v, k) => global[k] = v) + }, + } + }, +}) \ No newline at end of file diff --git a/test/vitest/env/package-lock.json b/test/vitest/env/package-lock.json new file mode 100644 index 000000000..caf2af0bd --- /dev/null +++ b/test/vitest/env/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "vitest-environment-custom-jsdom", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vitest-environment-custom-jsdom" + } + } +} diff --git a/test/vitest/env/package.json b/test/vitest/env/package.json new file mode 100644 index 000000000..ed0773339 --- /dev/null +++ b/test/vitest/env/package.json @@ -0,0 +1,5 @@ +{ + "name": "vitest-environment-custom-jsdom", + "private": true, + "main": "index.ts" +} diff --git a/vitest.config.ts b/vitest.config.ts index 7c120e7bf..d69fefa48 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,10 +9,10 @@ export default defineConfig({ provider: 'istanbul', reporter: ['lcov', 'text-summary'], }, - environment: 'jsdom', + environment: 'custom-jsdom', globals: true, singleThread: true, - testTimeout: 15000, + testTimeout: 5000, }, plugins: [tsconfigPaths()], });