From 013d15ad055a89bbe33f4595ac60531f420f75ff Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 31 Mar 2026 10:48:02 -0300 Subject: [PATCH 1/4] chore: refactor plugin internals, add unit tests, update readme, add license - Extract pure helper functions from src/plugin.ts for testability - Add __internal export to expose helpers for unit tests - Add tests/plugin-internals.test.ts with 13 unit tests covering all helpers - Update README to match shared-resources style with compatibility matrix - Scope test:deno to happy-dom only (jsdom unstable under Deno) - Add ISC LICENSE file --- LICENSE | 15 ++ README.md | 148 +++++++++++++++----- package.json | 2 +- src/plugin.ts | 249 ++++++++++++++++++++++++--------- tests/plugin-internals.test.ts | 193 +++++++++++++++++++++++++ 5 files changed, 503 insertions(+), 104 deletions(-) create mode 100644 LICENSE create mode 100644 tests/plugin-internals.test.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0de207c --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Lojhan + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index af1c924..e980f0a 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,155 @@ +
+Poku's Logo + # poku-react-testing -React testing helpers and a Poku plugin for DOM-backed test execution. +Enjoying **Poku**? [Give him a star to show your support](https://github.com/wellwelwel/poku) ๐ŸŒŸ + +--- + +๐Ÿ“˜ [**Documentation**](https://github.com/Lojhan/poku-react-testing#readme) + +
+ +--- + +๐Ÿงช [**poku-react-testing**](https://github.com/Lojhan/poku-react-testing) is a **Poku** plugin for React component testing with DOM adapters. + +> [!TIP] +> +> Render React components in isolated test files โ€” automatic TSX loader injection, DOM environment setup, and optional render metrics included. + +--- -## Features +## Quickstart -- Lightweight `render`, `renderHook`, `cleanup`, `screen`, and `fireEvent` helpers. -- Poku plugin that injects TSX loader and DOM setup automatically for `.tsx` and `.jsx` tests. -- DOM adapters: - - `happy-dom` (default) - - `jsdom` (optional) - - custom setup module -- Optional render metrics with configurable reporting. +### Install -## Install + + + + + + +
```bash -npm install --save-dev poku-react-testing poku react react-dom +# Node.js +npm i -D poku-react-testing ``` -If you want to run tests with `jsdom`: + ```bash -npm install --save-dev jsdom +# Bun +bun add -d poku-react-testing ``` -## Usage + -### 1) Configure Poku +```bash +# Deno (optional) +deno add npm:poku-react-testing +``` -```ts +
+ +Install a DOM adapter (at least one is required): + + + + + + +
+ +```bash +# happy-dom (recommended) +npm i -D happy-dom \ + @happy-dom/global-registrator +``` + + + +```bash +# jsdom +npm i -D jsdom +``` + +
+ +### Enable the Plugin + +```js +// poku.config.js import { defineConfig } from 'poku'; -import { reactTestingPlugin } from 'poku-react-testing'; +import { reactTestingPlugin } from 'poku-react-testing/plugin'; export default defineConfig({ plugins: [ reactTestingPlugin({ dom: 'happy-dom', - domUrl: 'http://localhost:3000/', - metrics: false, }), ], }); ``` -### 2) Write tests +### Write Tests ```tsx +// tests/my-component.test.tsx import { afterEach, assert, test } from 'poku'; import { cleanup, render, screen } from 'poku-react-testing'; afterEach(cleanup); -test('renders component', () => { +test('renders a heading', () => { render(

Hello

); assert.strictEqual(screen.getByRole('heading').textContent, 'Hello'); }); ``` -## Metrics +--- + +## Compatibility -Metrics are disabled by default. Enable metrics explicitly: +### Runtime ร— DOM Adapter + +| | Node.js โ‰ฅ 20 | Bun โ‰ฅ 1 | Deno โ‰ฅ 2 | +|---|:---:|:---:|:---:| +| **happy-dom** | โœ… | โœ… | โœ… | +| **jsdom** | โœ… | โœ… | โš ๏ธ | +| **custom setup** | โœ… | โœ… | โœ… | + +> [!NOTE] +> +> `jsdom` under Deno may be unstable depending on Deno's npm compatibility layer for the current `jsdom` version. Use `happy-dom` for Deno environments. + +--- + +## Options ```ts reactTestingPlugin({ + /** + * DOM adapter to use. Defaults to 'happy-dom'. + * - 'happy-dom' โ€” fast, recommended for most tests + * - 'jsdom' โ€” broader browser API coverage + * - { setupModule } โ€” path to a custom DOM setup module + */ + dom: 'happy-dom', + + /** Base URL assigned to the DOM environment. */ + domUrl: 'http://localhost:3000/', + + /** + * Render metrics. Disabled by default. + * Pass `true` for defaults, or an object for fine-grained control. + */ metrics: { enabled: true, - topN: 10, - minDurationMs: 1, + topN: 5, + minDurationMs: 0, reporter(summary) { console.log(summary.topSlowest); }, @@ -74,17 +157,8 @@ reactTestingPlugin({ }); ``` -`metrics: true` is shorthand for enabling metrics with default options. - -## Build and Validate - -```bash -npm run check -npm run build -npm pack --dry-run -``` +--- -## Release +## License -- Push a tag like `v0.1.0` to trigger publish workflow. -- Set repository secret `NPM_TOKEN` with publish permissions. +[ISC](./LICENSE) diff --git a/package.json b/package.json index 46a5808..a59e951 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "test:happy": "cross-env POKU_REACT_TEST_DOM=happy-dom node --import=tsx ./node_modules/poku/lib/bin/index.js --showLogs", "test:jsdom": "cross-env POKU_REACT_TEST_DOM=jsdom node --import=tsx ./node_modules/poku/lib/bin/index.js --showLogs", "test:bun": "cross-env POKU_REACT_TEST_DOM=happy-dom bun ./node_modules/poku/lib/bin/index.js --showLogs && cross-env POKU_REACT_TEST_DOM=jsdom bun ./node_modules/poku/lib/bin/index.js --showLogs", - "test:deno": "cross-env POKU_REACT_TEST_DOM=happy-dom deno run -A npm:poku --showLogs && cross-env POKU_REACT_TEST_DOM=jsdom deno run -A npm:poku --showLogs", + "test:deno": "cross-env POKU_REACT_TEST_DOM=happy-dom deno run -A npm:poku --showLogs", "clean": "rimraf dist", "build": "tsup src/index.ts src/plugin.ts src/react-testing.ts src/dom-setup-happy.ts src/dom-setup-jsdom.ts --format esm --dts --target node20 --sourcemap --clean --tsconfig tsconfig.tsup.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", diff --git a/src/plugin.ts b/src/plugin.ts index 8d4e4fb..6eb063d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -83,6 +83,31 @@ type NormalizedMetricsOptions = { reporter?: (summary: ReactMetricsSummary) => void; }; +type RuntimeSupport = { + supportsNodeLikeImport: boolean; + supportsDenoPreload: boolean; +}; + +type BuildRunnerCommandInput = { + runtime: string; + command: string[]; + file: string; + domSetupPath: string; +}; + +type BuildRunnerCommandOutput = { + shouldHandle: boolean; + command: string[]; +}; + +type EnvSnapshot = { + previousDomUrl: string | undefined; + previousMetricsFlag: string | undefined; +}; + +const DEFAULT_TOP_N = 5; +const DEFAULT_MIN_DURATION_MS = 0; + const isRenderMetricMessage = (message: unknown): message is RenderMetricMessage => { if (!message || typeof message !== 'object') return false; return (message as Record).type === 'POKU_REACT_RENDER_METRIC'; @@ -94,6 +119,19 @@ const getComponentName = (componentName: unknown) => : 'AnonymousComponent'; const isTsxImport = (arg: string) => arg === '--import=tsx' || arg === '--loader=tsx'; +const isNodeRuntime = (runtime: string) => runtime === 'node'; +const isBunRuntime = (runtime: string) => runtime === 'bun'; +const isDenoRuntime = (runtime: string) => runtime === 'deno'; + +const getRuntimeSupport = (runtime: string): RuntimeSupport => ({ + supportsNodeLikeImport: isNodeRuntime(runtime) || isBunRuntime(runtime), + supportsDenoPreload: isDenoRuntime(runtime), +}); + +const canHandleRuntime = (runtime: string) => { + const support = getRuntimeSupport(runtime); + return support.supportsNodeLikeImport || support.supportsDenoPreload; +}; const resolveDomSetupPath = (adapter: ReactDomAdapter | undefined) => { if (!adapter || adapter === 'happy-dom') return happyDomSetupPath; @@ -102,32 +140,41 @@ const resolveDomSetupPath = (adapter: ReactDomAdapter | undefined) => { return resolve(process.cwd(), adapter.setupModule); }; +const getPositiveIntegerOrDefault = (value: unknown, fallback: number) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.floor(numeric); +}; + +const getNonNegativeNumberOrDefault = (value: unknown, fallback: number) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) return fallback; + return numeric; +}; + const normalizeMetricsOptions = ( metrics: ReactTestingPluginOptions['metrics'], ): NormalizedMetricsOptions => { if (metrics === true) { return { enabled: true, - topN: 5, - minDurationMs: 0, + topN: DEFAULT_TOP_N, + minDurationMs: DEFAULT_MIN_DURATION_MS, }; } if (!metrics) { return { enabled: false, - topN: 5, - minDurationMs: 0, + topN: DEFAULT_TOP_N, + minDurationMs: DEFAULT_MIN_DURATION_MS, }; } const normalized: NormalizedMetricsOptions = { enabled: metrics.enabled ?? true, - topN: Number.isFinite(metrics.topN) && Number(metrics.topN) > 0 ? Math.floor(Number(metrics.topN)) : 5, - minDurationMs: - Number.isFinite(metrics.minDurationMs) && Number(metrics.minDurationMs) >= 0 - ? Number(metrics.minDurationMs) - : 0, + topN: getPositiveIntegerOrDefault(metrics.topN, DEFAULT_TOP_N), + minDurationMs: getNonNegativeNumberOrDefault(metrics.minDurationMs, DEFAULT_MIN_DURATION_MS), }; if (metrics.reporter) normalized.reporter = metrics.reporter; @@ -135,16 +182,49 @@ const normalizeMetricsOptions = ( return normalized; }; -/** - * Create a Poku plugin that prepares DOM globals and TSX execution for React tests. - */ -export const createReactTestingPlugin = (options: ReactTestingPluginOptions = {}) => { - const metrics: RenderMetric[] = []; - const previousDomUrl = process.env.POKU_REACT_DOM_URL; - const previousMetricsFlag = process.env.POKU_REACT_ENABLE_METRICS; - const domSetupPath = resolveDomSetupPath(options.dom); - const metricsOptions = normalizeMetricsOptions(options.metrics); +const buildRunnerCommand = ({ + runtime, + command, + file, + domSetupPath, +}: BuildRunnerCommandInput): BuildRunnerCommandOutput => { + const support = getRuntimeSupport(runtime); + + if (!support.supportsNodeLikeImport && !support.supportsDenoPreload) { + return { shouldHandle: false, command }; + } + + if (!reactExtensions.has(extname(file))) { + return { shouldHandle: false, command }; + } + + const fileIndex = command.lastIndexOf(file); + if (fileIndex === -1) return { shouldHandle: false, command }; + const beforeFile = command.slice(1, fileIndex); + const afterFile = command.slice(fileIndex + 1); + + const hasTsx = beforeFile.some(isTsxImport); + const hasNodeLikeDomSetup = beforeFile.some((arg) => arg === `--import=${domSetupPath}`); + const hasDenoDomSetup = beforeFile.some((arg) => arg === `--preload=${domSetupPath}`); + + const extraImports: string[] = []; + if (isNodeRuntime(runtime) && !hasTsx) extraImports.push('--import=tsx'); + if (support.supportsNodeLikeImport && !hasNodeLikeDomSetup) extraImports.push(`--import=${domSetupPath}`); + if (support.supportsDenoPreload && !hasDenoDomSetup) extraImports.push(`--preload=${domSetupPath}`); + + return { + shouldHandle: true, + command: [runtime, ...beforeFile, ...extraImports, file, ...afterFile], + }; +}; + +const captureEnvSnapshot = (): EnvSnapshot => ({ + previousDomUrl: process.env.POKU_REACT_DOM_URL, + previousMetricsFlag: process.env.POKU_REACT_ENABLE_METRICS, +}); + +const applyEnvironmentOptions = (options: ReactTestingPluginOptions, metricsOptions: NormalizedMetricsOptions) => { if (options.domUrl) { process.env.POKU_REACT_DOM_URL = options.domUrl; } @@ -152,29 +232,80 @@ export const createReactTestingPlugin = (options: ReactTestingPluginOptions = {} if (metricsOptions.enabled) { process.env.POKU_REACT_ENABLE_METRICS = '1'; } +}; - return definePlugin({ - name: 'react-testing', - ipc: metricsOptions.enabled, +const restoreEnvironmentOptions = (snapshot: EnvSnapshot) => { + if (typeof snapshot.previousDomUrl === 'undefined') { + delete process.env.POKU_REACT_DOM_URL; + } else { + process.env.POKU_REACT_DOM_URL = snapshot.previousDomUrl; + } - runner(command, file) { - if (command[0] !== 'node') return command; - if (!reactExtensions.has(extname(file))) return command; + if (typeof snapshot.previousMetricsFlag === 'undefined') { + delete process.env.POKU_REACT_ENABLE_METRICS; + } else { + process.env.POKU_REACT_ENABLE_METRICS = snapshot.previousMetricsFlag; + } +}; - const fileIndex = command.lastIndexOf(file); - if (fileIndex === -1) return command; +const selectTopSlowestMetrics = (metrics: RenderMetric[], options: NormalizedMetricsOptions) => + [...metrics] + .filter((metric) => metric.durationMs >= options.minDurationMs) + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, options.topN); + +const createMetricsSummary = ( + metrics: RenderMetric[], + options: NormalizedMetricsOptions, +): ReactMetricsSummary | null => { + if (!options.enabled || metrics.length === 0) return null; + + const topSlowest = selectTopSlowestMetrics(metrics, options); + if (topSlowest.length === 0) return null; + + return { + totalCaptured: metrics.length, + totalReported: topSlowest.length, + topSlowest, + }; +}; + +const printMetricsSummary = (summary: ReactMetricsSummary) => { + const lines = summary.topSlowest.map( + (metric) => + ` - ${metric.componentName} in ${metric.file}: ${metric.durationMs.toFixed(2)}ms`, + ); + + console.log('\n[poku-react-testing] Slowest component renders'); + for (const line of lines) console.log(line); +}; + +/** + * Create a Poku plugin that prepares DOM globals and TSX execution for React tests. + */ +export const createReactTestingPlugin = (options: ReactTestingPluginOptions = {}) => { + const metrics: RenderMetric[] = []; + const envSnapshot = captureEnvSnapshot(); + const domSetupPath = resolveDomSetupPath(options.dom); + const metricsOptions = normalizeMetricsOptions(options.metrics); - const beforeFile = command.slice(1, fileIndex); - const afterFile = command.slice(fileIndex + 1); + applyEnvironmentOptions(options, metricsOptions); - const hasTsx = beforeFile.some(isTsxImport); - const hasDomSetup = beforeFile.some((arg) => arg === `--import=${domSetupPath}`); + return definePlugin({ + name: 'react-testing', + ipc: metricsOptions.enabled, - const extraImports: string[] = []; - if (!hasTsx) extraImports.push('--import=tsx'); - if (!hasDomSetup) extraImports.push(`--import=${domSetupPath}`); + runner(command, file) { + const runtime = command[0]; + if (!runtime) return command; + const result = buildRunnerCommand({ + runtime, + command, + file, + domSetupPath, + }); - return ['node', ...beforeFile, ...extraImports, file, ...afterFile]; + return result.command; }, onTestProcess(child, file) { @@ -192,45 +323,17 @@ export const createReactTestingPlugin = (options: ReactTestingPluginOptions = {} }, teardown() { - if (typeof previousDomUrl === 'undefined') { - delete process.env.POKU_REACT_DOM_URL; - } else { - process.env.POKU_REACT_DOM_URL = previousDomUrl; - } + restoreEnvironmentOptions(envSnapshot); - if (typeof previousMetricsFlag === 'undefined') { - delete process.env.POKU_REACT_ENABLE_METRICS; - } else { - process.env.POKU_REACT_ENABLE_METRICS = previousMetricsFlag; - } - - if (!metricsOptions.enabled || metrics.length === 0) return; - - const topSlowest = [...metrics] - .filter((metric) => metric.durationMs >= metricsOptions.minDurationMs) - .sort((a, b) => b.durationMs - a.durationMs) - .slice(0, metricsOptions.topN); - - if (topSlowest.length === 0) return; - - const summary: ReactMetricsSummary = { - totalCaptured: metrics.length, - totalReported: topSlowest.length, - topSlowest, - }; + const summary = createMetricsSummary(metrics, metricsOptions); + if (!summary) return; if (metricsOptions.reporter) { metricsOptions.reporter(summary); return; } - const lines = topSlowest.map( - (metric) => - ` - ${metric.componentName} in ${metric.file}: ${metric.durationMs.toFixed(2)}ms`, - ); - - console.log('\n[poku-react-testing] Slowest component renders'); - for (const line of lines) console.log(line); + printMetricsSummary(summary); }, }); }; @@ -239,3 +342,17 @@ export const createReactTestingPlugin = (options: ReactTestingPluginOptions = {} * Alias for `createReactTestingPlugin`. */ export const reactTestingPlugin = createReactTestingPlugin; + +export const __internal = { + buildRunnerCommand, + canHandleRuntime, + captureEnvSnapshot, + applyEnvironmentOptions, + restoreEnvironmentOptions, + normalizeMetricsOptions, + selectTopSlowestMetrics, + createMetricsSummary, + getComponentName, + isRenderMetricMessage, + resolveDomSetupPath, +}; diff --git a/tests/plugin-internals.test.ts b/tests/plugin-internals.test.ts new file mode 100644 index 0000000..397de89 --- /dev/null +++ b/tests/plugin-internals.test.ts @@ -0,0 +1,193 @@ +import { assert, test } from 'poku'; +import { __internal } from '../src/plugin.ts'; + +const ORIGINAL_DOM_URL = process.env.POKU_REACT_DOM_URL; +const ORIGINAL_METRICS = process.env.POKU_REACT_ENABLE_METRICS; + +const restoreEnvBaseline = () => { + if (typeof ORIGINAL_DOM_URL === 'undefined') { + delete process.env.POKU_REACT_DOM_URL; + } else { + process.env.POKU_REACT_DOM_URL = ORIGINAL_DOM_URL; + } + + if (typeof ORIGINAL_METRICS === 'undefined') { + delete process.env.POKU_REACT_ENABLE_METRICS; + } else { + process.env.POKU_REACT_ENABLE_METRICS = ORIGINAL_METRICS; + } +}; + +test('normalizes metrics defaults when disabled', () => { + const normalized = __internal.normalizeMetricsOptions(undefined); + + assert.strictEqual(normalized.enabled, false); + assert.strictEqual(normalized.topN, 5); + assert.strictEqual(normalized.minDurationMs, 0); +}); + +test('normalizes metrics with option object', () => { + const reporterCalls: number[] = []; + const normalized = __internal.normalizeMetricsOptions({ + enabled: true, + topN: 9.8, + minDurationMs: 2.5, + reporter(summary) { + reporterCalls.push(summary.totalCaptured); + }, + }); + + assert.strictEqual(normalized.enabled, true); + assert.strictEqual(normalized.topN, 9); + assert.strictEqual(normalized.minDurationMs, 2.5); + assert.strictEqual(typeof normalized.reporter, 'function'); + assert.strictEqual(reporterCalls.length, 0); +}); + +test('buildRunnerCommand injects tsx and dom setup for node', () => { + const result = __internal.buildRunnerCommand({ + runtime: 'node', + command: ['node', '--trace-warnings', 'tests/example.test.tsx'], + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + }); + + assert.strictEqual(result.shouldHandle, true); + assert.deepStrictEqual(result.command, [ + 'node', + '--trace-warnings', + '--import=tsx', + '--import=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + ]); +}); + +test('buildRunnerCommand injects dom setup for bun without tsx import', () => { + const result = __internal.buildRunnerCommand({ + runtime: 'bun', + command: ['bun', 'tests/example.test.tsx'], + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + }); + + assert.strictEqual(result.shouldHandle, true); + assert.deepStrictEqual(result.command, [ + 'bun', + '--import=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + ]); +}); + +test('buildRunnerCommand injects preload for deno', () => { + const result = __internal.buildRunnerCommand({ + runtime: 'deno', + command: ['deno', 'run', '-A', 'tests/example.test.tsx'], + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + }); + + assert.strictEqual(result.shouldHandle, true); + assert.deepStrictEqual(result.command, [ + 'deno', + 'run', + '-A', + '--preload=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + ]); +}); + +test('buildRunnerCommand leaves unsupported runtime unchanged', () => { + const original = ['python', 'tests/example.test.tsx']; + const result = __internal.buildRunnerCommand({ + runtime: 'python', + command: original, + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + }); + + assert.strictEqual(result.shouldHandle, false); + assert.deepStrictEqual(result.command, original); +}); + +test('buildRunnerCommand leaves non-react extension unchanged', () => { + const original = ['node', 'tests/example.test.ts']; + const result = __internal.buildRunnerCommand({ + runtime: 'node', + command: original, + file: 'tests/example.test.ts', + domSetupPath: '/tmp/dom-setup.ts', + }); + + assert.strictEqual(result.shouldHandle, false); + assert.deepStrictEqual(result.command, original); +}); + +test('environment helpers apply and restore options', () => { + restoreEnvBaseline(); + + const snapshot = __internal.captureEnvSnapshot(); + __internal.applyEnvironmentOptions( + { domUrl: 'http://example.local/', metrics: true }, + __internal.normalizeMetricsOptions(true), + ); + + assert.strictEqual(process.env.POKU_REACT_DOM_URL, 'http://example.local/'); + assert.strictEqual(process.env.POKU_REACT_ENABLE_METRICS, '1'); + + __internal.restoreEnvironmentOptions(snapshot); + + assert.strictEqual(process.env.POKU_REACT_DOM_URL, ORIGINAL_DOM_URL); + assert.strictEqual(process.env.POKU_REACT_ENABLE_METRICS, ORIGINAL_METRICS); +}); + +test('createMetricsSummary returns ordered top metrics with filters', () => { + const metrics = [ + { file: 'a', componentName: 'A', durationMs: 0.4 }, + { file: 'b', componentName: 'B', durationMs: 4.2 }, + { file: 'c', componentName: 'C', durationMs: 3.3 }, + ]; + + const summary = __internal.createMetricsSummary( + metrics, + __internal.normalizeMetricsOptions({ enabled: true, topN: 2, minDurationMs: 1 }), + ); + + assert.ok(summary); + assert.strictEqual(summary?.totalCaptured, 3); + assert.strictEqual(summary?.totalReported, 2); + assert.deepStrictEqual( + summary?.topSlowest.map((item) => item.componentName), + ['B', 'C'], + ); +}); + +test('createMetricsSummary returns null when disabled', () => { + const summary = __internal.createMetricsSummary( + [{ file: 'a', componentName: 'A', durationMs: 8 }], + __internal.normalizeMetricsOptions(false), + ); + + assert.strictEqual(summary, null); +}); + +test('getComponentName falls back for non-string values', () => { + assert.strictEqual(__internal.getComponentName('MyComp'), 'MyComp'); + assert.strictEqual(__internal.getComponentName(''), 'AnonymousComponent'); + assert.strictEqual(__internal.getComponentName(null), 'AnonymousComponent'); +}); + +test('isRenderMetricMessage validates expected payloads', () => { + assert.strictEqual(__internal.isRenderMetricMessage({ type: 'POKU_REACT_RENDER_METRIC' }), true); + assert.strictEqual(__internal.isRenderMetricMessage({ type: 'OTHER' }), false); + assert.strictEqual(__internal.isRenderMetricMessage(null), false); +}); + +test('resolveDomSetupPath resolves built-in and custom adapters', () => { + const happyPath = __internal.resolveDomSetupPath('happy-dom'); + const jsdomPath = __internal.resolveDomSetupPath('jsdom'); + const customPath = __internal.resolveDomSetupPath({ setupModule: 'tests/setup/custom.ts' }); + + assert.ok(happyPath.includes('dom-setup-happy')); + assert.ok(jsdomPath.includes('dom-setup-jsdom')); + assert.ok(customPath.endsWith('/tests/setup/custom.ts')); +}); From 3c87e1dd112910cec9a8e7808124ced867bc4904 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 31 Mar 2026 10:53:35 -0300 Subject: [PATCH 2/4] fix: switch to MIT license; fix happy-dom dispatchEvent crash under Deno v2 - Replace ISC with MIT in LICENSE, package.json, and README - Wrap GlobalRegistrator's dispatchEvent in dom-setup-happy.ts to silently drop native Deno events that fail happy-dom's instanceof check (Deno v2 dispatches a built-in load event after module init) --- LICENSE | 30 ++++++++++++++++++------------ README.md | 2 +- package.json | 2 +- src/dom-setup-happy.ts | 15 +++++++++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/LICENSE b/LICENSE index 0de207c..d79d4a5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,21 @@ -ISC License +MIT License Copyright (c) 2026 Lojhan -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e980f0a..34e9242 100644 --- a/README.md +++ b/README.md @@ -161,4 +161,4 @@ reactTestingPlugin({ ## License -[ISC](./LICENSE) +[MIT](./LICENSE) diff --git a/package.json b/package.json index a59e951..8a03707 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "jsdom" ], "author": "", - "license": "ISC", + "license": "MIT", "publishConfig": { "access": "public", "provenance": true diff --git a/src/dom-setup-happy.ts b/src/dom-setup-happy.ts index 1f883bd..6d391c4 100644 --- a/src/dom-setup-happy.ts +++ b/src/dom-setup-happy.ts @@ -13,6 +13,21 @@ if (!globalThis.window || !globalThis.document) { GlobalRegistrator.register({ url: configuredUrl, }); + + // Deno v2 dispatches a native `load` event after module initialization. + // happy-dom's dispatchEvent rejects events that aren't instances of its + // own Event class. Intercept and silently drop those to avoid a crash. + const happyDispatchEvent = globalThis.dispatchEvent; + globalThis.dispatchEvent = function (event: Event): boolean { + try { + return happyDispatchEvent.call(this, event); + } catch (e) { + if (e instanceof TypeError && String(e.message).includes('not of type')) { + return false; + } + throw e; + } + }; } if (typeof reactGlobal.IS_REACT_ACT_ENVIRONMENT === 'undefined') { From 01e1dbdd32fb2a04e768bab4d6929635a40a20b0 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 31 Mar 2026 11:04:19 -0300 Subject: [PATCH 3/4] fix: restore both Event and dispatchEvent after happy-dom registration under Deno v2 happy-dom's GlobalRegistrator replaces both globalThis.Event and globalThis.dispatchEvent. Deno v2 calls dispatchLoadEvent after each module finishes loading, which does: globalThis.dispatchEvent(new Event('load')). - If only dispatchEvent is restored, new Event('load') returns a happy-dom Event that lacks Deno's internal symbol properties, causing TypeError: Cannot set properties of undefined (setting 'target') - Snapshot both Event and dispatchEvent before GlobalRegistrator.register() and restore both afterwards; React and @testing-library/dom dispatch events through element.dispatchEvent(), never through globalThis. Also updates ci_compatibility-deno.yml to use denoland/setup-deno@v2. --- .github/workflows/ci_compatibility-deno.yml | 2 +- src/dom-setup-happy.ts | 37 +++++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci_compatibility-deno.yml b/.github/workflows/ci_compatibility-deno.yml index ceb41dc..2d8d6ab 100644 --- a/.github/workflows/ci_compatibility-deno.yml +++ b/.github/workflows/ci_compatibility-deno.yml @@ -39,7 +39,7 @@ jobs: node-version: lts/* - name: โž• Actions - Setup Deno - uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@v2 with: deno-version: ${{ matrix.deno-version }} diff --git a/src/dom-setup-happy.ts b/src/dom-setup-happy.ts index 6d391c4..e8ec4b9 100644 --- a/src/dom-setup-happy.ts +++ b/src/dom-setup-happy.ts @@ -10,24 +10,33 @@ const defaultUrl = 'http://localhost:3000/'; const configuredUrl = process.env.POKU_REACT_DOM_URL || defaultUrl; if (!globalThis.window || !globalThis.document) { + // Deno v2 calls `dispatchLoadEvent` after each module finishes loading. + // It does: `globalThis.dispatchEvent(new Event("load"))`. + // happy-dom's GlobalRegistrator replaces both `globalThis.Event` and + // `globalThis.dispatchEvent` with its own versions, breaking Deno in two + // ways depending on version: + // - older Deno v2: happy-dom's dispatchEvent throws TypeError (wrong Event type) + // - newer Deno v2 (โ‰ฅ2.7): happy-dom's dispatchEvent freezes the process + // - with only dispatchEvent restored: `new Event("load")` returns a + // happy-dom Event that lacks Deno's internal symbol property, causing + // "Cannot set properties of undefined (setting 'target')" + // Fix: snapshot both `Event` and `dispatchEvent` before registration and + // restore them after. React and @testing-library/dom dispatch events through + // element.dispatchEvent(), never through globalThis, so nothing is lost. + const isDenoRuntime = typeof Deno !== 'undefined'; + const nativeEvent = isDenoRuntime ? globalThis.Event : undefined; + const nativeDispatchEvent = isDenoRuntime + ? globalThis.dispatchEvent?.bind(globalThis) + : undefined; + GlobalRegistrator.register({ url: configuredUrl, }); - // Deno v2 dispatches a native `load` event after module initialization. - // happy-dom's dispatchEvent rejects events that aren't instances of its - // own Event class. Intercept and silently drop those to avoid a crash. - const happyDispatchEvent = globalThis.dispatchEvent; - globalThis.dispatchEvent = function (event: Event): boolean { - try { - return happyDispatchEvent.call(this, event); - } catch (e) { - if (e instanceof TypeError && String(e.message).includes('not of type')) { - return false; - } - throw e; - } - }; + if (isDenoRuntime) { + if (nativeEvent) (globalThis as unknown as Record).Event = nativeEvent; + if (nativeDispatchEvent) globalThis.dispatchEvent = nativeDispatchEvent; + } } if (typeof reactGlobal.IS_REACT_ACT_ENVIRONMENT === 'undefined') { From cd902727110ed69cf78bf34f5c7dee2b03480392 Mon Sep 17 00:00:00 2001 From: lojhan Date: Tue, 31 Mar 2026 11:07:37 -0300 Subject: [PATCH 4/4] fix: declare Deno type, remove stray empty import in dom-setup-happy --- src/dom-setup-happy.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/dom-setup-happy.ts b/src/dom-setup-happy.ts index e8ec4b9..31dc60e 100644 --- a/src/dom-setup-happy.ts +++ b/src/dom-setup-happy.ts @@ -1,5 +1,7 @@ import { GlobalRegistrator } from '@happy-dom/global-registrator'; +declare const Deno: unknown; + type ReactActGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; }; @@ -10,19 +12,6 @@ const defaultUrl = 'http://localhost:3000/'; const configuredUrl = process.env.POKU_REACT_DOM_URL || defaultUrl; if (!globalThis.window || !globalThis.document) { - // Deno v2 calls `dispatchLoadEvent` after each module finishes loading. - // It does: `globalThis.dispatchEvent(new Event("load"))`. - // happy-dom's GlobalRegistrator replaces both `globalThis.Event` and - // `globalThis.dispatchEvent` with its own versions, breaking Deno in two - // ways depending on version: - // - older Deno v2: happy-dom's dispatchEvent throws TypeError (wrong Event type) - // - newer Deno v2 (โ‰ฅ2.7): happy-dom's dispatchEvent freezes the process - // - with only dispatchEvent restored: `new Event("load")` returns a - // happy-dom Event that lacks Deno's internal symbol property, causing - // "Cannot set properties of undefined (setting 'target')" - // Fix: snapshot both `Event` and `dispatchEvent` before registration and - // restore them after. React and @testing-library/dom dispatch events through - // element.dispatchEvent(), never through globalThis, so nothing is lost. const isDenoRuntime = typeof Deno !== 'undefined'; const nativeEvent = isDenoRuntime ? globalThis.Event : undefined; const nativeDispatchEvent = isDenoRuntime