diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff27c1..d7aaf22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,15 @@ ## [1.1.0](https://github.com/Lojhan/poku-react-testing/compare/v1.0.1...v1.1.0) (2026-03-31) - ### Features -* optimize react testing runtime and add prettier ([#6](https://github.com/Lojhan/poku-react-testing/issues/6)) ([424194b](https://github.com/Lojhan/poku-react-testing/commit/424194b2c5bdc5f8c3d3c7a41bc91b50d51dd38c)) +- optimize react testing runtime and add prettier ([#6](https://github.com/Lojhan/poku-react-testing/issues/6)) ([424194b](https://github.com/Lojhan/poku-react-testing/commit/424194b2c5bdc5f8c3d3c7a41bc91b50d51dd38c)) ## [1.0.1](https://github.com/Lojhan/poku-react-testing/compare/v1.0.0...v1.0.1) (2026-03-31) - ### Bug Fixes -* add repository metadata required for npm provenance ([#4](https://github.com/Lojhan/poku-react-testing/issues/4)) ([f20b16a](https://github.com/Lojhan/poku-react-testing/commit/f20b16a9ab72bb957743c7739a926d0bfb851f68)) +- add repository metadata required for npm provenance ([#4](https://github.com/Lojhan/poku-react-testing/issues/4)) ([f20b16a](https://github.com/Lojhan/poku-react-testing/commit/f20b16a9ab72bb957743c7739a926d0bfb851f68)) ## 1.0.0 (2026-03-31) diff --git a/benchmark/REPORT.md b/benchmark/REPORT.md index 6896647..e38c66a 100644 --- a/benchmark/REPORT.md +++ b/benchmark/REPORT.md @@ -1,49 +1,49 @@ # React Testing Framework Benchmark Report -> Generated: Tue, 31 Mar 2026 21:00:50 GMT +> Generated: Wed, 01 Apr 2026 12:41:50 GMT ## Environment -| Property | Value | -|---|---| -| Node.js | v22.5.1 | -| Platform | darwin 25.4.0 | -| CPU | Apple M3 Pro | -| CPU Cores | 12 | -| Total RAM | 18.0 GB | -| Runs/scenario | 7 (trim ±1) | +| Property | Value | +| ------------- | ------------- | +| Node.js | v22.5.1 | +| Platform | darwin 25.4.0 | +| CPU | Apple M3 Pro | +| CPU Cores | 12 | +| Total RAM | 18.0 GB | +| Runs/scenario | 7 (trim ±1) | ## Scenarios Each scenario runs the **same 9 React tests** across 5 test files: -| Test File | Tests | -|---|---| -| 'counter.test.jsx' | 1 — stateful counter, event interaction | -| 'hooks.test.jsx' | 2 — custom hook harness + `renderHook` | -| 'lifecycle.test.jsx' | 2 — `rerender`, `unmount` + effect cleanup | -| 'context.test.jsx' | 1 — `createContext` + wrapper injection | -| 'concurrency.test.jsx' | 2 — React 19 `use()` + `useTransition` | +| Test File | Tests | +| ---------------------- | ------------------------------------------ | +| 'counter.test.jsx' | 1 — stateful counter, event interaction | +| 'hooks.test.jsx' | 2 — custom hook harness + `renderHook` | +| 'lifecycle.test.jsx' | 2 — `rerender`, `unmount` + effect cleanup | +| 'context.test.jsx' | 1 — `createContext` + wrapper injection | +| 'concurrency.test.jsx' | 2 — React 19 `use()` + `useTransition` | ### Frameworks under test -| Combination | DOM layer | Assertion style | -|---|---|---| -| poku + poku-react-testing | happy-dom | `assert.strictEqual` | -| poku + poku-react-testing | jsdom | `assert.strictEqual` | -| jest 29 + @testing-library/react | jsdom (jest-environment-jsdom) | `expect().toBe()` | -| vitest 3 + @testing-library/react | jsdom | `expect().toBe()` | -| vitest 3 + @testing-library/react | happy-dom | `expect().toBe()` | +| Combination | DOM layer | Assertion style | +| --------------------------------- | ------------------------------ | -------------------- | +| poku + poku-react-testing | happy-dom | `assert.strictEqual` | +| poku + poku-react-testing | jsdom | `assert.strictEqual` | +| jest 29 + @testing-library/react | jsdom (jest-environment-jsdom) | `expect().toBe()` | +| vitest 3 + @testing-library/react | jsdom | `expect().toBe()` | +| vitest 3 + @testing-library/react | happy-dom | `expect().toBe()` | ## Results | Scenario | Mean | Min | Max | Stdev | Peak RSS | vs poku+happy-dom | -|--------------------|--------|--------|--------|--------|----------|-------------------| -| poku + happy-dom | 1.073s | 0.996s | 1.230s | 0.085s | 163.3 MB | *(baseline)* | -| poku + jsdom | 1.060s | 1.007s | 1.177s | 0.060s | 163.4 MB | -1% | -| jest + jsdom | 0.859s | 0.779s | 0.929s | 0.050s | 206.2 MB | -20% | -| vitest + jsdom | 0.964s | 0.950s | 0.987s | 0.017s | 148.0 MB | -10% | -| vitest + happy-dom | 0.838s | 0.812s | 0.864s | 0.021s | 116.3 MB | -22% | +| ------------------ | ------ | ------ | ------ | ------ | -------- | ----------------- | +| poku + happy-dom | 0.560s | 0.515s | 0.600s | 0.033s | 154.3 MB | _(baseline)_ | +| poku + jsdom | 0.444s | 0.429s | 0.451s | 0.008s | 157.1 MB | -21% | +| jest + jsdom | 1.040s | 0.975s | 1.135s | 0.056s | 203.4 MB | +86% | +| vitest + jsdom | 1.193s | 1.129s | 1.269s | 0.057s | 152.3 MB | +113% | +| vitest + happy-dom | 1.041s | 0.990s | 1.126s | 0.047s | 117.1 MB | +86% | > **Wall-clock time** is measured with `performance.now()` around the child-process spawn. > **Peak RSS** is captured via `/usr/bin/time -l` on macOS (bytes → MB). @@ -53,44 +53,44 @@ Each scenario runs the **same 9 React tests** across 5 test files: ### Overall ranking (mean wall-clock time) -1. **vitest + happy-dom** — 0.838s -2. **jest + jsdom** — 0.859s -3. **vitest + jsdom** — 0.964s -4. **poku + jsdom** — 1.060s -5. **poku + happy-dom** — 1.073s +1. **poku + jsdom** — 0.444s +2. **poku + happy-dom** — 0.560s +3. **jest + jsdom** — 1.040s +4. **vitest + happy-dom** — 1.041s +5. **vitest + jsdom** — 1.193s ### Speed comparison -- poku+happy-dom vs jest+jsdom: jest is **-20% faster** -- poku+happy-dom vs vitest+jsdom: vitest is **-10% faster** -- jest+jsdom vs vitest+jsdom: vitest is **12% slower** than jest +- poku+happy-dom vs jest+jsdom: jest is **86% slower** +- poku+happy-dom vs vitest+jsdom: vitest is **113% slower** +- jest+jsdom vs vitest+jsdom: vitest is **15% slower** than jest ### DOM adapter impact -- **poku**: happy-dom vs jsdom — jsdom is **-1% faster** +- **poku**: happy-dom vs jsdom — jsdom is **-21% faster** - **vitest**: happy-dom vs jsdom — jsdom is **15% slower** ### Memory footprint -- **vitest + happy-dom**: 116.3 MB peak RSS -- **vitest + jsdom**: 148.0 MB peak RSS -- **poku + happy-dom**: 163.3 MB peak RSS -- **poku + jsdom**: 163.4 MB peak RSS -- **jest + jsdom**: 206.2 MB peak RSS +- **vitest + happy-dom**: 117.1 MB peak RSS +- **vitest + jsdom**: 152.3 MB peak RSS +- **poku + happy-dom**: 154.3 MB peak RSS +- **poku + jsdom**: 157.1 MB peak RSS +- **jest + jsdom**: 203.4 MB peak RSS ### Consistency (lower stdev = more predictable) -- **vitest + jsdom**: σ = 0.017s -- **vitest + happy-dom**: σ = 0.021s -- **jest + jsdom**: σ = 0.050s -- **poku + jsdom**: σ = 0.060s -- **poku + happy-dom**: σ = 0.085s +- **poku + jsdom**: σ = 0.008s +- **poku + happy-dom**: σ = 0.033s +- **vitest + happy-dom**: σ = 0.047s +- **jest + jsdom**: σ = 0.056s +- **vitest + jsdom**: σ = 0.057s ## Key findings -- **Fastest**: vitest + happy-dom — 0.838s mean -- **Slowest**: poku + happy-dom — 1.073s mean -- **Speed spread**: 28% difference between fastest and slowest +- **Fastest**: poku + jsdom — 0.444s mean +- **Slowest**: vitest + jsdom — 1.193s mean +- **Speed spread**: 169% difference between fastest and slowest ### Interpretation @@ -100,6 +100,7 @@ processes with minimal bootstrap — means cold-start overhead is proportional t files, not to the framework's own initialization. **jest** carries the heaviest startup cost due to: + 1. Babel transformation of every TSX file on first run (no persistent cache in this benchmark) 2. 'jest-worker' process pool initialisation 3. JSDOM environment setup per test file diff --git a/benchmark/package-lock.json b/benchmark/package-lock.json index 35767b9..4182dc8 100644 --- a/benchmark/package-lock.json +++ b/benchmark/package-lock.json @@ -27,7 +27,7 @@ }, "..": { "name": "poku-react-testing", - "version": "1.0.0", + "version": "1.1.0", "dev": true, "license": "MIT", "dependencies": { @@ -35,6 +35,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "^20.8.9", + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@types/jsdom": "^28.0.1", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -42,7 +43,8 @@ "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.1.0", + "poku": "4.2.0", + "prettier": "^3.6.2", "react": "^19.2.4", "react-dom": "^19.2.4", "rimraf": "^6.0.1", diff --git a/benchmark/poku.config.js b/benchmark/poku.config.js index 03ae003..a2d3d9e 100644 --- a/benchmark/poku.config.js +++ b/benchmark/poku.config.js @@ -1,9 +1,12 @@ import { defineConfig } from 'poku'; import { reactTestingPlugin } from 'poku-react-testing/plugin'; -const configuredDom = process.env.POKU_REACT_TEST_DOM; -const dom = configuredDom === 'jsdom' ? 'jsdom' : 'happy-dom'; +const dom = process.env.POKU_REACT_TEST_DOM; +if (!dom) { + throw new Error('POKU_REACT_TEST_DOM environment variable is not set'); +} export default defineConfig({ plugins: [reactTestingPlugin({ dom })], + isolation: 'none', }); diff --git a/benchmark/results.json b/benchmark/results.json index 1402af6..2d18d6e 100644 --- a/benchmark/results.json +++ b/benchmark/results.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-03-31T21:00:50.386Z", + "timestamp": "2026-04-01T12:41:50.774Z", "system": { "nodeVersion": "v22.5.1", "platform": "darwin 25.4.0", @@ -14,68 +14,68 @@ "results": [ { "label": "poku + happy-dom", - "mean": 1073.1924668, - "min": 995.5031250000002, - "max": 1229.6369170000003, - "stddev": 84.66710558647982, - "meanRss": 171232460.8, - "meanUserCpu": 4396, - "meanSysCpu": 1584, + "mean": 559.9537916000002, + "min": 515.060708, + "max": 599.9880840000001, + "stddev": 33.06234198220535, + "meanRss": 161808384, + "meanUserCpu": 672, + "meanSysCpu": 202, "runs": 7, "validRuns": 7, "failures": 0 }, { "label": "poku + jsdom", - "mean": 1060.1384749999997, - "min": 1007.1474579999995, - "max": 1176.5342500000006, - "stddev": 60.012456221527664, - "meanRss": 171294720, - "meanUserCpu": 3228, - "meanSysCpu": 1440, + "mean": 443.6319584000001, + "min": 428.5047089999998, + "max": 450.59829100000024, + "stddev": 7.9857833050707905, + "meanRss": 164767334.4, + "meanUserCpu": 432, + "meanSysCpu": 92, "runs": 7, "validRuns": 7, "failures": 0 }, { "label": "jest + jsdom", - "mean": 858.7310334000001, - "min": 778.734042, - "max": 929.1150420000013, - "stddev": 50.38267849622563, - "meanRss": 216186880, - "meanUserCpu": 778, - "meanSysCpu": 176, + "mean": 1040.1169332000002, + "min": 975.1540420000001, + "max": 1134.9154159999998, + "stddev": 56.06532298235639, + "meanRss": 213300019.2, + "meanUserCpu": 936, + "meanSysCpu": 228, "runs": 7, "validRuns": 7, "failures": 0 }, { "label": "vitest + jsdom", - "mean": 964.3196584000004, - "min": 949.5501660000009, - "max": 986.9945420000004, - "stddev": 16.60550682561403, - "meanRss": 155179417.6, - "meanUserCpu": 3336, - "meanSysCpu": 1102, + "mean": 1192.7835997999996, + "min": 1129.2755830000006, + "max": 1268.7202919999982, + "stddev": 56.83080807294834, + "meanRss": 159652249.6, + "meanUserCpu": 3798, + "meanSysCpu": 1380, "runs": 7, "validRuns": 7, "failures": 0 }, { "label": "vitest + happy-dom", - "mean": 837.8697498000001, - "min": 812.1916249999995, - "max": 864.1250409999993, - "stddev": 20.924101623413893, - "meanRss": 121916620.8, - "meanUserCpu": 2942, - "meanSysCpu": 1006, + "mean": 1041.3667920000007, + "min": 990.0950420000008, + "max": 1126.3293750000012, + "stddev": 47.26983715919258, + "meanRss": 122814464, + "meanUserCpu": 3346, + "meanSysCpu": 1156, "runs": 7, "validRuns": 7, "failures": 0 } ] -} \ No newline at end of file +} diff --git a/benchmark/tests/poku/concurrency.test.jsx b/benchmark/tests/poku/concurrency.test.jsx index 6a68b10..cb15731 100644 --- a/benchmark/tests/poku/concurrency.test.jsx +++ b/benchmark/tests/poku/concurrency.test.jsx @@ -9,7 +9,7 @@ const ResourceView = ({ resource }) => { return

{value}

; }; -test('renders a resolved use() resource under Suspense', () => { +test('renders a resolved use() resource under Suspense', async () => { const value = 'Loaded from use() resource'; const resolvedResource = { status: 'fulfilled', @@ -30,7 +30,7 @@ test('renders a resolved use() resource under Suspense', () => { ); }); -await test('runs urgent and transition update pipeline', async () => { +test('runs urgent and transition update pipeline', async () => { const TransitionPipeline = () => { const [urgentState, setUrgentState] = useState('idle'); const [deferredState, setDeferredState] = useState('idle'); diff --git a/benchmark/tests/poku/context.test.jsx b/benchmark/tests/poku/context.test.jsx index 8366e60..3d37996 100644 --- a/benchmark/tests/poku/context.test.jsx +++ b/benchmark/tests/poku/context.test.jsx @@ -11,7 +11,7 @@ const ThemeLabel = () => { return

Theme: {theme}

; }; -test('injects context values via wrapper', () => { +test('injects context values via wrapper', async () => { const ThemeWrapper = ({ children }) => ( {children} ); diff --git a/benchmark/tests/poku/counter.test.jsx b/benchmark/tests/poku/counter.test.jsx index 49e5938..8e77616 100644 --- a/benchmark/tests/poku/counter.test.jsx +++ b/benchmark/tests/poku/counter.test.jsx @@ -17,7 +17,7 @@ const Counter = ({ initialCount = 0 }) => { ); }; -test('renders and updates a React component', () => { +test('renders and updates a React component', async () => { render(); assert.strictEqual( diff --git a/benchmark/tests/poku/hooks.test.jsx b/benchmark/tests/poku/hooks.test.jsx index dd8782b..a945d6b 100644 --- a/benchmark/tests/poku/hooks.test.jsx +++ b/benchmark/tests/poku/hooks.test.jsx @@ -32,7 +32,7 @@ const HookHarness = () => { ); }; -test('tests custom hooks through a component harness', () => { +test('tests custom hooks through a component harness', async () => { render(); assert.strictEqual( @@ -46,7 +46,7 @@ test('tests custom hooks through a component harness', () => { ); }); -test('tests hook logic directly with renderHook', () => { +test('tests hook logic directly with renderHook', async () => { const { result } = renderHook(({ initial }) => useToggle(initial), { initialProps: { initial: true }, }); diff --git a/benchmark/tests/poku/lifecycle.test.jsx b/benchmark/tests/poku/lifecycle.test.jsx index 87f56f9..423c3a9 100644 --- a/benchmark/tests/poku/lifecycle.test.jsx +++ b/benchmark/tests/poku/lifecycle.test.jsx @@ -6,7 +6,7 @@ afterEach(cleanup); const Greeting = ({ name }) =>

Hello {name}

; -test('rerender updates component props in place', () => { +test('rerender updates component props in place', async () => { const view = render(); assert.strictEqual( @@ -20,7 +20,7 @@ test('rerender updates component props in place', () => { ); }); -test('unmount runs effect cleanup logic', () => { +test('unmount runs effect cleanup logic', async () => { let cleaned = false; const WithEffect = () => { diff --git a/package-lock.json b/package-lock.json index 2a134a1..056cfc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.1.0", + "poku": "4.2.0", "prettier": "^3.6.2", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -2127,9 +2127,9 @@ } }, "node_modules/poku": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/poku/-/poku-4.1.0.tgz", - "integrity": "sha512-gHyR0sE1zZ7qDowChiToZjQ75Dwqf0JDA3cHh5hVD8K00HOnVW4nr9XlximThE/AyenlxVatSEuiLfwYFcJS7w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/poku/-/poku-4.2.0.tgz", + "integrity": "sha512-GygMGFGgEJ9kfs6Z+QPg/ODs9OF3oGHN8+hYIxtBox3pwYISO+Vu660vH1e+YzjpGoaoy2o5y6YwE1tX5yZx3Q==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 835ba9d..4d228dc 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.1.0", + "poku": "4.2.0", "prettier": "^3.6.2", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/poku.config.js b/poku.config.js index 7c83735..f73abed 100644 --- a/poku.config.js +++ b/poku.config.js @@ -1,9 +1,12 @@ import { defineConfig } from 'poku'; import { reactTestingPlugin } from './src/plugin.ts'; -const configuredDom = process.env.POKU_REACT_TEST_DOM; -const dom = configuredDom === 'jsdom' ? 'jsdom' : 'happy-dom'; +const dom = process.env.POKU_REACT_TEST_DOM; +if (!dom) { + throw new Error('POKU_REACT_TEST_DOM environment variable is not set'); +} export default defineConfig({ plugins: [reactTestingPlugin({ dom })], + isolation: 'none', }); diff --git a/src/plugin-command.ts b/src/plugin-command.ts new file mode 100644 index 0000000..587eef6 --- /dev/null +++ b/src/plugin-command.ts @@ -0,0 +1,136 @@ +import type { ReactDomAdapter } from './plugin-types.ts'; +import { existsSync } from 'node:fs'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +const resolveSetupModulePath = (baseName: string) => { + const jsPath = resolve(currentDir, `${baseName}.js`); + if (existsSync(jsPath)) return jsPath; + + return resolve(currentDir, `${baseName}.ts`); +}; + +const happyDomSetupPath = resolveSetupModulePath('dom-setup-happy'); +const jsdomSetupPath = resolveSetupModulePath('dom-setup-jsdom'); + +const reactExtensions = new Set(['.tsx', '.jsx']); + +export type RuntimeSupport = { + supportsNodeLikeImport: boolean; + supportsDenoPreload: boolean; +}; + +export type BuildRunnerCommandInput = { + runtime: string; + command: string[]; + file: string; + domSetupPath: string; + runtimeOptionArgs: string[]; +}; + +export type BuildRunnerCommandOutput = { + shouldHandle: boolean; + command: string[]; +}; + +const isTsxImport = (arg: string) => + arg === '--import=tsx' || arg === '--loader=tsx'; + +export const isNodeRuntime = (runtime: string) => runtime === 'node'; +const isBunRuntime = (runtime: string) => runtime === 'bun'; +const isDenoRuntime = (runtime: string) => runtime === 'deno'; + +export const getRuntimeSupport = (runtime: string): RuntimeSupport => ({ + supportsNodeLikeImport: isNodeRuntime(runtime) || isBunRuntime(runtime), + supportsDenoPreload: isDenoRuntime(runtime), +}); + +export const canHandleRuntime = (runtime: string) => { + const support = getRuntimeSupport(runtime); + return support.supportsNodeLikeImport || support.supportsDenoPreload; +}; + +export const resolveDomSetupPath = (adapter: ReactDomAdapter | undefined) => { + if (!adapter || adapter === 'happy-dom') return happyDomSetupPath; + if (adapter === 'jsdom') return jsdomSetupPath; + + return resolve(process.cwd(), adapter.setupModule); +}; + +export const buildRunnerCommand = ({ + runtime, + command, + file, + domSetupPath, + runtimeOptionArgs, +}: 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 nodeImportFlag = `--import=${domSetupPath}`; + const denoPreloadFlag = `--preload=${domSetupPath}`; + const beforeFile: string[] = []; + const afterFile: string[] = []; + + let hasTsx = false; + let hasNodeLikeDomSetup = false; + let hasDenoDomSetup = false; + const existingArgs = new Set(); + + for (let index = 1; index < command.length; index += 1) { + const arg = command[index]; + if (typeof arg !== 'string') continue; + + existingArgs.add(arg); + + if (index < fileIndex) { + beforeFile.push(arg); + + if (isTsxImport(arg)) hasTsx = true; + else if (arg === nodeImportFlag) hasNodeLikeDomSetup = true; + else if (arg === denoPreloadFlag) hasDenoDomSetup = true; + continue; + } + + if (index > fileIndex) { + afterFile.push(arg); + } + } + + const extraImports: string[] = []; + if (isNodeRuntime(runtime) && !hasTsx) extraImports.push('--import=tsx'); + if (support.supportsNodeLikeImport && !hasNodeLikeDomSetup) + extraImports.push(nodeImportFlag); + if (support.supportsDenoPreload && !hasDenoDomSetup) + extraImports.push(denoPreloadFlag); + + const runtimeArgsToInject: string[] = []; + for (const runtimeOptionArg of runtimeOptionArgs) { + if (existingArgs.has(runtimeOptionArg)) continue; + runtimeArgsToInject.push(runtimeOptionArg); + } + + return { + shouldHandle: true, + command: [ + runtime, + ...beforeFile, + ...extraImports, + file, + ...runtimeArgsToInject, + ...afterFile, + ], + }; +}; diff --git a/src/plugin-metrics.ts b/src/plugin-metrics.ts new file mode 100644 index 0000000..1e58bd8 --- /dev/null +++ b/src/plugin-metrics.ts @@ -0,0 +1,161 @@ +import type { + NormalizedMetricsOptions, + ReactMetricsSummary, + ReactTestingPluginOptions, + RenderMetric, +} from './plugin-types.ts'; +import { runtimeOptionArgPrefixes } from './runtime-options.ts'; + +type RenderMetricMessage = { + type: 'POKU_REACT_RENDER_METRIC'; + componentName?: string; + durationMs?: number; +}; + +type RenderMetricBatchMessage = { + type: 'POKU_REACT_RENDER_METRIC_BATCH'; + metrics: Array<{ + componentName?: string; + durationMs?: number; + }>; +}; + +const DEFAULT_TOP_N = 5; +const DEFAULT_MIN_DURATION_MS = 0; + +export const isRenderMetricMessage = ( + message: unknown +): message is RenderMetricMessage => { + if (!message || typeof message !== 'object') return false; + return ( + (message as Record).type === 'POKU_REACT_RENDER_METRIC' + ); +}; + +export const isRenderMetricBatchMessage = ( + message: unknown +): message is RenderMetricBatchMessage => { + if (!message || typeof message !== 'object') return false; + + const record = message as Record; + return ( + record.type === 'POKU_REACT_RENDER_METRIC_BATCH' && + Array.isArray(record.metrics) + ); +}; + +export const getComponentName = (componentName: unknown) => + typeof componentName === 'string' && componentName.length > 0 + ? componentName + : 'AnonymousComponent'; + +const getPositiveIntegerOrDefault = (value: unknown, fallback: number) => { + const numeric = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value.trim()) + : NaN; + + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.floor(numeric); +}; + +const getNonNegativeNumberOrDefault = (value: unknown, fallback: number) => { + const numeric = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value.trim()) + : NaN; + + if (!Number.isFinite(numeric) || numeric < 0) return fallback; + return numeric; +}; + +export const buildRuntimeOptionArgs = ( + options: ReactTestingPluginOptions, + metricsOptions: NormalizedMetricsOptions +) => { + const args: string[] = []; + + if (options.domUrl) { + args.push(`${runtimeOptionArgPrefixes.domUrl}${options.domUrl}`); + } + + if (metricsOptions.enabled) { + args.push(`${runtimeOptionArgPrefixes.metrics}1`); + args.push( + `${runtimeOptionArgPrefixes.minMetricMs}${metricsOptions.minDurationMs}` + ); + } + + return args; +}; + +export const normalizeMetricsOptions = ( + metrics: ReactTestingPluginOptions['metrics'] +): NormalizedMetricsOptions => { + if (metrics === true) { + return { + enabled: true, + topN: DEFAULT_TOP_N, + minDurationMs: DEFAULT_MIN_DURATION_MS, + }; + } + + if (!metrics) { + return { + enabled: false, + topN: DEFAULT_TOP_N, + minDurationMs: DEFAULT_MIN_DURATION_MS, + }; + } + + const normalized: NormalizedMetricsOptions = { + enabled: metrics.enabled ?? true, + topN: getPositiveIntegerOrDefault(metrics.topN, DEFAULT_TOP_N), + minDurationMs: getNonNegativeNumberOrDefault( + metrics.minDurationMs, + DEFAULT_MIN_DURATION_MS + ), + }; + + if (metrics.reporter) normalized.reporter = metrics.reporter; + + return normalized; +}; + +export const selectTopSlowestMetrics = ( + metrics: RenderMetric[], + options: NormalizedMetricsOptions +) => + [...metrics] + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, options.topN); + +export 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, + }; +}; + +export 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); +}; diff --git a/src/plugin-setup.ts b/src/plugin-setup.ts new file mode 100644 index 0000000..ec800fc --- /dev/null +++ b/src/plugin-setup.ts @@ -0,0 +1,62 @@ +import { pathToFileURL } from 'node:url'; +import { canHandleRuntime, isNodeRuntime } from './plugin-command.ts'; + +type TsxEsmApiModule = { + register?: () => () => void; +}; + +const TSX_LOADER_MODULE = 'tsx/esm/api'; + +const appendMissingRuntimeArgs = (runtimeOptionArgs: string[]) => { + for (const arg of runtimeOptionArgs) { + if (process.argv.includes(arg)) continue; + process.argv.push(arg); + } +}; + +const loadDomSetupModule = async (domSetupPath: string) => { + await import(pathToFileURL(domSetupPath).href); +}; + +const registerNodeTsxLoader = async () => { + const moduleName = TSX_LOADER_MODULE; + + try { + const mod = (await import(moduleName)) as TsxEsmApiModule; + if (typeof mod.register !== 'function') { + throw new Error('Missing register() export from tsx loader API'); + } + + return mod.register(); + } catch (error) { + throw new Error( + '[poku-react-testing] isolation "none" in Node.js requires a working "tsx" installation to load .tsx/.jsx test files.', + { cause: error } + ); + } +}; + +export type InProcessSetupOptions = { + isolation: string | undefined; + runtime: string; + runtimeOptionArgs: string[]; + domSetupPath: string; +}; + +export const setupInProcessEnvironment = async ( + options: InProcessSetupOptions +): Promise<(() => void) | undefined> => { + if (options.isolation !== 'none') return undefined; + if (!canHandleRuntime(options.runtime)) return undefined; + + let cleanupNodeTsxLoader: (() => void) | undefined; + + if (isNodeRuntime(options.runtime)) { + cleanupNodeTsxLoader = await registerNodeTsxLoader(); + } + + appendMissingRuntimeArgs(options.runtimeOptionArgs); + await loadDomSetupModule(options.domSetupPath); + + return cleanupNodeTsxLoader; +}; diff --git a/src/plugin-types.ts b/src/plugin-types.ts new file mode 100644 index 0000000..2cd798c --- /dev/null +++ b/src/plugin-types.ts @@ -0,0 +1,60 @@ +export type ReactDomAdapter = 'happy-dom' | 'jsdom' | { setupModule: string }; + +export type RenderMetric = { + file: string; + componentName: string; + durationMs: number; +}; + +export type ReactMetricsSummary = { + totalCaptured: number; + totalReported: number; + topSlowest: RenderMetric[]; +}; + +export type ReactMetricsOptions = { + /** + * Enable or disable render metrics collection. + */ + enabled?: boolean; + /** + * Maximum number of rows to display/report. + * @default 5 + */ + topN?: number; + /** + * Minimum duration to include in the final report. + * @default 0 + */ + minDurationMs?: number; + /** + * Custom reporter. Falls back to console output when omitted. + */ + reporter?: (summary: ReactMetricsSummary) => void; +}; + +export type ReactTestingPluginOptions = { + /** + * DOM implementation used by test file processes. + * + * - `happy-dom`: fast default suitable for most component tests. + * - `jsdom`: broader compatibility for browser-like APIs. + * - `{ setupModule }`: custom module that prepares globals. + */ + dom?: ReactDomAdapter; + /** + * URL assigned to the DOM environment. + */ + domUrl?: string; + /** + * Render metrics configuration. Disabled by default for production-safe behavior. + */ + metrics?: boolean | ReactMetricsOptions; +}; + +export type NormalizedMetricsOptions = { + enabled: boolean; + topN: number; + minDurationMs: number; + reporter?: (summary: ReactMetricsSummary) => void; +}; diff --git a/src/plugin.ts b/src/plugin.ts index 249b89b..09c763a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,353 +1,33 @@ -import { existsSync } from 'node:fs'; -import { dirname, extname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import type { + ReactDomAdapter, + ReactMetricsOptions, + ReactMetricsSummary, + ReactTestingPluginOptions, + RenderMetric, +} from './plugin-types.ts'; import { definePlugin } from 'poku/plugins'; -import { runtimeOptionArgPrefixes } from './runtime-options.ts'; - -const currentDir = dirname(fileURLToPath(import.meta.url)); -const resolveSetupModulePath = (baseName: string) => { - const jsPath = resolve(currentDir, `${baseName}.js`); - if (existsSync(jsPath)) return jsPath; - - return resolve(currentDir, `${baseName}.ts`); -}; - -const happyDomSetupPath = resolveSetupModulePath('dom-setup-happy'); -const jsdomSetupPath = resolveSetupModulePath('dom-setup-jsdom'); - -const reactExtensions = new Set(['.tsx', '.jsx']); - -export type ReactDomAdapter = 'happy-dom' | 'jsdom' | { setupModule: string }; - -export type ReactTestingPluginOptions = { - /** - * DOM implementation used by test file processes. - * - * - `happy-dom`: fast default suitable for most component tests. - * - `jsdom`: broader compatibility for browser-like APIs. - * - `{ setupModule }`: custom module that prepares globals. - */ - dom?: ReactDomAdapter; - /** - * URL assigned to the DOM environment. - */ - domUrl?: string; - /** - * Render metrics configuration. Disabled by default for production-safe behavior. - */ - metrics?: boolean | ReactMetricsOptions; -}; - -export type ReactMetricsSummary = { - totalCaptured: number; - totalReported: number; - topSlowest: RenderMetric[]; -}; - -export type ReactMetricsOptions = { - /** - * Enable or disable render metrics collection. - */ - enabled?: boolean; - /** - * Maximum number of rows to display/report. - * @default 5 - */ - topN?: number; - /** - * Minimum duration to include in the final report. - * @default 0 - */ - minDurationMs?: number; - /** - * Custom reporter. Falls back to console output when omitted. - */ - reporter?: (summary: ReactMetricsSummary) => void; -}; - -type RenderMetricMessage = { - type: 'POKU_REACT_RENDER_METRIC'; - componentName?: string; - durationMs?: number; -}; - -type RenderMetricBatchMessage = { - type: 'POKU_REACT_RENDER_METRIC_BATCH'; - metrics: Array<{ - componentName?: string; - durationMs?: number; - }>; -}; - -type RenderMetric = { - file: string; - componentName: string; - durationMs: number; -}; - -type NormalizedMetricsOptions = { - enabled: boolean; - topN: number; - minDurationMs: number; - reporter?: (summary: ReactMetricsSummary) => void; -}; - -type RuntimeSupport = { - supportsNodeLikeImport: boolean; - supportsDenoPreload: boolean; -}; - -type BuildRunnerCommandInput = { - runtime: string; - command: string[]; - file: string; - domSetupPath: string; - runtimeOptionArgs: string[]; -}; - -type BuildRunnerCommandOutput = { - shouldHandle: boolean; - command: string[]; -}; - -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' - ); -}; - -const isRenderMetricBatchMessage = ( - message: unknown -): message is RenderMetricBatchMessage => { - if (!message || typeof message !== 'object') return false; - - const record = message as Record; - return ( - record.type === 'POKU_REACT_RENDER_METRIC_BATCH' && - Array.isArray(record.metrics) - ); -}; - -const getComponentName = (componentName: unknown) => - typeof componentName === 'string' && componentName.length > 0 - ? componentName - : '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; - if (adapter === 'jsdom') return jsdomSetupPath; - - return resolve(process.cwd(), adapter.setupModule); -}; - -const getPositiveIntegerOrDefault = (value: unknown, fallback: number) => { - const numeric = - typeof value === 'number' - ? value - : typeof value === 'string' && value.trim().length > 0 - ? Number(value.trim()) - : NaN; - - if (!Number.isFinite(numeric) || numeric <= 0) return fallback; - return Math.floor(numeric); -}; - -const getNonNegativeNumberOrDefault = (value: unknown, fallback: number) => { - const numeric = - typeof value === 'number' - ? value - : typeof value === 'string' && value.trim().length > 0 - ? Number(value.trim()) - : NaN; - - if (!Number.isFinite(numeric) || numeric < 0) return fallback; - return numeric; -}; - -const buildRuntimeOptionArgs = ( - options: ReactTestingPluginOptions, - metricsOptions: NormalizedMetricsOptions -) => { - const args: string[] = []; - - if (options.domUrl) { - args.push(`${runtimeOptionArgPrefixes.domUrl}${options.domUrl}`); - } - - if (metricsOptions.enabled) { - args.push(`${runtimeOptionArgPrefixes.metrics}1`); - args.push( - `${runtimeOptionArgPrefixes.minMetricMs}${metricsOptions.minDurationMs}` - ); - } - - return args; -}; - -const normalizeMetricsOptions = ( - metrics: ReactTestingPluginOptions['metrics'] -): NormalizedMetricsOptions => { - if (metrics === true) { - return { - enabled: true, - topN: DEFAULT_TOP_N, - minDurationMs: DEFAULT_MIN_DURATION_MS, - }; - } - - if (!metrics) { - return { - enabled: false, - topN: DEFAULT_TOP_N, - minDurationMs: DEFAULT_MIN_DURATION_MS, - }; - } - - const normalized: NormalizedMetricsOptions = { - enabled: metrics.enabled ?? true, - topN: getPositiveIntegerOrDefault(metrics.topN, DEFAULT_TOP_N), - minDurationMs: getNonNegativeNumberOrDefault( - metrics.minDurationMs, - DEFAULT_MIN_DURATION_MS - ), - }; - - if (metrics.reporter) normalized.reporter = metrics.reporter; - - return normalized; -}; - -const buildRunnerCommand = ({ - runtime, - command, - file, - domSetupPath, - runtimeOptionArgs, -}: BuildRunnerCommandInput): BuildRunnerCommandOutput => { - const support = getRuntimeSupport(runtime); - - if (!support.supportsNodeLikeImport && !support.supportsDenoPreload) { - return { shouldHandle: false, command }; - } - - if (!reactExtensions.has(extname(file))) { - return { shouldHandle: false, command }; - } - - // Optimization: find from the end to prevent false matches in directory names - const fileIndex = command.lastIndexOf(file); - if (fileIndex === -1) return { shouldHandle: false, command }; - - const nodeImportFlag = `--import=${domSetupPath}`; - const denoPreloadFlag = `--preload=${domSetupPath}`; - const beforeFile: string[] = []; - const afterFile: string[] = []; - - let hasTsx = false; - let hasNodeLikeDomSetup = false; - let hasDenoDomSetup = false; - const existingArgs = new Set(); - - for (let index = 1; index < command.length; index += 1) { - const arg = command[index]; - if (typeof arg !== 'string') continue; - - existingArgs.add(arg); - - if (index < fileIndex) { - beforeFile.push(arg); - - if (isTsxImport(arg)) hasTsx = true; - else if (arg === nodeImportFlag) hasNodeLikeDomSetup = true; - else if (arg === denoPreloadFlag) hasDenoDomSetup = true; - continue; - } - - if (index > fileIndex) { - afterFile.push(arg); - } - } - - const extraImports: string[] = []; - if (isNodeRuntime(runtime) && !hasTsx) extraImports.push('--import=tsx'); - if (support.supportsNodeLikeImport && !hasNodeLikeDomSetup) - extraImports.push(nodeImportFlag); - if (support.supportsDenoPreload && !hasDenoDomSetup) - extraImports.push(denoPreloadFlag); - - const runtimeArgsToInject: string[] = []; - for (const runtimeOptionArg of runtimeOptionArgs) { - if (existingArgs.has(runtimeOptionArg)) continue; - runtimeArgsToInject.push(runtimeOptionArg); - } - - return { - shouldHandle: true, - command: [ - runtime, - ...beforeFile, - ...extraImports, - file, - ...runtimeArgsToInject, - ...afterFile, - ], - }; -}; - -const selectTopSlowestMetrics = ( - metrics: RenderMetric[], - options: NormalizedMetricsOptions -) => - [...metrics] - .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, // Note: Represents captured over threshold - totalReported: topSlowest.length, - topSlowest, - }; -}; - -const printMetricsSummary = (summary: ReactMetricsSummary) => { - const lines = summary.topSlowest.map( - (metric) => - ` - ${metric.componentName} in ${metric.file}: ${metric.durationMs.toFixed(2)}ms` - ); +import { + buildRunnerCommand, + canHandleRuntime, + resolveDomSetupPath, +} from './plugin-command.ts'; +import { + buildRuntimeOptionArgs, + createMetricsSummary, + getComponentName, + isRenderMetricBatchMessage, + isRenderMetricMessage, + normalizeMetricsOptions, + printMetricsSummary, + selectTopSlowestMetrics, +} from './plugin-metrics.ts'; +import { setupInProcessEnvironment } from './plugin-setup.ts'; - console.log('\n[poku-react-testing] Slowest component renders'); - for (const line of lines) console.log(line); +export type { + ReactDomAdapter, + ReactMetricsOptions, + ReactMetricsSummary, + ReactTestingPluginOptions, }; /** @@ -357,6 +37,7 @@ export const createReactTestingPlugin = ( options: ReactTestingPluginOptions = {} ) => { let metrics: RenderMetric[] = []; + let cleanupNodeTsxLoader: (() => void) | undefined; const domSetupPath = resolveDomSetupPath(options.dom); const metricsOptions = normalizeMetricsOptions(options.metrics); const runtimeOptionArgs = buildRuntimeOptionArgs(options, metricsOptions); @@ -365,6 +46,15 @@ export const createReactTestingPlugin = ( name: 'react-testing', ipc: metricsOptions.enabled, + async setup(context) { + cleanupNodeTsxLoader = await setupInProcessEnvironment({ + isolation: context.configs.isolation, + runtime: context.runtime, + runtimeOptionArgs, + domSetupPath, + }); + }, + runner(command, file) { const runtime = command[0]; if (!runtime) return command; @@ -420,6 +110,9 @@ export const createReactTestingPlugin = ( }, teardown() { + cleanupNodeTsxLoader?.(); + cleanupNodeTsxLoader = undefined; + const summary = createMetricsSummary(metrics, metricsOptions); if (!summary) return; diff --git a/src/react-testing.ts b/src/react-testing.ts index a96493b..1f1723c 100644 --- a/src/react-testing.ts +++ b/src/react-testing.ts @@ -49,22 +49,55 @@ type QueuedRenderMetric = { durationMs: number; }; -const metricBuffer: QueuedRenderMetric[] = []; -let metricFlushTimer: ReturnType | undefined; -let metricsChannelClosed = false; +type MetricsRuntimeState = { + metricBuffer: QueuedRenderMetric[]; + metricFlushTimer: ReturnType | undefined; + metricsChannelClosed: boolean; + listenersRegistered: boolean; +}; + +const metricsStateKey = Symbol.for('poku-react-testing.metrics-runtime-state'); + +type MetricsStateGlobal = typeof globalThis & { + [metricsStateKey]?: MetricsRuntimeState; +}; + +const getMetricsRuntimeState = (): MetricsRuntimeState => { + const stateGlobal = globalThis as MetricsStateGlobal; + + if (!stateGlobal[metricsStateKey]) { + stateGlobal[metricsStateKey] = { + metricBuffer: [], + metricFlushTimer: undefined, + metricsChannelClosed: false, + listenersRegistered: false, + }; + } + + return stateGlobal[metricsStateKey]; +}; + +const metricsState = getMetricsRuntimeState(); const flushMetricBuffer = () => { if (!metricsEnabled || typeof process.send !== 'function') return; if (process.connected === false) { - metricBuffer.length = 0; - metricsChannelClosed = true; + metricsState.metricBuffer.length = 0; + metricsState.metricsChannelClosed = true; return; } - if (metricsChannelClosed || metricBuffer.length === 0) return; + if ( + metricsState.metricsChannelClosed || + metricsState.metricBuffer.length === 0 + ) + return; - const payload = metricBuffer.splice(0, metricBuffer.length); + const payload = metricsState.metricBuffer.splice( + 0, + metricsState.metricBuffer.length + ); try { process.send({ @@ -72,29 +105,31 @@ const flushMetricBuffer = () => { metrics: payload, }); } catch { - metricsChannelClosed = true; - metricBuffer.length = 0; + metricsState.metricsChannelClosed = true; + metricsState.metricBuffer.length = 0; } }; const clearMetricFlushTimer = () => { - if (!metricFlushTimer) return; - clearTimeout(metricFlushTimer); - metricFlushTimer = undefined; + if (!metricsState.metricFlushTimer) return; + clearTimeout(metricsState.metricFlushTimer); + metricsState.metricFlushTimer = undefined; }; const scheduleMetricFlush = () => { - if (metricFlushTimer) return; + if (metricsState.metricFlushTimer) return; - metricFlushTimer = setTimeout(() => { - metricFlushTimer = undefined; + metricsState.metricFlushTimer = setTimeout(() => { + metricsState.metricFlushTimer = undefined; flushMetricBuffer(); }, runtimeOptions.metricFlushMs); - metricFlushTimer.unref?.(); + metricsState.metricFlushTimer.unref?.(); }; -if (metricsEnabled) { +if (metricsEnabled && !metricsState.listenersRegistered) { + metricsState.listenersRegistered = true; + process.on('beforeExit', () => { clearMetricFlushTimer(); flushMetricBuffer(); @@ -102,17 +137,17 @@ if (metricsEnabled) { process.on('disconnect', () => { clearMetricFlushTimer(); - metricBuffer.length = 0; - metricsChannelClosed = true; + metricsState.metricBuffer.length = 0; + metricsState.metricsChannelClosed = true; }); } const emitRenderMetric = (componentName: string, durationMs: number) => { if (!metricsEnabled || typeof process.send !== 'function') return; - if (process.connected === false || metricsChannelClosed) { - metricBuffer.length = 0; - metricsChannelClosed = true; + if (process.connected === false || metricsState.metricsChannelClosed) { + metricsState.metricBuffer.length = 0; + metricsState.metricsChannelClosed = true; clearMetricFlushTimer(); return; } @@ -123,12 +158,12 @@ const emitRenderMetric = (componentName: string, durationMs: number) => { // Optimization: Drop metrics below the threshold to prevent IPC flooding if (safeDuration < minMetricMs) return; - metricBuffer.push({ + metricsState.metricBuffer.push({ componentName, durationMs: safeDuration, }); - if (metricBuffer.length >= runtimeOptions.metricBatchSize) { + if (metricsState.metricBuffer.length >= runtimeOptions.metricBatchSize) { clearMetricFlushTimer(); flushMetricBuffer(); return; diff --git a/tests/plugin-command.test.ts b/tests/plugin-command.test.ts new file mode 100644 index 0000000..a9f6afc --- /dev/null +++ b/tests/plugin-command.test.ts @@ -0,0 +1,86 @@ +import { assert, test } from 'poku'; +import { + buildRunnerCommand, + canHandleRuntime, + resolveDomSetupPath, +} from '../src/plugin-command.ts'; + +test('canHandleRuntime supports node, bun and deno', async () => { + assert.strictEqual(canHandleRuntime('node'), true); + assert.strictEqual(canHandleRuntime('bun'), true); + assert.strictEqual(canHandleRuntime('deno'), true); + assert.strictEqual(canHandleRuntime('python'), false); +}); + +test('buildRunnerCommand injects imports and runtime args for node', async () => { + const result = buildRunnerCommand({ + runtime: 'node', + command: ['node', '--trace-warnings', 'tests/example.test.tsx'], + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + runtimeOptionArgs: ['--poku-react-dom-url=http://example.local/'], + }); + + assert.strictEqual(result.shouldHandle, true); + assert.deepStrictEqual(result.command, [ + 'node', + '--trace-warnings', + '--import=tsx', + '--import=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + '--poku-react-dom-url=http://example.local/', + ]); +}); + +test('buildRunnerCommand injects deno preload and avoids duplicates', async () => { + const result = buildRunnerCommand({ + runtime: 'deno', + command: [ + 'deno', + 'run', + '-A', + '--preload=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + '--poku-react-metrics=1', + ], + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + runtimeOptionArgs: ['--poku-react-metrics=1'], + }); + + assert.strictEqual(result.shouldHandle, true); + assert.deepStrictEqual(result.command, [ + 'deno', + 'run', + '-A', + '--preload=/tmp/dom-setup.ts', + 'tests/example.test.tsx', + '--poku-react-metrics=1', + ]); +}); + +test('buildRunnerCommand leaves unsupported runtime unchanged', async () => { + const original = ['python', 'tests/example.test.tsx']; + const result = buildRunnerCommand({ + runtime: 'python', + command: original, + file: 'tests/example.test.tsx', + domSetupPath: '/tmp/dom-setup.ts', + runtimeOptionArgs: [], + }); + + assert.strictEqual(result.shouldHandle, false); + assert.deepStrictEqual(result.command, original); +}); + +test('resolveDomSetupPath resolves built-in and custom adapters', async () => { + const happyPath = resolveDomSetupPath('happy-dom'); + const jsdomPath = resolveDomSetupPath('jsdom'); + const customPath = 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')); +}); diff --git a/tests/plugin-internals.test.ts b/tests/plugin-internals.test.ts index 032920f..465a49a 100644 --- a/tests/plugin-internals.test.ts +++ b/tests/plugin-internals.test.ts @@ -1,7 +1,7 @@ import { assert, test } from 'poku'; import { __internal } from '../src/plugin.ts'; -test('normalizes metrics defaults when disabled', () => { +test('normalizes metrics defaults when disabled', async () => { const normalized = __internal.normalizeMetricsOptions(undefined); assert.strictEqual(normalized.enabled, false); @@ -9,7 +9,7 @@ test('normalizes metrics defaults when disabled', () => { assert.strictEqual(normalized.minDurationMs, 0); }); -test('normalizes metrics with option object', () => { +test('normalizes metrics with option object', async () => { const reporterCalls: number[] = []; const normalized = __internal.normalizeMetricsOptions({ enabled: true, @@ -27,7 +27,7 @@ test('normalizes metrics with option object', () => { assert.strictEqual(reporterCalls.length, 0); }); -test('buildRunnerCommand injects tsx and dom setup for node', () => { +test('buildRunnerCommand injects tsx and dom setup for node', async () => { const result = __internal.buildRunnerCommand({ runtime: 'node', command: ['node', '--trace-warnings', 'tests/example.test.tsx'], @@ -47,7 +47,7 @@ test('buildRunnerCommand injects tsx and dom setup for node', () => { ]); }); -test('buildRunnerCommand injects dom setup for bun without tsx import', () => { +test('buildRunnerCommand injects dom setup for bun without tsx import', async () => { const result = __internal.buildRunnerCommand({ runtime: 'bun', command: ['bun', 'tests/example.test.tsx'], @@ -65,7 +65,7 @@ test('buildRunnerCommand injects dom setup for bun without tsx import', () => { ]); }); -test('buildRunnerCommand injects preload for deno', () => { +test('buildRunnerCommand injects preload for deno', async () => { const result = __internal.buildRunnerCommand({ runtime: 'deno', command: ['deno', 'run', '-A', 'tests/example.test.tsx'], @@ -85,7 +85,7 @@ test('buildRunnerCommand injects preload for deno', () => { ]); }); -test('buildRunnerCommand leaves unsupported runtime unchanged', () => { +test('buildRunnerCommand leaves unsupported runtime unchanged', async () => { const original = ['python', 'tests/example.test.tsx']; const result = __internal.buildRunnerCommand({ runtime: 'python', @@ -99,7 +99,7 @@ test('buildRunnerCommand leaves unsupported runtime unchanged', () => { assert.deepStrictEqual(result.command, original); }); -test('buildRunnerCommand leaves non-react extension unchanged', () => { +test('buildRunnerCommand leaves non-react extension unchanged', async () => { const original = ['node', 'tests/example.test.ts']; const result = __internal.buildRunnerCommand({ runtime: 'node', @@ -113,7 +113,7 @@ test('buildRunnerCommand leaves non-react extension unchanged', () => { assert.deepStrictEqual(result.command, original); }); -test('buildRunnerCommand avoids duplicate runtime args', () => { +test('buildRunnerCommand avoids duplicate runtime args', async () => { const result = __internal.buildRunnerCommand({ runtime: 'node', command: [ @@ -137,7 +137,7 @@ test('buildRunnerCommand avoids duplicate runtime args', () => { ]); }); -test('buildRuntimeOptionArgs creates argv-safe plugin flags', () => { +test('buildRuntimeOptionArgs creates argv-safe plugin flags', async () => { const args = __internal.buildRuntimeOptionArgs( { domUrl: 'http://example.local/', metrics: true }, __internal.normalizeMetricsOptions({ enabled: true, minDurationMs: 2.75 }) @@ -150,7 +150,7 @@ test('buildRuntimeOptionArgs creates argv-safe plugin flags', () => { ]); }); -test('normalizeMetricsOptions ignores invalid non-number containers', () => { +test('normalizeMetricsOptions ignores invalid non-number containers', async () => { const normalized = __internal.normalizeMetricsOptions({ enabled: true, topN: [42] as unknown as number, @@ -161,7 +161,7 @@ test('normalizeMetricsOptions ignores invalid non-number containers', () => { assert.strictEqual(normalized.minDurationMs, 0); }); -test('createMetricsSummary returns ordered top metrics with filters', () => { +test('createMetricsSummary returns ordered top metrics with filters', async () => { const metrics = [ { file: 'a', componentName: 'A', durationMs: 0.4 }, { file: 'b', componentName: 'B', durationMs: 4.2 }, @@ -186,7 +186,7 @@ test('createMetricsSummary returns ordered top metrics with filters', () => { ); }); -test('createMetricsSummary returns null when disabled', () => { +test('createMetricsSummary returns null when disabled', async () => { const summary = __internal.createMetricsSummary( [{ file: 'a', componentName: 'A', durationMs: 8 }], __internal.normalizeMetricsOptions(false) @@ -195,13 +195,13 @@ test('createMetricsSummary returns null when disabled', () => { assert.strictEqual(summary, null); }); -test('getComponentName falls back for non-string values', () => { +test('getComponentName falls back for non-string values', async () => { assert.strictEqual(__internal.getComponentName('MyComp'), 'MyComp'); assert.strictEqual(__internal.getComponentName(''), 'AnonymousComponent'); assert.strictEqual(__internal.getComponentName(null), 'AnonymousComponent'); }); -test('isRenderMetricMessage validates expected payloads', () => { +test('isRenderMetricMessage validates expected payloads', async () => { assert.strictEqual( __internal.isRenderMetricMessage({ type: 'POKU_REACT_RENDER_METRIC' }), true @@ -213,7 +213,7 @@ test('isRenderMetricMessage validates expected payloads', () => { assert.strictEqual(__internal.isRenderMetricMessage(null), false); }); -test('isRenderMetricBatchMessage validates batched payloads', () => { +test('isRenderMetricBatchMessage validates batched payloads', async () => { assert.strictEqual( __internal.isRenderMetricBatchMessage({ type: 'POKU_REACT_RENDER_METRIC_BATCH', @@ -231,7 +231,7 @@ test('isRenderMetricBatchMessage validates batched payloads', () => { assert.strictEqual(__internal.isRenderMetricBatchMessage(null), false); }); -test('resolveDomSetupPath resolves built-in and custom adapters', () => { +test('resolveDomSetupPath resolves built-in and custom adapters', async () => { const happyPath = __internal.resolveDomSetupPath('happy-dom'); const jsdomPath = __internal.resolveDomSetupPath('jsdom'); const customPath = __internal.resolveDomSetupPath({ diff --git a/tests/plugin-metrics.test.ts b/tests/plugin-metrics.test.ts new file mode 100644 index 0000000..a2b7eec --- /dev/null +++ b/tests/plugin-metrics.test.ts @@ -0,0 +1,118 @@ +import { assert, test } from 'poku'; +import { + buildRuntimeOptionArgs, + createMetricsSummary, + getComponentName, + isRenderMetricBatchMessage, + isRenderMetricMessage, + normalizeMetricsOptions, + selectTopSlowestMetrics, +} from '../src/plugin-metrics.ts'; + +test('normalizeMetricsOptions uses defaults when disabled', async () => { + const normalized = normalizeMetricsOptions(undefined); + + assert.strictEqual(normalized.enabled, false); + assert.strictEqual(normalized.topN, 5); + assert.strictEqual(normalized.minDurationMs, 0); +}); + +test('normalizeMetricsOptions sanitizes numeric fields', async () => { + const normalized = normalizeMetricsOptions({ + enabled: true, + topN: 9.7, + minDurationMs: 2.5, + }); + + assert.strictEqual(normalized.enabled, true); + assert.strictEqual(normalized.topN, 9); + assert.strictEqual(normalized.minDurationMs, 2.5); +}); + +test('buildRuntimeOptionArgs emits stable CLI flags', async () => { + const args = buildRuntimeOptionArgs( + { domUrl: 'http://example.local/', metrics: true }, + normalizeMetricsOptions({ enabled: true, minDurationMs: 2.75 }) + ); + + assert.deepStrictEqual(args, [ + '--poku-react-dom-url=http://example.local/', + '--poku-react-metrics=1', + '--poku-react-min-metric-ms=2.75', + ]); +}); + +test('selectTopSlowestMetrics sorts and truncates', async () => { + const top = selectTopSlowestMetrics( + [ + { file: 'a', componentName: 'A', durationMs: 1 }, + { file: 'b', componentName: 'B', durationMs: 4 }, + { file: 'c', componentName: 'C', durationMs: 3 }, + ], + normalizeMetricsOptions({ enabled: true, topN: 2 }) + ); + + assert.deepStrictEqual( + top.map((metric) => metric.componentName), + ['B', 'C'] + ); +}); + +test('createMetricsSummary returns null when disabled or empty', async () => { + assert.strictEqual( + createMetricsSummary([], normalizeMetricsOptions(true)), + null + ); + assert.strictEqual( + createMetricsSummary( + [{ file: 'a', componentName: 'A', durationMs: 8 }], + normalizeMetricsOptions(false) + ), + null + ); +}); + +test('createMetricsSummary reports top metrics', async () => { + const summary = createMetricsSummary( + [ + { file: 'a', componentName: 'A', durationMs: 0.4 }, + { file: 'b', componentName: 'B', durationMs: 4.2 }, + { file: 'c', componentName: 'C', durationMs: 3.3 }, + ], + 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('metric message guards validate expected payloads', async () => { + assert.strictEqual( + isRenderMetricMessage({ type: 'POKU_REACT_RENDER_METRIC' }), + true + ); + assert.strictEqual(isRenderMetricMessage({ type: 'OTHER' }), false); + + assert.strictEqual( + isRenderMetricBatchMessage({ + type: 'POKU_REACT_RENDER_METRIC_BATCH', + metrics: [{ componentName: 'A', durationMs: 1.2 }], + }), + true + ); + assert.strictEqual( + isRenderMetricBatchMessage({ type: 'POKU_REACT_RENDER_METRIC_BATCH' }), + false + ); +}); + +test('getComponentName falls back for invalid values', async () => { + assert.strictEqual(getComponentName('MyComp'), 'MyComp'); + assert.strictEqual(getComponentName(''), 'AnonymousComponent'); + assert.strictEqual(getComponentName(null), 'AnonymousComponent'); +}); diff --git a/tests/react-concurrency.test.tsx b/tests/react-concurrency.test.tsx index e0b2ac6..ebee293 100644 --- a/tests/react-concurrency.test.tsx +++ b/tests/react-concurrency.test.tsx @@ -9,7 +9,7 @@ const ResourceView = ({ resource }: { resource: Promise }) => { return

{value}

; }; -test('renders a resolved use() resource under Suspense', () => { +test('renders a resolved use() resource under Suspense', async () => { const value = 'Loaded from use() resource'; const resolvedResource = { status: 'fulfilled' as const, @@ -30,7 +30,7 @@ test('renders a resolved use() resource under Suspense', () => { ); }); -await test('runs urgent and transition update pipeline', async () => { +test('runs urgent and transition update pipeline', async () => { const TransitionPipeline = () => { const [urgentState, setUrgentState] = useState('idle'); const [deferredState, setDeferredState] = useState('idle'); diff --git a/tests/react-context.test.tsx b/tests/react-context.test.tsx index f675afa..ece3553 100644 --- a/tests/react-context.test.tsx +++ b/tests/react-context.test.tsx @@ -11,7 +11,7 @@ const ThemeLabel = () => { return

Theme: {theme}

; }; -test('injects context values via wrapper', () => { +test('injects context values via wrapper', async () => { const ThemeWrapper = ({ children }: { children?: React.ReactNode }) => ( {children} ); diff --git a/tests/react-hooks.test.tsx b/tests/react-hooks.test.tsx index acd0ad2..d83eeed 100644 --- a/tests/react-hooks.test.tsx +++ b/tests/react-hooks.test.tsx @@ -46,7 +46,7 @@ test('tests custom hooks through a component harness', () => { ); }); -test('tests hook logic directly with renderHook', () => { +test('tests hook logic directly with renderHook', async () => { const { result } = renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), { diff --git a/tests/react-lifecycle.test.tsx b/tests/react-lifecycle.test.tsx index 43be2a3..f87bba2 100644 --- a/tests/react-lifecycle.test.tsx +++ b/tests/react-lifecycle.test.tsx @@ -6,7 +6,7 @@ afterEach(cleanup); const Greeting = ({ name }: { name: string }) =>

Hello {name}

; -test('rerender updates component props in place', () => { +test('rerender updates component props in place', async () => { const view = render(); assert.strictEqual( @@ -22,7 +22,7 @@ test('rerender updates component props in place', () => { ); }); -test('unmount runs effect cleanup logic', () => { +test('unmount runs effect cleanup logic', async () => { let cleaned = false; const WithEffect = () => { diff --git a/tests/react-plugin.test.tsx b/tests/react-plugin.test.tsx index ea97302..d9f7100 100644 --- a/tests/react-plugin.test.tsx +++ b/tests/react-plugin.test.tsx @@ -25,7 +25,7 @@ const Counter = ({ initialCount = 0 }: CounterProps) => { ); }; -test('renders and updates a React component', () => { +test('renders and updates a React component', async () => { render(); assert.strictEqual(