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(