diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index c9fd0ceb0d4c..985ac06bd310 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -1,8 +1,9 @@ +import type { DOMWindow } from 'jsdom' import type { Environment } from '../../types/environment' import type { JSDOMOptions } from '../../types/jsdom-options' import { populateGlobal } from './utils' -function catchWindowErrors(window: Window) { +function catchWindowErrors(window: DOMWindow) { let userErrorListenerCount = 0 function throwUnhandlerError(e: ErrorEvent) { if (userErrorListenerCount === 0 && e.error != null) { @@ -70,7 +71,10 @@ export default { userAgent, ...restOptions, }) - const clearWindowErrors = catchWindowErrors(dom.window as any) + + const clearAddEventListenerPatch = patchAddEventListener(dom.window) + + const clearWindowErrors = catchWindowErrors(dom.window) // TODO: browser doesn't expose Buffer, but a lot of dependencies use it dom.window.Buffer = Buffer @@ -120,6 +124,7 @@ export default { return dom.getInternalVMContext() }, teardown() { + clearAddEventListenerPatch() clearWindowErrors() dom.window.close() dom = undefined as any @@ -161,6 +166,8 @@ export default { ...restOptions, }) + const clearAddEventListenerPatch = patchAddEventListener(dom.window) + const { keys, originals } = populateGlobal(global, dom.window, { bindFunctions: true, }) @@ -171,6 +178,7 @@ export default { return { teardown(global) { + clearAddEventListenerPatch() clearWindowErrors() dom.window.close() delete global.jsdom @@ -180,3 +188,43 @@ export default { } }, } + +function patchAddEventListener(window: DOMWindow) { + const JSDOMAbortSignal = window.AbortSignal + const JSDOMAbortController = window.AbortController + const originalAddEventListener = window.EventTarget.prototype.addEventListener + + window.EventTarget.prototype.addEventListener = function addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ) { + if (typeof options === 'object' && options.signal != null) { + const { signal, ...otherOptions } = options + // - this happens because AbortSignal is provided by Node.js, + // but jsdom APIs require jsdom's AbortSignal, while Node APIs + // (like fetch and Request) require a Node.js AbortSignal + // - disable narrow typing with "as any" because we need it later + if (!((signal as any) instanceof JSDOMAbortSignal)) { + const jsdomCompatOptions = Object.create(null) + Object.assign(jsdomCompatOptions, otherOptions) + + // use jsdom-native abort controller instead and forward the + // previous one with `addEventListener` + const jsdomAbortController = new JSDOMAbortController() + signal.addEventListener('abort', () => { + jsdomAbortController.abort(signal.reason) + }) + + jsdomCompatOptions.signal = jsdomAbortController.signal + return originalAddEventListener.call(this, type, callback, jsdomCompatOptions) + } + } + + return originalAddEventListener.call(this, type, callback, options) + } + + return () => { + window.EventTarget.prototype.addEventListener = originalAddEventListener + } +} diff --git a/test/core/test/environments/jsdom.spec.ts b/test/core/test/environments/jsdom.spec.ts index db3bc83ff19a..3751f520dc26 100644 --- a/test/core/test/environments/jsdom.spec.ts +++ b/test/core/test/environments/jsdom.spec.ts @@ -2,7 +2,7 @@ import { stripVTControlCharacters } from 'node:util' import { processError } from '@vitest/utils/error' -import { expect, test } from 'vitest' +import { expect, test, vi } from 'vitest' test('MessageChannel and MessagePort are available', () => { expect(MessageChannel).toBeDefined() @@ -40,6 +40,26 @@ test('Fetch API accepts other APIs', () => { expect.soft(() => new Request('http://localhost', { method: 'POST', body: searchParams })).not.toThrowError() }) +test('DOM APIs accept AbortController', () => { + const element = document.createElement('div') + document.body.append(element) + const controller = new AbortController() + const spy = vi.fn() + element.addEventListener('click', spy, { + signal: controller.signal, + }) + + element.click() + + expect(spy).toHaveBeenCalledTimes(1) + + controller.abort() + + element.click() + + expect(spy).toHaveBeenCalledTimes(1) +}) + test('atob and btoa are available', () => { expect(atob('aGVsbG8gd29ybGQ=')).toBe('hello world') expect(btoa('hello world')).toBe('aGVsbG8gd29ybGQ=')