From d06e63463ff093b0e95ea3849e0729d9beb27fef Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sat, 7 May 2022 12:56:44 +0300 Subject: [PATCH 1/3] feat: self/window/global share state with globalThis --- .../vitest/src/integrations/env/happy-dom.ts | 26 ++-- packages/vitest/src/integrations/env/jsdom.ts | 25 ++-- packages/vitest/src/integrations/env/utils.ts | 119 +++++++++++++++++- packages/vitest/src/integrations/vi.ts | 9 +- test/core/test/dom.test.ts | 6 + test/core/test/happy-dom.test.ts | 89 +++++++++++++ test/core/test/jsdom.test.ts | 11 -- 7 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 test/core/test/happy-dom.test.ts delete mode 100644 test/core/test/jsdom.test.ts diff --git a/packages/vitest/src/integrations/env/happy-dom.ts b/packages/vitest/src/integrations/env/happy-dom.ts index 8817d0b5b908..b8fdbcab2c40 100644 --- a/packages/vitest/src/integrations/env/happy-dom.ts +++ b/packages/vitest/src/integrations/env/happy-dom.ts @@ -1,6 +1,6 @@ import { importModule } from 'local-pkg' import type { Environment } from '../../types' -import { getWindowKeys } from './utils' +import { populateGlobal } from './utils' export default ({ name: 'happy-dom', @@ -8,29 +8,21 @@ export default ({ // happy-dom v3 introduced a breaking change to Window, but // provides GlobalWindow as a way to use previous behaviour const { Window, GlobalWindow } = await importModule('happy-dom') as typeof import('happy-dom') - const win: any = new (GlobalWindow || Window)() + const win = new (GlobalWindow || Window)() - const keys = getWindowKeys(global, win) + const { keys, allowRewrite } = populateGlobal(global, win) - const overrideObject = new Map() - for (const key of keys) { - Object.defineProperty(global, key, { - get() { - if (overrideObject.has(key)) - return overrideObject.get(key) - return win[key] - }, - set(v) { - overrideObject.set(key, v) - }, - configurable: true, - }) - } + const originals = new Map( + allowRewrite.map(([key]) => [key, global[key]]), + ) return { teardown(global) { + win.document.head.innerHTML = '' + win.document.body.innerHTML = '' win.happyDOM.cancelAsync() keys.forEach(key => delete global[key]) + originals.forEach((v, k) => global[k] = v) }, } }, diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index 0e457b998473..bdd612190a49 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -1,6 +1,6 @@ import { importModule } from 'local-pkg' import type { Environment, JSDOMOptions } from '../../types' -import { getWindowKeys } from './utils' +import { populateGlobal } from './utils' export default ({ name: 'jsdom', @@ -40,26 +40,19 @@ export default ({ }, ) - const keys = getWindowKeys(global, dom.window) + const { keys, allowRewrite } = populateGlobal(global, dom.window) - const overrideObject = new Map() - for (const key of keys) { - Object.defineProperty(global, key, { - get() { - if (overrideObject.has(key)) - return overrideObject.get(key) - return dom.window[key] - }, - set(v) { - overrideObject.set(key, v) - }, - configurable: true, - }) - } + const originals = new Map( + allowRewrite.map(([key]) => [key, global[key]]), + ) return { teardown(global) { + dom.window.document.head.innerHTML = '' + dom.window.document.body.innerHTML = '' + dom.window.close() keys.forEach(key => delete global[key]) + originals.forEach((v, k) => global[k] = v) }, } }, diff --git a/packages/vitest/src/integrations/env/utils.ts b/packages/vitest/src/integrations/env/utils.ts index 07947d6a92a8..5b5d651bf7dc 100644 --- a/packages/vitest/src/integrations/env/utils.ts +++ b/packages/vitest/src/integrations/env/utils.ts @@ -1,20 +1,131 @@ import { KEYS } from './jsdom-keys' -const allowRewrite = new Set([ +const allowRewrite = [ 'Event', 'EventTarget', -]) +] + +const skipKeys = [ + 'window', + 'self', +] export function getWindowKeys(global: any, win: any) { const keys = new Set(KEYS.concat(Object.getOwnPropertyNames(win)) .filter((k) => { - if (k.startsWith('_')) + if (k.startsWith('_') || skipKeys.includes(k)) return false if (k in global) - return allowRewrite.has(k) + return allowRewrite.includes(k) return true })) return keys } + +export function populateGlobal(global: any, win: any) { + const keys = getWindowKeys(global, win) + + const overrideObject = new Map() + for (const key of keys) { + Object.defineProperty(global, key, { + get() { + if (overrideObject.has(key)) + return overrideObject.get(key) + return win[key] + }, + set(v) { + overrideObject.set(key, v) + }, + configurable: true, + }) + } + + const globalKeys = ['window', 'self', 'GLOBAL', 'global'] + + global.globalThis = new Proxy(global.globalThis, { + set(target, key, value, receiver) { + overrideObject.set(key, value) + return Reflect.set(target, key, value, receiver) + }, + deleteProperty(target, key) { + overrideObject.delete(key) + return Reflect.deleteProperty(target, key) + }, + defineProperty(target, p, attributes) { + if (attributes.writable && 'value' in attributes) { + // skip - already covered by "set" + } + else if (attributes.get) { + globalKeys.forEach((key) => { + if (win[key]) + Object.defineProperty(win[key], p, attributes) + }) + } + return Reflect.defineProperty(target, p, attributes) + }, + }) + + globalKeys.forEach((key) => { + if (!win[key]) + return + + const proxy = new Proxy(win[key], { + get(target, p, receiver) { + if (overrideObject.has(p)) + return overrideObject.get(p) + return Reflect.get(target, p, receiver) + }, + set(target, p, value, receiver) { + try { + // if property is defined with configurable: false, + // this will throw an error, but `self.prop = value` should not throw + // this matches browser behaviour where it silently ignores the error + // and returns previously defined value, which is a hell for debugging + Object.defineProperty(global, p, { + get: () => overrideObject.get(p), + set: value => overrideObject.set(p, value), + configurable: true, + }) + overrideObject.set(p, value) + Reflect.set(target, p, value, receiver) + } + catch { + // ignore + } + return true + }, + deleteProperty(target, p) { + Reflect.deleteProperty(global, p) + overrideObject.delete(p) + return Reflect.deleteProperty(target, p) + }, + defineProperty(target, p, attributes) { + if (attributes.writable && 'value' in attributes) { + // skip - already covered by "set" + } + else if (attributes.get) { + overrideObject.delete(p) + Reflect.defineProperty(global, p, attributes) + } + return Reflect.defineProperty(target, p, attributes) + }, + }) + + Object.defineProperty(global, key, { + get() { + return proxy + }, + configurable: true, + }) + }) + + skipKeys.forEach(k => keys.add(k)) + + return { + keys, + skipKeys, + allowRewrite, + } +} diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 66b113d10d79..f5942488adcc 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -210,11 +210,14 @@ class VitestUtils { * `IntersectionObserver`. */ public stubGlobal(name: string | symbol | number, value: any) { - // @ts-expect-error we can do anything! - globalThis[name] = value - if (globalThis.window) + if (globalThis.window) { // @ts-expect-error we can do anything! globalThis.window[name] = value + } + else { + // @ts-expect-error we can do anything! + globalThis[name] = value + } return this } diff --git a/test/core/test/dom.test.ts b/test/core/test/dom.test.ts index 8d23b413f383..49e7bafeb618 100644 --- a/test/core/test/dom.test.ts +++ b/test/core/test/dom.test.ts @@ -18,3 +18,9 @@ it('dispatchEvent doesn\'t throw', () => { const event = new Event('click') expect(() => target.dispatchEvent(event)).not.toThrow() }) + +it('Image works as expected', () => { + const img = new Image(100) + + expect(img.width).toBe(100) +}) diff --git a/test/core/test/happy-dom.test.ts b/test/core/test/happy-dom.test.ts new file mode 100644 index 000000000000..add3b597ff4d --- /dev/null +++ b/test/core/test/happy-dom.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment happy-dom + */ + +/* eslint-disable vars-on-top */ + +import { expect, it } from 'vitest' + +declare global { + // eslint-disable-next-line no-var + var __property: unknown +} + +it('defined on self/window are defined on global', () => { + expect(self).toBeDefined() + expect(window).toBeDefined() + + expect(self.__property).not.toBeDefined() + expect(window.__property).not.toBeDefined() + expect(globalThis.__property).not.toBeDefined() + + globalThis.__property = 'defined_value' + + expect(__property).toBe('defined_value') + expect(self.__property).toBe('defined_value') + expect(window.__property).toBe('defined_value') + expect(globalThis.__property).toBe('defined_value') + + self.__property = 'test_value' + + expect(__property).toBe('test_value') + expect(self.__property).toBe('test_value') + expect(window.__property).toBe('test_value') + expect(globalThis.__property).toBe('test_value') + + window.__property = 'new_value' + + expect(__property).toBe('new_value') + expect(self.__property).toBe('new_value') + expect(window.__property).toBe('new_value') + expect(globalThis.__property).toBe('new_value') + + globalThis.__property = 'global_value' + + expect(__property).toBe('global_value') + expect(self.__property).toBe('global_value') + expect(window.__property).toBe('global_value') + expect(globalThis.__property).toBe('global_value') + + const obj = {} + + self.__property = obj + + expect(self.__property).toBe(obj) + expect(window.__property).toBe(obj) + expect(globalThis.__property).toBe(obj) +}) + +it('usage with defineProperty', () => { + Object.defineProperty(self, '__property', { + get: () => 'self_property', + configurable: true, + }) + + expect(__property).toBe('self_property') + expect(self.__property).toBe('self_property') + expect(globalThis.__property).toBe('self_property') + expect(window.__property).toBe('self_property') + + Object.defineProperty(window, '__property', { + get: () => 'window_property', + configurable: true, + }) + + expect(__property).toBe('window_property') + expect(self.__property).toBe('window_property') + expect(globalThis.__property).toBe('window_property') + expect(window.__property).toBe('window_property') + + Object.defineProperty(globalThis, '__property', { + get: () => 'global_property', + configurable: true, + }) + + expect(__property).toBe('global_property') + expect(self.__property).toBe('global_property') + expect(globalThis.__property).toBe('global_property') + expect(window.__property).toBe('global_property') +}) diff --git a/test/core/test/jsdom.test.ts b/test/core/test/jsdom.test.ts deleted file mode 100644 index 611132877921..000000000000 --- a/test/core/test/jsdom.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { expect, test } from 'vitest' - -test('Image works as expected', () => { - const img = new Image(100) - - expect(img.width).toBe(100) -}) From 1a8f904bbfc7b63829592378fdb244cf751b8d6d Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sat, 7 May 2022 15:47:09 +0300 Subject: [PATCH 2/3] chore: fix infinite loop in --no-threads --- .../vitest/src/integrations/env/happy-dom.ts | 2 - packages/vitest/src/integrations/env/jsdom.ts | 3 -- packages/vitest/src/integrations/env/utils.ts | 48 +++++++++---------- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/packages/vitest/src/integrations/env/happy-dom.ts b/packages/vitest/src/integrations/env/happy-dom.ts index b8fdbcab2c40..deeb328181dc 100644 --- a/packages/vitest/src/integrations/env/happy-dom.ts +++ b/packages/vitest/src/integrations/env/happy-dom.ts @@ -18,8 +18,6 @@ export default ({ return { teardown(global) { - win.document.head.innerHTML = '' - win.document.body.innerHTML = '' win.happyDOM.cancelAsync() keys.forEach(key => delete global[key]) originals.forEach((v, k) => global[k] = v) diff --git a/packages/vitest/src/integrations/env/jsdom.ts b/packages/vitest/src/integrations/env/jsdom.ts index bdd612190a49..5b8324ea1117 100644 --- a/packages/vitest/src/integrations/env/jsdom.ts +++ b/packages/vitest/src/integrations/env/jsdom.ts @@ -48,9 +48,6 @@ export default ({ return { teardown(global) { - dom.window.document.head.innerHTML = '' - dom.window.document.body.innerHTML = '' - dom.window.close() keys.forEach(key => delete global[key]) originals.forEach((v, k) => global[k] = v) }, diff --git a/packages/vitest/src/integrations/env/utils.ts b/packages/vitest/src/integrations/env/utils.ts index 5b5d651bf7dc..cd971cfb7879 100644 --- a/packages/vitest/src/integrations/env/utils.ts +++ b/packages/vitest/src/integrations/env/utils.ts @@ -42,30 +42,7 @@ export function populateGlobal(global: any, win: any) { }) } - const globalKeys = ['window', 'self', 'GLOBAL', 'global'] - - global.globalThis = new Proxy(global.globalThis, { - set(target, key, value, receiver) { - overrideObject.set(key, value) - return Reflect.set(target, key, value, receiver) - }, - deleteProperty(target, key) { - overrideObject.delete(key) - return Reflect.deleteProperty(target, key) - }, - defineProperty(target, p, attributes) { - if (attributes.writable && 'value' in attributes) { - // skip - already covered by "set" - } - else if (attributes.get) { - globalKeys.forEach((key) => { - if (win[key]) - Object.defineProperty(win[key], p, attributes) - }) - } - return Reflect.defineProperty(target, p, attributes) - }, - }) + const globalKeys = new Set(['window', 'self', 'GLOBAL', 'global']) globalKeys.forEach((key) => { if (!win[key]) @@ -121,6 +98,29 @@ export function populateGlobal(global: any, win: any) { }) }) + global.globalThis = new Proxy(global.globalThis, { + set(target, key, value, receiver) { + overrideObject.set(key, value) + return Reflect.set(target, key, value, receiver) + }, + deleteProperty(target, key) { + overrideObject.delete(key) + return Reflect.deleteProperty(target, key) + }, + defineProperty(target, p, attributes) { + if (attributes.writable && 'value' in attributes) { + // skip - already covered by "set" + } + else if (attributes.get && !globalKeys.has(p)) { + globalKeys.forEach((key) => { + if (win[key]) + Object.defineProperty(win[key], p, attributes) + }) + } + return Reflect.defineProperty(target, p, attributes) + }, + }) + skipKeys.forEach(k => keys.add(k)) return { From 0ecc7bf86b9d9330fb0343eba132c3542fa00b57 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sat, 7 May 2022 15:47:27 +0300 Subject: [PATCH 3/3] chore: add cleanup function to test-utils --- examples/react-testing-lib/src/utils/test-utils.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/react-testing-lib/src/utils/test-utils.tsx b/examples/react-testing-lib/src/utils/test-utils.tsx index b4bde9c16ba4..9ab259484918 100644 --- a/examples/react-testing-lib/src/utils/test-utils.tsx +++ b/examples/react-testing-lib/src/utils/test-utils.tsx @@ -1,5 +1,10 @@ /* eslint-disable import/export */ -import { render } from '@testing-library/react' +import { cleanup, render } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) const customRender = (ui: React.ReactElement, options = {}) => render(ui, {