diff --git a/package.json b/package.json index bb88130..d0caa8f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "docs": "pezkuwi-dev-build-docs", "lint": "pezkuwi-dev-run-lint", "postinstall": "./packages/dev/scripts/pezkuwi-dev-yarn-only.mjs", - "test": "yarn build && pezkuwi-dev-run-test --dev-build --env browser" + "test": "yarn build && pezkuwi-dev-run-test --dev-build --experimental-test-module-mocks --env browser" }, "devDependencies": { "@pezkuwi/dev": "workspace:packages/dev", diff --git a/packages/dev-test/package.json b/packages/dev-test/package.json index 03d6f04..ed29e2a 100644 --- a/packages/dev-test/package.json +++ b/packages/dev-test/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.5", + "version": "0.86.6", "main": "./index.js", "exports": { "./globals.d.ts": "./src/globals.d.ts" diff --git a/packages/dev-test/src/env/browser.spec.ts b/packages/dev-test/src/env/browser.spec.ts index e38c910..47f0018 100644 --- a/packages/dev-test/src/env/browser.spec.ts +++ b/packages/dev-test/src/env/browser.spec.ts @@ -23,4 +23,42 @@ describe('browser', () => { it('contains HTML*Element', () => { expect(typeof all.HTMLElement).toBe('function'); }); + + it('contains the DOM node hierarchy', () => { + const div = document.createElement('div'); + + expect(div).toBeInstanceOf(Element); + expect(div).toBeInstanceOf(Node); + expect(document.createTextNode('text')).toBeInstanceOf(Text); + expect(document.createDocumentFragment()).toBeInstanceOf(DocumentFragment); + expect(document.createElementNS('http://www.w3.org/2000/svg', 'svg')).toBeInstanceOf(SVGElement); + expect(div.attachShadow({ mode: 'open' })).toBeInstanceOf(ShadowRoot); + expect(document).toBeInstanceOf(Document); + }); + + it('contains the UI event constructors', () => { + const div = document.createElement('div'); + + expect(() => div.dispatchEvent(new MouseEvent('click'))).not.toThrow(); + expect(() => div.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))).not.toThrow(); + expect(new FocusEvent('focus')).toBeInstanceOf(UIEvent); + }); + + it('contains getComputedStyle and the observers', () => { + const div = document.createElement('div'); + + div.style.display = 'none'; + document.body.appendChild(div); + + expect(getComputedStyle(div).display).toBe('none'); + expect(() => new MutationObserver(() => undefined).observe(div, { childList: true })).not.toThrow(); + expect(() => new ResizeObserver(() => undefined).observe(div)).not.toThrow(); + + div.remove(); + }); + + it('dispatches the global CustomEvent and Event on window', () => { + expect(() => window.dispatchEvent(new CustomEvent('custom', { detail: 1 }))).not.toThrow(); + expect(() => window.dispatchEvent(new Event('plain'))).not.toThrow(); + }); }); diff --git a/packages/dev-test/src/env/browser.ts b/packages/dev-test/src/env/browser.ts index 39377a1..fda9a8b 100644 --- a/packages/dev-test/src/env/browser.ts +++ b/packages/dev-test/src/env/browser.ts @@ -1,8 +1,80 @@ // Copyright 2017-2026 @pezkuwi/dev-test authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { DOMWindow } from 'jsdom'; + import { JSDOM } from 'jsdom'; +/** + * Constructors that Node defines as well. A DOM node only accepts an event + * created in its own realm, so in the browser environment the JSDOM ones + * replace Node's, as they do under jest-environment-jsdom. + */ +export const BROWSER_OVERRIDES: readonly string[] = ['CustomEvent', 'Event']; + +/** + * JSDOM does no layout, so no element ever changes size and the callback + * never runs. JSDOM has no ResizeObserver of its own. + */ +class ResizeObserver { + observe (): void { + // nothing is ever resized + } + + unobserve (): void { + // nothing is being watched + } + + disconnect (): void { + // nothing is being watched + } +} + +/** The DOM node hierarchy, which libraries check with instanceof */ +function nodes (window: DOMWindow) { + return { + CharacterData: window.CharacterData, + Comment: window.Comment, + Document: window.Document, + DocumentFragment: window.DocumentFragment, + Element: window.Element, + Node: window.Node, + NodeList: window.NodeList, + SVGElement: window.SVGElement, + ShadowRoot: window.ShadowRoot, + Text: window.Text + }; +} + +/** Event constructors. Node has its own Event and CustomEvent, see BROWSER_OVERRIDES */ +function events (window: DOMWindow) { + return { + CompositionEvent: window.CompositionEvent, + CustomEvent: window.CustomEvent, + Event: window.Event, + FocusEvent: window.FocusEvent, + InputEvent: window.InputEvent, + KeyboardEvent: window.KeyboardEvent, + MouseEvent: window.MouseEvent, + TouchEvent: window.TouchEvent, + UIEvent: window.UIEvent, + WheelEvent: window.WheelEvent + }; +} + +/** Styles, geometry and the observers */ +function layout (window: DOMWindow) { + return { + // present on the JSDOM window, absent from its DOMWindow type + DOMRect: window['DOMRect'] as typeof DOMRect, + DOMRectReadOnly: window['DOMRectReadOnly'] as typeof DOMRectReadOnly, + MutationObserver: window.MutationObserver, + MutationRecord: window.MutationRecord, + ResizeObserver, + getComputedStyle: window.getComputedStyle.bind(window) + }; +} + /** * Export a very basic JSDom environment - this is just enough so we have * @testing-environment/react tests passing in this repo @@ -15,6 +87,9 @@ export function browser () { const { window } = new JSDOM('', { url: 'http://localhost' }); return { + ...nodes(window), + ...events(window), + ...layout(window), // All HTML Elements that are defined on the JSDOM window object. // (we copied as-is from the types definition). We cannot get this // via Object.keys(window).filter(...) so we have to specify explicitly diff --git a/packages/dev-test/src/env/expect.spec.ts b/packages/dev-test/src/env/expect.spec.ts index 2de1cf8..b14a0c3 100644 --- a/packages/dev-test/src/env/expect.spec.ts +++ b/packages/dev-test/src/env/expect.spec.ts @@ -255,4 +255,28 @@ describe('expect', () => { expect(() => expect([1, 2]).not.toEqual(expect.arrayContaining([2]))).toThrow(); }); }); + + describe('.toContain', () => { + it('finds a substring in a string', () => { + expect('voter-alice').toContain('alice'); + expect(() => expect('voter-alice').toContain('bob')).toThrow(); + }); + + it('finds the item itself in an array or other iterable', () => { + const item = { a: 1 }; + + expect([1, item]).toContain(item); + expect(new Set(['a'])).toContain('a'); + expect(() => expect([{ a: 1 }]).toContain({ a: 1 })).toThrow(); + }); + + it('rejects a value that is neither a string nor an iterable', () => { + expect(() => expect(123).toContain(1)).toThrow(); + }); + + it('supports .not', () => { + expect('voter-alice').not.toContain('bob'); + expect(() => expect([1, 2]).not.toContain(2)).toThrow(); + }); + }); }); diff --git a/packages/dev-test/src/env/expect.ts b/packages/dev-test/src/env/expect.ts index 72ef0d0..2a2fec9 100644 --- a/packages/dev-test/src/env/expect.ts +++ b/packages/dev-test/src/env/expect.ts @@ -144,6 +144,40 @@ function assertMatchObj (value: unknown, check: object): void { .forEach(([key, other]) => assertMatch((value as Record)[key], other)); } +/** + * @internal + * + * Whether a string contains the substring, or another iterable the item + */ +function contains (value: unknown, item: unknown): boolean { + if (typeof value === 'string') { + return typeof item === 'string' && value.includes(item); + } + + assert.ok(typeof (value as Iterable | null | undefined)?.[Symbol.iterator] === 'function', `Expected a string or an iterable, found ${typeof value}`); + + return [...(value as Iterable)].includes(item); +} + +/** + * @internal + * + * A helper for toContain: a string contains the substring, any other + * iterable contains the item itself (not a deep-equal copy) + */ +function assertContains (value: unknown, item: unknown): void { + assert.ok(contains(value, item), `Expected ${String(value)} to contain ${String(item)}`); +} + +/** + * @internal + * + * The inverse of assertContains + */ +function assertNotContains (value: unknown, item: unknown): void { + assert.ok(!contains(value, item), `Expected ${String(value)} not to contain ${String(item)}`); +} + /** * @internal * @@ -269,6 +303,7 @@ export function expect () { toBeDefined: () => assert.ok(value === undefined), toBeNull: (value: unknown) => assert.ok(value !== null), toBeUndefined: () => assert.ok(value !== undefined), + toContain: (item: unknown) => assertNotContains(value, item), toEqual: (other: unknown) => containsMatcher(other) ? assert.throws(() => assertEqualMatch(value, other)) : assert.notDeepEqual(value, other), @@ -287,6 +322,7 @@ export function expect () { toBeNull: (value: unknown) => assert.ok(value === null), toBeTruthy: () => assert.ok(value), toBeUndefined: () => assert.ok(value === undefined), + toContain: (item: unknown) => assertContains(value, item), toEqual: (other: unknown) => containsMatcher(other) ? assertEqualMatch(value, other) : assert.deepEqual(value, other), diff --git a/packages/dev-test/src/env/index.ts b/packages/dev-test/src/env/index.ts index 37ce94e..8ec9132 100644 --- a/packages/dev-test/src/env/index.ts +++ b/packages/dev-test/src/env/index.ts @@ -1,7 +1,7 @@ // Copyright 2017-2026 @pezkuwi/dev-test authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { browser } from './browser.js'; +import { browser, BROWSER_OVERRIDES } from './browser.js'; import { expect } from './expect.js'; import { jest } from './jest.js'; import { lifecycle } from './lifecycle.js'; @@ -15,7 +15,11 @@ export function exposeEnv (isBrowser: boolean): void { env && Object .entries(env()) .forEach(([key, fn]) => { - globalThis[key as 'undefined'] ??= fn; + if (isBrowser && BROWSER_OVERRIDES.includes(key)) { + (globalThis as Record)[key] = fn; + } else { + globalThis[key as 'undefined'] ??= fn; + } }); }); } diff --git a/packages/dev-test/src/env/moduleMocks.spec.ts b/packages/dev-test/src/env/moduleMocks.spec.ts new file mode 100644 index 0000000..c4fb1b6 --- /dev/null +++ b/packages/dev-test/src/env/moduleMocks.spec.ts @@ -0,0 +1,14 @@ +// Copyright 2017-2026 @pezkuwi/dev-test authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { mock } from 'node:test'; + +describe('mock.module', () => { + it('replaces a module loaded after the mock is registered', async () => { + mock.module('node:os', { namedExports: { platform: () => 'mocked' } }); + + const { platform } = await import('node:os'); + + expect(platform()).toBe('mocked'); + }); +}); diff --git a/packages/dev-test/src/packageInfo.ts b/packages/dev-test/src/packageInfo.ts index 058817c..65cf17e 100644 --- a/packages/dev-test/src/packageInfo.ts +++ b/packages/dev-test/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.5' }; +export const packageInfo = { name: '@pezkuwi/dev-test', path: 'auto', type: 'auto', version: '0.86.6' }; diff --git a/packages/dev-ts/package.json b/packages/dev-ts/package.json index 6a93946..d600536 100644 --- a/packages/dev-ts/package.json +++ b/packages/dev-ts/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.5", + "version": "0.86.6", "main": "./index.js", "exports": { "./globals.d.ts": "./src/globals.d.ts" diff --git a/packages/dev-ts/src/packageInfo.ts b/packages/dev-ts/src/packageInfo.ts index 58403f8..e5aec23 100644 --- a/packages/dev-ts/src/packageInfo.ts +++ b/packages/dev-ts/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.5' }; +export const packageInfo = { name: '@pezkuwi/dev-ts', path: 'auto', type: 'auto', version: '0.86.6' }; diff --git a/packages/dev/README.md b/packages/dev/README.md index 4038350..c0d1d7d 100644 --- a/packages/dev/README.md +++ b/packages/dev/README.md @@ -252,6 +252,11 @@ This script runs test files in the repository, filtering by file extensions and - **`--logfile `**: Specifies a log file to capture test output. +- **`--experimental-test-module-mocks`**: + Passed to Node.js, enabling `mock.module` from `node:test`. Node only swaps a module that has not + been loaded yet, so a spec registers its mocks first and then loads the code under test with a + dynamic `import()`. + - **`--import `**: Imports the specified module. diff --git a/packages/dev/package.json b/packages/dev/package.json index 6cc6e20..559a971 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.86.5", + "version": "0.86.6", "bin": { "pezkuwi-ci-ghact-build": "./scripts/pezkuwi-ci-ghact-build.mjs", "pezkuwi-ci-ghact-docs": "./scripts/pezkuwi-ci-ghact-docs.mjs", @@ -52,8 +52,8 @@ "dependencies": { "@eslint/compat": "^2.1.1", "@eslint/js": "^10.0.1", - "@pezkuwi/dev-test": "^0.86.5", - "@pezkuwi/dev-ts": "^0.86.5", + "@pezkuwi/dev-test": "^0.86.6", + "@pezkuwi/dev-ts": "^0.86.6", "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-dynamic-import-vars": "^2.1.5", diff --git a/packages/dev/scripts/pezkuwi-dev-run-test.mjs b/packages/dev/scripts/pezkuwi-dev-run-test.mjs index 0e2878a..9e45fa5 100755 --- a/packages/dev/scripts/pezkuwi-dev-run-test.mjs +++ b/packages/dev/scripts/pezkuwi-dev-run-test.mjs @@ -58,6 +58,11 @@ for (let i = 0; i < args.length; i++) { cmd.push(args[++i]); break; + // node flags with no params + case '--experimental-test-module-mocks': + nodeFlags.push(args[i]); + break; + // node flags that could have additional params case '--import': case '--loader': diff --git a/packages/dev/src/packageInfo.ts b/packages/dev/src/packageInfo.ts index 8f31535..1ec83e0 100644 --- a/packages/dev/src/packageInfo.ts +++ b/packages/dev/src/packageInfo.ts @@ -3,4 +3,4 @@ // Do not edit, auto-generated by @pezkuwi/dev -export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.5' }; +export const packageInfo = { name: '@pezkuwi/dev', path: 'auto', type: 'auto', version: '0.86.6' }; diff --git a/yarn.lock b/yarn.lock index 9a80126..ace19c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1189,7 +1189,7 @@ __metadata: languageName: node linkType: hard -"@pezkuwi/dev-test@npm:^0.86.5, @pezkuwi/dev-test@workspace:packages/dev-test": +"@pezkuwi/dev-test@npm:^0.86.6, @pezkuwi/dev-test@workspace:packages/dev-test": version: 0.0.0-use.local resolution: "@pezkuwi/dev-test@workspace:packages/dev-test" dependencies: @@ -1205,7 +1205,7 @@ __metadata: languageName: unknown linkType: soft -"@pezkuwi/dev-ts@npm:^0.86.5, @pezkuwi/dev-ts@workspace:packages/dev-ts": +"@pezkuwi/dev-ts@npm:^0.86.6, @pezkuwi/dev-ts@workspace:packages/dev-ts": version: 0.0.0-use.local resolution: "@pezkuwi/dev-ts@workspace:packages/dev-ts" dependencies: @@ -1221,8 +1221,8 @@ __metadata: dependencies: "@eslint/compat": "npm:^2.1.1" "@eslint/js": "npm:^10.0.1" - "@pezkuwi/dev-test": "npm:^0.86.5" - "@pezkuwi/dev-ts": "npm:^0.86.5" + "@pezkuwi/dev-test": "npm:^0.86.6" + "@pezkuwi/dev-ts": "npm:^0.86.6" "@rollup/plugin-alias": "npm:^5.1.1" "@rollup/plugin-commonjs": "npm:^29.0.3" "@rollup/plugin-dynamic-import-vars": "npm:^2.1.5"