diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 52a3af34f..41e391213 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -21,6 +21,9 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Cache node modules uses: actions/cache@v4 id: cache-node-modules diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47395b5db..966a9fef3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,9 @@ jobs: with: node-version: ${{ matrix.node-version }} + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + - name: Cache node modules uses: actions/cache@v4 id: cache-node-modules diff --git a/docs/contributing.md b/docs/contributing.md index 3c74842b4..7a0f0f4f4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -6,6 +6,24 @@ We are very happy that you would like to contribute. In this guide you will find ### Install +**Install Bun** + +To be able to run all tests in the "./packages/global-registrator" package, you need to install [Bun](https://bun.sh/). + +Linux & MacOS + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +Windows + +```bash +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +**Install dependencies** + ```bash npm install ``` @@ -22,7 +40,21 @@ npm run compile npm run watch ``` -### Debugging +### Test + +**Run tests** + +```bash +npm test +``` + +**Watch tests** + +```bash +npm run test:watch +``` + +### Debug 1. Go to the package you wish to test in the terminal (e.g. "cd ./packages/happy-dom") 2. Write "debugger;" at the place you want to place a breakpoint in the code. @@ -38,20 +70,6 @@ npm run test:debug 7. Click on the green ball. 8. Click continue to jump to your breakpoint. -### Automated Tests - -**Run tests** - -```bash -npm test -``` - -**Watch tests** - -```bash -npm run test:watch -``` - # Commit Convention We use the [Conventional Commits](https://www.conventionalcommits.org/en/) standard for our commit messages. The description should start with an uppercase character. diff --git a/packages/global-registrator/README.md b/packages/global-registrator/README.md index dd40170b7..f1ecb19cd 100644 --- a/packages/global-registrator/README.md +++ b/packages/global-registrator/README.md @@ -32,7 +32,7 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator'; GlobalRegistrator.register(); -GlobalRegistrator.unregister(); +await GlobalRegistrator.unregister(); // Outputs: "undefined" console.log(global.document); diff --git a/packages/global-registrator/package.json b/packages/global-registrator/package.json index 756dac107..5b4cbe48c 100644 --- a/packages/global-registrator/package.json +++ b/packages/global-registrator/package.json @@ -70,8 +70,10 @@ "compile:cjs": "rm -rf cjs && tsc --moduleResolution Node --module CommonJS --outDir cjs && npm run compile:change-cjs-file-extension", "compile:change-cjs-file-extension": "node ../happy-dom/bin/change-file-extension.cjs --dir=./cjs --fromExt=.js --toExt=.cjs", "watch": "npm run compile && tsc -w --preserveWatchOutput", - "test": "rm -rf tmp && tsc --project ./test && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node ./tmp/react/React.test.cjs", - "test:debug": "tsc --project ./test && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node --inspect-brk ./tmp/react/React.test.cjs" + "test": "npm run test:react && npm run test:bun", + "test:debug": "tsc --project ./test/react && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node --inspect-brk ./tmp/react/React.test.cjs", + "test:react": "rm -rf tmp && tsc --project ./test/react && node ../happy-dom/bin/change-file-extension.cjs --dir=./tmp --fromExt=.js --toExt=.cjs && node ./tmp/react/React.test.cjs", + "test:bun": "bun test ./test/bun/Bun.test.js" }, "dependencies": { "happy-dom": "^0.0.0" diff --git a/packages/global-registrator/src/GlobalRegistrator.ts b/packages/global-registrator/src/GlobalRegistrator.ts index 525f1e1d2..a36c39e00 100644 --- a/packages/global-registrator/src/GlobalRegistrator.ts +++ b/packages/global-registrator/src/GlobalRegistrator.ts @@ -1,14 +1,13 @@ -import { GlobalWindow } from 'happy-dom'; +import { GlobalWindow, PropertySymbol } from 'happy-dom'; import type { IOptionalBrowserSettings } from 'happy-dom'; const IGNORE_LIST = ['constructor', 'undefined', 'NaN', 'global', 'globalThis']; -const SELF_REFERRING = ['self', 'top', 'parent', 'window']; /** * */ export default class GlobalRegistrator { - private static registered: { [key: string]: PropertyDescriptor } | null = null; + private static registered: { [key: string | symbol]: PropertyDescriptor } | null = null; /** * Registers Happy DOM globally. @@ -33,6 +32,7 @@ export default class GlobalRegistrator { this.registered = {}; + // Define properties on the global object const propertyDescriptors = Object.getOwnPropertyDescriptors(window); for (const key of Object.keys(propertyDescriptors)) { @@ -41,43 +41,54 @@ export default class GlobalRegistrator { const globalPropertyDescriptor = Object.getOwnPropertyDescriptor(global, key); if ( - !globalPropertyDescriptor || - (windowPropertyDescriptor.value !== undefined && - windowPropertyDescriptor.value !== globalPropertyDescriptor.value) + globalPropertyDescriptor?.value === undefined || + globalPropertyDescriptor?.value !== windowPropertyDescriptor.value ) { this.registered[key] = globalPropertyDescriptor || null; - if ( - typeof windowPropertyDescriptor.value === 'function' && - !windowPropertyDescriptor.value.toString().startsWith('class ') - ) { - Object.defineProperty(global, key, { - ...windowPropertyDescriptor, - value: windowPropertyDescriptor.value.bind(global) - }); - } else { - Object.defineProperty(global, key, windowPropertyDescriptor); + // If the property is the window object, replace it with the global object + if (windowPropertyDescriptor.value === window) { + window[key] = global; + windowPropertyDescriptor.value = global; } + + Object.defineProperty(global, key, { + ...windowPropertyDescriptor, + configurable: true + }); } } } - for (const key of SELF_REFERRING) { + // Define symbol properties on the global object + const propertySymbols = Object.getOwnPropertySymbols(window); + + for (const key of propertySymbols) { + const propertyDescriptor = Object.getOwnPropertyDescriptor(window, key); this.registered[key] = null; - global[key] = global; + Object.defineProperty(global, key, { + ...propertyDescriptor, + configurable: true + }); } + + // Set owner window on document to global + global.document[PropertySymbol.ownerWindow] = global; + global.document[PropertySymbol.defaultView] = global; } /** - * Registers Happy DOM globally. + * Closes the window and unregisters Happy DOM from being global. */ - public static unregister(): void { + public static async unregister(): Promise { if (this.registered === null) { throw new Error( 'Failed to unregister. Happy DOM has not previously been globally registered.' ); } + const happyDOM = global.happyDOM; + for (const key of Object.keys(this.registered)) { if (this.registered[key] !== null) { Object.defineProperty(global, key, this.registered[key]); @@ -87,5 +98,9 @@ export default class GlobalRegistrator { } this.registered = null; + + if (happyDOM) { + await happyDOM.close(); + } } } diff --git a/packages/global-registrator/test/bun/Bun.test.js b/packages/global-registrator/test/bun/Bun.test.js new file mode 100644 index 000000000..fc3b2e88b --- /dev/null +++ b/packages/global-registrator/test/bun/Bun.test.js @@ -0,0 +1,89 @@ +import { GlobalRegistrator } from '../../lib/index.js'; +import { test, expect } from 'bun:test'; + +GlobalRegistrator.register(); + +/* eslint-disable no-undef */ + +const GETTERS = [ + 'location', + 'history', + 'navigator', + 'screen', + 'sessionStorage', + 'localStorage', + 'opener', + 'scrollX', + 'pageXOffset', + 'scrollY', + 'pageYOffset', + 'CSS', + 'innerWidth', + 'innerHeight', + 'outerWidth', + 'outerHeight', + 'devicePixelRatio' +]; + +test('DOM', () => { + document.body.innerHTML = ``; + const button = document.querySelector('button'); + expect(button?.innerText).toEqual('My button'); +}); + +test('CSS', () => { + const style = document.createElement('style'); + + document.head.appendChild(style); + style.innerHTML = ` + body { + background-color: red; + } + + @media (min-width: 1000px) { + body { + background-color: green; + } + } + `; + + expect(globalThis.getComputedStyle(document.body).backgroundColor).toBe('green'); +}); + +test('Window getters', () => { + const included = []; + const propertyNames = Object.getOwnPropertyNames(global); + + for (const name of GETTERS) { + if (propertyNames.includes(name)) { + included.push(name); + } + } + + expect(included).toEqual(GETTERS); +}); + +test('Window location', () => { + globalThis.location.href = 'https://example.com/'; + expect(globalThis.location.href).toBe('https://example.com/'); +}); + +test('Window options', () => { + GlobalRegistrator.unregister(); + + GlobalRegistrator.register({ + url: 'https://example.com/', + width: 1920, + height: 1080, + settings: { + navigator: { + userAgent: 'Custom User Agent' + } + } + }); + + expect(globalThis.location.href).toBe('https://example.com/'); + expect(globalThis.innerWidth).toBe(1920); + expect(globalThis.innerHeight).toBe(1080); + expect(globalThis.navigator.userAgent).toBe('Custom User Agent'); +}); diff --git a/packages/global-registrator/test/react/React.test.tsx b/packages/global-registrator/test/react/React.test.tsx index f612bb8d4..f23a987c4 100644 --- a/packages/global-registrator/test/react/React.test.tsx +++ b/packages/global-registrator/test/react/React.test.tsx @@ -41,6 +41,7 @@ async function main(): Promise { function testGetters(): void { const included: string[] = []; const propertyNames = Object.getOwnPropertyNames(global); + for (const name of GETTERS) { if (propertyNames.includes(name)) { included.push(name); @@ -49,11 +50,7 @@ async function main(): Promise { if (included.length !== GETTERS.length) { throw Error( - 'Object.getOwnPropertyNames() did not return all properties defined as getter. Expected: ' + - GETTERS.join(', ') + - '. Got: ' + - included.join(', ') + - '.' + `Object.getOwnPropertyNames() did not return all properties defined as getter. Expected: "${GETTERS.join(', ')}", Got: "${included.join(', ')}".` ); } } @@ -121,6 +118,31 @@ async function main(): Promise { testLocationHref(); + /** + * Test CSS. + */ + function testCSS(): void { + const style = document.createElement('style'); + document.head.appendChild(style); + style.innerHTML = ` + body { + background-color: red; + } + + @media (min-width: 1000px) { + body { + background-color: green; + } + } + `; + + if (globalThis.getComputedStyle(document.body).backgroundColor !== 'green') { + throw Error('The CSS was not applied correctly.'); + } + } + + testCSS(); + /** * Unregisters Happy DOM globally. */ @@ -132,17 +154,21 @@ async function main(): Promise { function testGettersAfterUnregister(): void { const included: string[] = []; const propertyNames = Object.getOwnPropertyNames(global); + for (const name of GETTERS) { if (propertyNames.includes(name)) { included.push(name); } } - if (included.length !== 0) { + // In Node.js v21 and later, the navigator property is available. + if (!included.includes('navigator')) { + included.push('navigator'); + } + + if (included.length !== 1 || included[0] !== 'navigator') { throw Error( - 'Object.getOwnPropertyNames() did not remove all properties defined as getter. Expected: []. Got: ' + - included.join(', ') + - '.' + `GlobalObserver.unregister() did not remove all properties defined as getter. Expected: "navigator", Got: "${included.join(', ')}".` ); } } diff --git a/packages/global-registrator/test/tsconfig.json b/packages/global-registrator/test/react/tsconfig.json similarity index 68% rename from packages/global-registrator/test/tsconfig.json rename to packages/global-registrator/test/react/tsconfig.json index ec39db5d8..f4dce54f8 100644 --- a/packages/global-registrator/test/tsconfig.json +++ b/packages/global-registrator/test/react/tsconfig.json @@ -1,8 +1,8 @@ { - "extends": "../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "../tmp", - "rootDir": "../test", + "outDir": "../../tmp", + "rootDir": "../../test", "jsx": "react", "module": "CommonJS", "moduleResolution": "Node", @@ -16,6 +16,6 @@ "include": [ "@types/node", ".", - "../lib" + "../../lib" ] } \ No newline at end of file diff --git a/packages/happy-dom/src/index.ts b/packages/happy-dom/src/index.ts index 491a9a761..74d7c43e7 100644 --- a/packages/happy-dom/src/index.ts +++ b/packages/happy-dom/src/index.ts @@ -1,4 +1,5 @@ import { URLSearchParams } from 'url'; +import * as PropertySymbol from './PropertySymbol.js'; import Browser from './browser/Browser.js'; import BrowserContext from './browser/BrowserContext.js'; import BrowserFrame from './browser/BrowserFrame.js'; @@ -60,6 +61,7 @@ import File from './file/File.js'; import FileReader from './file/FileReader.js'; import FormData from './form-data/FormData.js'; import History from './history/History.js'; +import Location from './location/Location.js'; import MutationObserver from './mutation-observer/MutationObserver.js'; import MutationRecord from './mutation-observer/MutationRecord.js'; import Attr from './nodes/attr/Attr.js'; @@ -117,9 +119,9 @@ import Storage from './storage/Storage.js'; import NodeFilter from './tree-walker/NodeFilter.js'; import NodeIterator from './tree-walker/NodeIterator.js'; import TreeWalker from './tree-walker/TreeWalker.js'; -import Location from './location/Location.js'; import URL from './url/URL.js'; import BrowserWindow from './window/BrowserWindow.js'; +import DetachedWindowAPI from './window/DetachedWindowAPI.js'; import GlobalWindow from './window/GlobalWindow.js'; import Window from './window/Window.js'; import XMLParser from './xml-parser/XMLParser.js'; @@ -216,6 +218,7 @@ export { DetachedBrowserContext, DetachedBrowserFrame, DetachedBrowserPage, + DetachedWindowAPI, Document, DocumentFragment, DocumentType, @@ -322,6 +325,7 @@ export { Permissions, ProcessingInstruction, ProgressEvent, + PropertySymbol, Range, Request, ResizeObserver, diff --git a/packages/happy-dom/src/window/BrowserWindow.ts b/packages/happy-dom/src/window/BrowserWindow.ts index d9fd217e1..14f2e0053 100644 --- a/packages/happy-dom/src/window/BrowserWindow.ts +++ b/packages/happy-dom/src/window/BrowserWindow.ts @@ -531,34 +531,6 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal WindowBrowserSettingsReader.setSettings(this, this.#browserFrame.page.context.browser.settings); - // Binds getts and setters, so that they will appear as an "own" property when using Object.getOwnPropertyNames(). - // This is needed for Vitest to work as it relies on Object.getOwnPropertyNames() to get the list of properties. - // @see https://github.com/capricorn86/happy-dom/issues/1339 - // Binds all methods to "this", so that it will use the correct context when called globally. - const propertyDescriptors = Object.assign( - Object.getOwnPropertyDescriptors(EventTarget.prototype), - Object.getOwnPropertyDescriptors(BrowserWindow.prototype) - ); - for (const key of Object.keys(propertyDescriptors)) { - const descriptor = propertyDescriptors[key]; - if (descriptor.get || descriptor.set) { - Object.defineProperty(this, key, { - configurable: true, - enumerable: true, - get: descriptor.get?.bind(this), - set: descriptor.set?.bind(this) - }); - } else if ( - key !== 'constructor' && - key[0] !== '_' && - key[0] === key[0].toLowerCase() && - typeof this[key] === 'function' && - !this[key].toString().startsWith('class ') - ) { - this[key] = this[key].bind(this); - } - } - const window = this; const asyncTaskManager = this.#browserFrame[PropertySymbol.asyncTaskManager]; @@ -686,6 +658,8 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal this.document.dispatchEvent(new Event('readystatechange')); this.document.dispatchEvent(new Event('load', { bubbles: true })); }); + + this.#bindToThisScope(); } /** @@ -1349,4 +1323,38 @@ export default class BrowserWindow extends EventTarget implements INodeJSGlobal WindowBrowserSettingsReader.removeSettings(this); } + + /** + * Binds methods, getters and setters to a scope. + * + * Getters and setters need to be bound to show up in Object.getOwnPropertyNames(), which is something Vitest relies on. + * + * @see https://github.com/capricorn86/happy-dom/issues/1339 + */ + #bindToThisScope(): void { + const propertyDescriptors = Object.assign( + Object.getOwnPropertyDescriptors(EventTarget.prototype), + Object.getOwnPropertyDescriptors(BrowserWindow.prototype) + ); + + for (const key of Object.keys(propertyDescriptors)) { + const descriptor = propertyDescriptors[key]; + if (descriptor.get || descriptor.set) { + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + get: descriptor.get?.bind(this), + set: descriptor.set?.bind(this) + }); + } else if ( + key !== 'constructor' && + key[0] !== '_' && + key[0] === key[0].toLowerCase() && + typeof this[key] === 'function' && + !this[key].toString().startsWith('class ') + ) { + this[key] = this[key].bind(this); + } + } + } }