@octanejs/testing-library 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/package.json +40 -0
- package/src/act-environment.ts +21 -0
- package/src/fire-event.ts +17 -0
- package/src/index.ts +45 -0
- package/src/pure.ts +394 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @octanejs/testing-library
|
|
2
|
+
|
|
3
|
+
[React Testing Library](https://testing-library.com/docs/react-testing-library/intro/)
|
|
4
|
+
for the [octane](https://github.com/octanejs/octane) renderer.
|
|
5
|
+
|
|
6
|
+
The split mirrors RTL's own architecture (and
|
|
7
|
+
`docs/react-library-compat-plan.md` §2): **`@testing-library/dom` is
|
|
8
|
+
framework-agnostic and reused verbatim** — every query, `screen`, `within`,
|
|
9
|
+
`waitFor`/`waitForElementToBeRemoved`, `findBy*`, `fireEvent`, `prettyDOM`,
|
|
10
|
+
`configure` — while only react-testing-library's thin React layer is ported to
|
|
11
|
+
octane: `render`, `cleanup`, `renderHook`, the `act` re-export, and the
|
|
12
|
+
dom-testing-library config wiring (`eventWrapper`/`asyncWrapper`) that makes
|
|
13
|
+
every dispatch/wait commit octane's scheduled work before your assertions run.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { render, screen, fireEvent, cleanup } from '@octanejs/testing-library';
|
|
17
|
+
import { Counter } from './Counter.tsrx';
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup); // automatic when your runner exposes a global afterEach
|
|
20
|
+
|
|
21
|
+
test('increments', () => {
|
|
22
|
+
render(Counter, { props: { step: 2 } });
|
|
23
|
+
fireEvent.click(screen.getByRole('button'));
|
|
24
|
+
expect(screen.getByRole('button').textContent).toBe('Count: 2');
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
- `render(ui, options?)` → `{ container, baseElement, ...queries, rerender, unmount, asFragment, debug }`
|
|
31
|
+
- `cleanup()` — unmounts everything `render` mounted (auto-registered afterEach
|
|
32
|
+
when a global `afterEach`/`teardown` exists; opt out with
|
|
33
|
+
`RTL_SKIP_AUTO_CLEANUP=true` or import `@octanejs/testing-library/pure`)
|
|
34
|
+
- `renderHook(callback, { initialProps, wrapper, ... }?)` → `{ result, rerender, unmount }`
|
|
35
|
+
- `act` — octane's `act`, re-exported (always async; always `await` it)
|
|
36
|
+
- `fireEvent`, `screen`, `waitFor`, `within`, … — dom-testing-library, re-exported
|
|
37
|
+
|
|
38
|
+
## Octane-specific surface
|
|
39
|
+
|
|
40
|
+
**Components are values in plain-`.ts` tests** — there's no JSX in a `.ts`
|
|
41
|
+
file, so `render` (and `rerender`) take two forms:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
render(Counter, { props: { step: 2 }, wrapper: Providers }); // body + props option
|
|
45
|
+
render(createElement(Counter, { step: 2 }), { wrapper: Providers }); // RTL-style element
|
|
46
|
+
rerender(Counter, { step: 3 }); // rerender takes bare props as the 2nd arg
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Same component ⇒ props update in place; a different component tears down and
|
|
50
|
+
remounts — exactly RTL's rerender semantics.
|
|
51
|
+
|
|
52
|
+
## Differences from react-testing-library
|
|
53
|
+
|
|
54
|
+
Octane dispatches **native, delegated DOM events — there is no synthetic event
|
|
55
|
+
layer** — so `fireEvent` is dom-testing-library's, deliberately *without* RTL's
|
|
56
|
+
React-specific remappings:
|
|
57
|
+
|
|
58
|
+
- **`fireEvent.change` fires a native `change`; `fireEvent.input` a native
|
|
59
|
+
`input`.** In React, `onChange` handlers actually run off native `input`
|
|
60
|
+
events, so RTL tests habitually drive text inputs with `fireEvent.change`.
|
|
61
|
+
In octane `onChange` means the platform `change` event (fires on
|
|
62
|
+
commit/blur), and `onInput` fires per keystroke — port such tests to
|
|
63
|
+
`fireEvent.input` (or better, `@testing-library/user-event`, which emits
|
|
64
|
+
real event sequences). There are **no controlled components**: `value` is a
|
|
65
|
+
plain attribute and inputs are native/uncontrolled, so there's no React-style
|
|
66
|
+
value re-assertion after events.
|
|
67
|
+
- **No enter/leave/focus double-dispatch.** RTL's `fireEvent.mouseEnter` also
|
|
68
|
+
fires `mouseover` (and `focus` fires `focusin`, `select` fires `keyup`, …)
|
|
69
|
+
purely to feed React's plugin system, which listens to different native
|
|
70
|
+
events than the handler names suggest. Octane's `onMouseEnter` receives the
|
|
71
|
+
real `mouseenter` (non-bubbling events are capture-delegated), so
|
|
72
|
+
`fireEvent.mouseEnter` alone triggers it — no compensation needed or wanted.
|
|
73
|
+
- **Commit timing is wired, not synthesized.** Octane already commits discrete
|
|
74
|
+
events (`click`, `input`, `keydown`, …) synchronously; this package
|
|
75
|
+
additionally wraps *every* `fireEvent` dispatch in `flushSync` + an effect
|
|
76
|
+
drain via dom-testing-library's `eventWrapper`, so non-discrete/programmatic
|
|
77
|
+
events also commit — with their `useEffect` cascades — before `fireEvent`
|
|
78
|
+
returns (the equivalent of RTL's `act()` around each dispatch).
|
|
79
|
+
- **Host elements at the root render between comment anchors.**
|
|
80
|
+
`render(createElement('div', …))` goes through octane's value-position
|
|
81
|
+
renderer, so `container.firstChild` is a comment node — use
|
|
82
|
+
`container.firstElementChild` (or just queries). Component roots
|
|
83
|
+
(`render(App, …)`) mount their template directly, no anchors.
|
|
84
|
+
- **`renderHook` and hook slots.** Octane hooks are keyed by compiler-assigned
|
|
85
|
+
call-site slots. Hook callbacks written in your test files Just Work — the
|
|
86
|
+
vite plugin's surgical pass slots base-hook calls in plain `.ts`, and
|
|
87
|
+
`.tsrx`/`.tsx` hooks are fully compiled. The harness additionally runs your
|
|
88
|
+
callback under a `withSlot` path, so calling a single pre-built binding hook
|
|
89
|
+
(`renderHook(() => useStore(api))`) works even unslotted. A hand-written
|
|
90
|
+
callback that calls **two or more base hooks directly without compilation**
|
|
91
|
+
(e.g. authored outside the vite plugin) still needs explicit slot symbols:
|
|
92
|
+
`useState(0, Symbol.for('a'))`.
|
|
93
|
+
- **Not ported** (no octane equivalent, by design): `reactStrictMode` /
|
|
94
|
+
StrictMode double-render, `legacyRoot`, `onCaughtError`/`onRecoverableError`
|
|
95
|
+
root options, and RTL's `configure({reactStrictMode})` wrapper —
|
|
96
|
+
`configure`/`getConfig` here are dom-testing-library's own.
|
|
97
|
+
- `hydrate: true` adopts server-rendered DOM already inside `container` via
|
|
98
|
+
octane's `hydrateRoot` (the container must hold octane SSR output).
|
|
99
|
+
|
|
100
|
+
## Test-runner setup
|
|
101
|
+
|
|
102
|
+
Like RTL, importing the package root auto-registers `afterEach(cleanup)` and
|
|
103
|
+
arms octane's "update was not wrapped in act(...)" warning for the run — but
|
|
104
|
+
only when your runner exposes **global** test hooks (vitest `globals: true`,
|
|
105
|
+
jest). With `globals: false`, register it yourself:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { cleanup } from '@octanejs/testing-library';
|
|
109
|
+
import { afterEach } from 'vitest';
|
|
110
|
+
afterEach(cleanup);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`@octanejs/testing-library/pure` skips the side effects entirely, exactly like
|
|
114
|
+
`@testing-library/react/pure`.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/testing-library",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "React Testing Library for the octane renderer — reuses the framework-agnostic @testing-library/dom verbatim and ports only react-testing-library's thin React layer (render/cleanup/renderHook) onto octane's createRoot + act.",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Dominic Gannaway",
|
|
9
|
+
"email": "dg@domgan.com"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
17
|
+
"directory": "packages/testing-library"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./pure": "./src/pure.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@testing-library/dom": "^10.4.1",
|
|
32
|
+
"octane": "0.1.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"vitest": "^4.1.9"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Internal act()-environment bookkeeping — NOT re-exported from the package.
|
|
2
|
+
//
|
|
3
|
+
// Octane keeps its IS_OCTANE_ACT_ENVIRONMENT flag module-private behind a
|
|
4
|
+
// write-only setter (`setIsOctaneActEnvironment`), so the current value is
|
|
5
|
+
// mirrored here for the save/disable/restore dance `waitFor` needs (RTL's
|
|
6
|
+
// act-compat mirrors React's IS_REACT_ACT_ENVIRONMENT global the same way).
|
|
7
|
+
// The mirror is exact as long as the flag is toggled through THIS module —
|
|
8
|
+
// which the auto-registering `index.ts` entry and the `pure.ts` asyncWrapper
|
|
9
|
+
// both do, matching RTL's assumption about its own global.
|
|
10
|
+
import { setIsOctaneActEnvironment } from 'octane';
|
|
11
|
+
|
|
12
|
+
let isActEnvironment = false;
|
|
13
|
+
|
|
14
|
+
export function getIsOctaneActEnvironment(): boolean {
|
|
15
|
+
return isActEnvironment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setOctaneActEnvironment(value: boolean): void {
|
|
19
|
+
isActEnvironment = value;
|
|
20
|
+
setIsOctaneActEnvironment(value);
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `fireEvent` — dom-testing-library's, re-exported UNWRAPPED.
|
|
2
|
+
//
|
|
3
|
+
// react-testing-library layers remappings on top of dom-testing-library's
|
|
4
|
+
// fireEvent because React's synthetic event system listens to DIFFERENT native
|
|
5
|
+
// events than the handler names suggest (mouseEnter handlers run off native
|
|
6
|
+
// mouseover, focus/blur off focusin/focusout, `select` is synthesized from key
|
|
7
|
+
// events, `onChange` fires on native input, …), so RTL double-dispatches to
|
|
8
|
+
// feed React's plugins.
|
|
9
|
+
//
|
|
10
|
+
// Octane has NO synthetic event layer — `onX` handlers receive the native `x`
|
|
11
|
+
// event, delegated. `fireEvent.mouseEnter` therefore dispatches a real
|
|
12
|
+
// `mouseenter` and octane's capture-phase delegation of non-bubbling events
|
|
13
|
+
// delivers it; `fireEvent.change` fires a native `change` (NOT React's
|
|
14
|
+
// input-as-change); no remapping is wanted. The commit wiring (flushSync +
|
|
15
|
+
// effect drain around every dispatch) rides on dom-testing-library's
|
|
16
|
+
// `eventWrapper` config hook — see pure.ts.
|
|
17
|
+
export { fireEvent } from '@testing-library/dom';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Default entry — `pure` plus RTL's test-framework side effects:
|
|
2
|
+
//
|
|
3
|
+
// 1. auto-`cleanup()` after each test when the runner exposes a global
|
|
4
|
+
// `afterEach` (or `teardown`), opt-out via RTL_SKIP_AUTO_CLEANUP;
|
|
5
|
+
// 2. octane's act()-environment warning armed for the whole run when global
|
|
6
|
+
// `beforeAll`/`afterAll` exist (mirrors RTL flipping IS_REACT_ACT_ENVIRONMENT).
|
|
7
|
+
//
|
|
8
|
+
// NOTE: with `globals: false` runners (this repo's vitest config) none of these
|
|
9
|
+
// globals exist, so both registrations are no-ops — register `afterEach(cleanup)`
|
|
10
|
+
// yourself, exactly like importing `@testing-library/react/pure`.
|
|
11
|
+
import { cleanup } from './pure';
|
|
12
|
+
import { getIsOctaneActEnvironment, setOctaneActEnvironment } from './act-environment';
|
|
13
|
+
|
|
14
|
+
type TestHook = (callback: () => void) => void;
|
|
15
|
+
const globals = globalThis as {
|
|
16
|
+
afterEach?: TestHook;
|
|
17
|
+
teardown?: TestHook;
|
|
18
|
+
beforeAll?: TestHook;
|
|
19
|
+
afterAll?: TestHook;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) {
|
|
23
|
+
if (typeof globals.afterEach === 'function') {
|
|
24
|
+
globals.afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
});
|
|
27
|
+
} else if (typeof globals.teardown === 'function') {
|
|
28
|
+
globals.teardown(() => {
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof globals.beforeAll === 'function' && typeof globals.afterAll === 'function') {
|
|
34
|
+
let previousIsActEnvironment = getIsOctaneActEnvironment();
|
|
35
|
+
globals.beforeAll(() => {
|
|
36
|
+
previousIsActEnvironment = getIsOctaneActEnvironment();
|
|
37
|
+
setOctaneActEnvironment(true);
|
|
38
|
+
});
|
|
39
|
+
globals.afterAll(() => {
|
|
40
|
+
setOctaneActEnvironment(previousIsActEnvironment);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export * from './pure';
|
package/src/pure.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @octanejs/testing-library — the side-effect-free core (`…/pure` entry).
|
|
3
|
+
*
|
|
4
|
+
* Strategy (docs/react-library-compat-plan.md §2): `@testing-library/dom` is
|
|
5
|
+
* framework-agnostic, so it is depended on VERBATIM and re-exported wholesale
|
|
6
|
+
* (queries, `screen`, `within`, `waitFor`, `fireEvent`, `prettyDOM`, …). Only
|
|
7
|
+
* react-testing-library's thin React layer is ported here, onto octane:
|
|
8
|
+
*
|
|
9
|
+
* - `render` / `cleanup` / `renderHook` mount through octane's `createRoot`
|
|
10
|
+
* (or `hydrateRoot`) and commit synchronously via `flushSync` + a passive-
|
|
11
|
+
* effect drain — the observable equivalent of RTL wrapping every render in
|
|
12
|
+
* a synchronous React `act()`.
|
|
13
|
+
* - dom-testing-library's config hooks are wired to octane's commit machinery
|
|
14
|
+
* (`eventWrapper` → flushSync, `asyncWrapper` → act-environment suspension,
|
|
15
|
+
* `unstable_advanceTimersWrapper` → octane `act`) exactly where RTL wires
|
|
16
|
+
* them to React's act-compat.
|
|
17
|
+
*
|
|
18
|
+
* Octane-specific surface (documented in the README): components in plain-`.ts`
|
|
19
|
+
* tests are values, not JSX — so `render` accepts BOTH an element descriptor
|
|
20
|
+
* (`render(createElement(App, {x: 1}))`) and the bare component + a `props`
|
|
21
|
+
* render-option (`render(App, {props: {x: 1}})`).
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
act,
|
|
25
|
+
createElement,
|
|
26
|
+
createRoot,
|
|
27
|
+
drainPassiveEffects,
|
|
28
|
+
flushSync,
|
|
29
|
+
hasPendingWork,
|
|
30
|
+
hydrateRoot,
|
|
31
|
+
isValidElement,
|
|
32
|
+
useEffect,
|
|
33
|
+
withSlot,
|
|
34
|
+
type ComponentBody,
|
|
35
|
+
type ElementDescriptor,
|
|
36
|
+
type Root,
|
|
37
|
+
} from 'octane';
|
|
38
|
+
import {
|
|
39
|
+
configure as configureDTL,
|
|
40
|
+
getQueriesForElement,
|
|
41
|
+
prettyDOM,
|
|
42
|
+
queries as defaultQueries,
|
|
43
|
+
} from '@testing-library/dom';
|
|
44
|
+
import type { BoundFunctions, Queries } from '@testing-library/dom';
|
|
45
|
+
import { getIsOctaneActEnvironment, setOctaneActEnvironment } from './act-environment';
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// dom-testing-library config wiring (RTL pure.js does the same three hooks).
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
// vitest/sinon fake timers stamp a `clock` property on the mocked setTimeout;
|
|
52
|
+
// jest's legacy timer mocks stamp `_isMockFunction` (the same two probes RTL
|
|
53
|
+
// ships). Used to avoid parking `asyncWrapper` on a macrotask that a fake
|
|
54
|
+
// clock would never fire.
|
|
55
|
+
function fakeTimersAreEnabled(): boolean {
|
|
56
|
+
const st = setTimeout as unknown as { _isMockFunction?: boolean };
|
|
57
|
+
return st._isMockFunction === true || Object.prototype.hasOwnProperty.call(setTimeout, 'clock');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
configureDTL({
|
|
61
|
+
// `waitFor` with fake timers advances the clock through this wrapper; octane's
|
|
62
|
+
// act() drains the renders/effects each advancement schedules.
|
|
63
|
+
unstable_advanceTimersWrapper: (cb) => act(cb as () => unknown),
|
|
64
|
+
// `waitFor`/`findBy*` bodies await work that legitimately updates state outside
|
|
65
|
+
// an act() scope — suspend the "not wrapped in act(...)" warning for the
|
|
66
|
+
// duration (RTL does the same around IS_REACT_ACT_ENVIRONMENT).
|
|
67
|
+
asyncWrapper: async (cb) => {
|
|
68
|
+
const previousActEnvironment = getIsOctaneActEnvironment();
|
|
69
|
+
setOctaneActEnvironment(false);
|
|
70
|
+
try {
|
|
71
|
+
const result = await cb();
|
|
72
|
+
// Let in-flight promise reactions land before the act environment is
|
|
73
|
+
// restored (RTL's 0ms settle). Octane flushes scheduled renders on
|
|
74
|
+
// microtasks, so under a fake clock — where a real 0ms timer would never
|
|
75
|
+
// fire and library code has no handle to advance vitest's clock (unlike
|
|
76
|
+
// jest's global) — a drained microtask queue is already quiescent.
|
|
77
|
+
await new Promise<void>((resolve) => {
|
|
78
|
+
if (fakeTimersAreEnabled()) {
|
|
79
|
+
void Promise.resolve().then(() => resolve());
|
|
80
|
+
} else {
|
|
81
|
+
setTimeout(() => resolve(), 0);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
settleSync();
|
|
85
|
+
return result;
|
|
86
|
+
} finally {
|
|
87
|
+
setOctaneActEnvironment(previousActEnvironment);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
// Every `fireEvent` dispatch runs through here. Octane's DISCRETE delegated
|
|
91
|
+
// events (click/input/keydown/…) already commit synchronously on their own
|
|
92
|
+
// (maybeFlushDiscrete → flushSync), but fireEvent also dispatches event types
|
|
93
|
+
// with no discrete path — non-delegated or programmatic events whose updates
|
|
94
|
+
// would otherwise sit queued until the next microtask, i.e. after the test's
|
|
95
|
+
// assertion. flushSync commits those too, and the passive drain mirrors RTL's
|
|
96
|
+
// act(): effects scheduled by the event have run before fireEvent returns.
|
|
97
|
+
eventWrapper: (cb) => {
|
|
98
|
+
let result: unknown;
|
|
99
|
+
flushSync(() => {
|
|
100
|
+
result = cb();
|
|
101
|
+
});
|
|
102
|
+
settleSync();
|
|
103
|
+
return result;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// Mounted-root bookkeeping (RTL pure.js's mountedContainers/mountedRootEntries):
|
|
109
|
+
// a Set for the constant-time "new container?" check, an array for cleanup().
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
interface RootEntry {
|
|
113
|
+
container: Element;
|
|
114
|
+
root: Root;
|
|
115
|
+
}
|
|
116
|
+
const mountedContainers = new Set<Element>();
|
|
117
|
+
const mountedRootEntries: RootEntry[] = [];
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// UI normalization — octane's two authoring forms into one mountable element.
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** What `render`/`rerender` accept: a component body, or a `createElement` result. */
|
|
124
|
+
export type OctaneUI<P = any> = ComponentBody<P> | ElementDescriptor<P>;
|
|
125
|
+
|
|
126
|
+
// A root can only mount a COMPONENT. `render(createElement('div', …))` (RTL's
|
|
127
|
+
// `render(<div/>)`) produces a HOST descriptor, so it is returned from a pass-
|
|
128
|
+
// through component and rendered by the runtime's value-position renderer.
|
|
129
|
+
function ValueRoot(props: { children: unknown }): unknown {
|
|
130
|
+
return props.children;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toElement<P>(ui: OctaneUI<P>, props: P | undefined): ElementDescriptor {
|
|
134
|
+
if (isValidElement(ui)) {
|
|
135
|
+
if (props !== undefined) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'render/rerender received both an element descriptor and `props` — ' +
|
|
138
|
+
'pass props through createElement(Component, props), or pass the bare component.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return ui;
|
|
142
|
+
}
|
|
143
|
+
if (typeof ui !== 'function') {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'render/rerender expects an octane component (a function) or an element ' +
|
|
146
|
+
`descriptor from createElement(...); received ${typeof ui}.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return createElement(ui, props);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function wrapUiIfNeeded(
|
|
153
|
+
element: ElementDescriptor,
|
|
154
|
+
wrapperComponent: ComponentBody<{ children: any }> | undefined,
|
|
155
|
+
): ElementDescriptor {
|
|
156
|
+
return wrapperComponent ? createElement(wrapperComponent, { children: element }) : element;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mountable(element: ElementDescriptor): ElementDescriptor {
|
|
160
|
+
return typeof element.type === 'string'
|
|
161
|
+
? createElement(ValueRoot, { children: element })
|
|
162
|
+
: element;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Drain render ⇄ passive-effect cascades to quiescence, SYNCHRONOUSLY: each
|
|
166
|
+
// round runs queued useEffect bodies (which may setState) and then commits the
|
|
167
|
+
// renders they scheduled (flushSync also runs layout-effect cascades, queuing
|
|
168
|
+
// the next round's passives). This is the observable behavior of RTL wrapping
|
|
169
|
+
// work in a sync React `act()`. Loops on octane's scheduler-quiescence probe
|
|
170
|
+
// (`hasPendingWork`) for an EXACT settle; the round cap only guards against a
|
|
171
|
+
// pathological unbounded effect→setState cycle (which octane's own
|
|
172
|
+
// "Too many re-renders" guard would surface anyway). Purely promise-driven work
|
|
173
|
+
// (use(promise), transitions) can never settle synchronously — that's what
|
|
174
|
+
// `waitFor`/`findBy*`/`act` are for, same as RTL.
|
|
175
|
+
function settleSync(): void {
|
|
176
|
+
for (let i = 0; i < 50 && hasPendingWork(); i++) {
|
|
177
|
+
drainPassiveEffects();
|
|
178
|
+
flushSync(() => {});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Commit a render synchronously — flushSync for the render itself, then the
|
|
183
|
+
// cascade settle. Equivalent to RTL's `act(() => root.render(ui))` (sync form).
|
|
184
|
+
function commitRender(root: Root, element: ElementDescriptor): void {
|
|
185
|
+
flushSync(() => {
|
|
186
|
+
root.render(element);
|
|
187
|
+
});
|
|
188
|
+
settleSync();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
// render()
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export interface RenderOptions<P = any> {
|
|
196
|
+
/** Element the UI mounts into. Defaults to a fresh <div> appended to baseElement. */
|
|
197
|
+
container?: HTMLElement;
|
|
198
|
+
/** Element the returned queries are bound to. Defaults to `container` or document.body. */
|
|
199
|
+
baseElement?: HTMLElement;
|
|
200
|
+
/** Adopt server-rendered DOM already inside `container` via octane's hydrateRoot. */
|
|
201
|
+
hydrate?: boolean;
|
|
202
|
+
/** Custom dom-testing-library queries (untyped passthrough; result is typed for the defaults). */
|
|
203
|
+
queries?: Queries;
|
|
204
|
+
/** Component rendered around the UI — must render `{props.children}` (providers etc.). */
|
|
205
|
+
wrapper?: ComponentBody<{ children: any }>;
|
|
206
|
+
/**
|
|
207
|
+
* Octane extension: props for the `render(Component, {props})` form — in
|
|
208
|
+
* plain-`.ts` tests a component is a VALUE, so this replaces JSX's inline
|
|
209
|
+
* props. Ignored (an error) when `ui` is already an element descriptor.
|
|
210
|
+
*/
|
|
211
|
+
props?: P;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type DebugFn = (
|
|
215
|
+
el?: Element | DocumentFragment | Array<Element | DocumentFragment>,
|
|
216
|
+
maxLength?: number,
|
|
217
|
+
options?: Parameters<typeof prettyDOM>[2],
|
|
218
|
+
) => void;
|
|
219
|
+
|
|
220
|
+
export type RenderResult = {
|
|
221
|
+
container: HTMLElement;
|
|
222
|
+
baseElement: HTMLElement;
|
|
223
|
+
debug: DebugFn;
|
|
224
|
+
/** Re-render (same component ⇒ props update in place, like RTL's rerender). */
|
|
225
|
+
rerender: (ui: OctaneUI, props?: any) => void;
|
|
226
|
+
unmount: () => void;
|
|
227
|
+
asFragment: () => DocumentFragment;
|
|
228
|
+
} & BoundFunctions<typeof defaultQueries>;
|
|
229
|
+
|
|
230
|
+
export function render<P = any>(ui: OctaneUI<P>, options: RenderOptions<P> = {}): RenderResult {
|
|
231
|
+
const { hydrate = false, props, queries, wrapper: WrapperComponent } = options;
|
|
232
|
+
let { baseElement, container } = options;
|
|
233
|
+
|
|
234
|
+
if (!baseElement) {
|
|
235
|
+
// Default to document.body (not documentElement) — matches RTL, keeps
|
|
236
|
+
// debug output free of <head> noise.
|
|
237
|
+
baseElement = options.container ?? document.body;
|
|
238
|
+
}
|
|
239
|
+
if (!container) {
|
|
240
|
+
container = baseElement.appendChild(document.createElement('div'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const element = mountable(wrapUiIfNeeded(toElement(ui, props), WrapperComponent));
|
|
244
|
+
|
|
245
|
+
let root: Root;
|
|
246
|
+
if (!mountedContainers.has(container)) {
|
|
247
|
+
if (hydrate) {
|
|
248
|
+
// hydrateRoot adopts the server DOM during creation (it renders once,
|
|
249
|
+
// synchronously) — only the effect settle is left to do.
|
|
250
|
+
root = hydrateRoot(container, element);
|
|
251
|
+
settleSync();
|
|
252
|
+
} else {
|
|
253
|
+
root = createRoot(container);
|
|
254
|
+
commitRender(root, element);
|
|
255
|
+
}
|
|
256
|
+
mountedRootEntries.push({ container, root });
|
|
257
|
+
// Track the container regardless of whether WE created it, so cleanup()
|
|
258
|
+
// handles caller-supplied containers too (RTL does the same).
|
|
259
|
+
mountedContainers.add(container);
|
|
260
|
+
} else {
|
|
261
|
+
// Same container rendered again → reuse its root (same component updates
|
|
262
|
+
// props in place; a different component tears down and remounts).
|
|
263
|
+
root = mountedRootEntries.find((entry) => entry.container === container)!.root;
|
|
264
|
+
commitRender(root, element);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
container,
|
|
269
|
+
baseElement,
|
|
270
|
+
// prettyDOM handles DocumentFragments at runtime; its published type only
|
|
271
|
+
// admits Element, hence the casts.
|
|
272
|
+
debug: (el = baseElement, maxLength, debugOptions) =>
|
|
273
|
+
Array.isArray(el)
|
|
274
|
+
? // eslint-disable-next-line no-console
|
|
275
|
+
el.forEach((e) => console.log(prettyDOM(e as Element, maxLength, debugOptions)))
|
|
276
|
+
: // eslint-disable-next-line no-console
|
|
277
|
+
console.log(prettyDOM(el as Element, maxLength, debugOptions)),
|
|
278
|
+
unmount: () => {
|
|
279
|
+
flushSync(() => {
|
|
280
|
+
root.unmount();
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
rerender: (rerenderUi: OctaneUI, rerenderProps?: any) => {
|
|
284
|
+
// The wrapper is re-applied so component identity is stable across
|
|
285
|
+
// rerenders (same body ⇒ octane updates props in place).
|
|
286
|
+
commitRender(
|
|
287
|
+
root,
|
|
288
|
+
mountable(wrapUiIfNeeded(toElement(rerenderUi, rerenderProps), WrapperComponent)),
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
asFragment: () => document.createRange().createContextualFragment(container.innerHTML),
|
|
292
|
+
...(getQueriesForElement(baseElement, queries as any) as BoundFunctions<typeof defaultQueries>),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
// cleanup()
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
export function cleanup(): void {
|
|
301
|
+
for (const { root, container } of mountedRootEntries) {
|
|
302
|
+
flushSync(() => {
|
|
303
|
+
root.unmount();
|
|
304
|
+
});
|
|
305
|
+
if (container.parentNode === document.body) {
|
|
306
|
+
document.body.removeChild(container);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
mountedRootEntries.length = 0;
|
|
310
|
+
mountedContainers.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
// renderHook()
|
|
315
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
export interface RenderHookOptions<Props> extends Omit<RenderOptions, 'props'> {
|
|
318
|
+
/** Props passed to the hook callback on the first render. */
|
|
319
|
+
initialProps?: Props;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface RenderHookResult<Result, Props> {
|
|
323
|
+
/** Latest COMMITTED hook result (recorded from an effect, like RTL). */
|
|
324
|
+
result: { current: Result };
|
|
325
|
+
rerender: (props?: Props) => void;
|
|
326
|
+
unmount: () => void;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The harness component below is plain `.ts`, so its own hooks take EXPLICIT
|
|
330
|
+
// slot symbols (this package is excluded from the compiler's auto-slotting
|
|
331
|
+
// pass; published node_modules are skipped by it anyway). Hook state is keyed
|
|
332
|
+
// per (component scope, slot), so module-level symbols are safe across
|
|
333
|
+
// concurrently mounted harnesses.
|
|
334
|
+
const HOOK_PATH_SLOT = Symbol.for('@octanejs/testing-library:renderHook/callback');
|
|
335
|
+
const RECORD_EFFECT_SLOT = Symbol.for('@octanejs/testing-library:renderHook/record');
|
|
336
|
+
|
|
337
|
+
export function renderHook<Result, Props = undefined>(
|
|
338
|
+
renderCallback: (props: Props) => Result,
|
|
339
|
+
options: RenderHookOptions<Props> = {},
|
|
340
|
+
): RenderHookResult<Result, Props> {
|
|
341
|
+
const { initialProps, ...renderOptions } = options;
|
|
342
|
+
const result: { current: Result } = { current: undefined as never };
|
|
343
|
+
|
|
344
|
+
// Defined per renderHook call (RTL does the same) so each invocation gets a
|
|
345
|
+
// distinct component identity, while rerenders of THIS invocation keep it.
|
|
346
|
+
function TestComponent(props: { renderCallbackProps: Props }) {
|
|
347
|
+
// withSlot pushes a call-site path for the callback's duration: a slotless
|
|
348
|
+
// custom hook reached from here (one the compiler never saw — e.g. a
|
|
349
|
+
// binding hook called directly in a plain-`.ts` test) still resolves a
|
|
350
|
+
// hook identity, and the trailing symbol serves bindings that read their
|
|
351
|
+
// slot off the last argument. Compiled callbacks are unaffected — their
|
|
352
|
+
// explicit per-call-site slots fold into the path deterministically.
|
|
353
|
+
// (A slotless callback calling TWO+ base hooks directly still needs the
|
|
354
|
+
// compiler or explicit symbols — the path alone can't tell them apart.)
|
|
355
|
+
const pendingResult = withSlot(
|
|
356
|
+
HOOK_PATH_SLOT,
|
|
357
|
+
renderCallback as (...args: any[]) => Result,
|
|
358
|
+
props.renderCallbackProps,
|
|
359
|
+
HOOK_PATH_SLOT,
|
|
360
|
+
);
|
|
361
|
+
// Record on COMMIT (RTL records from useEffect): a render that throws
|
|
362
|
+
// never publishes a result.
|
|
363
|
+
useEffect(
|
|
364
|
+
() => {
|
|
365
|
+
result.current = pendingResult;
|
|
366
|
+
},
|
|
367
|
+
undefined,
|
|
368
|
+
RECORD_EFFECT_SLOT,
|
|
369
|
+
);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { rerender: baseRerender, unmount } = render(TestComponent, {
|
|
374
|
+
...renderOptions,
|
|
375
|
+
props: { renderCallbackProps: initialProps as Props },
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
result,
|
|
380
|
+
rerender: (rerenderCallbackProps?: Props) =>
|
|
381
|
+
baseRerender(TestComponent, { renderCallbackProps: rerenderCallbackProps as Props }),
|
|
382
|
+
unmount,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
387
|
+
// Exports — everything dom-testing-library ships, plus the octane layer.
|
|
388
|
+
// `fireEvent` is dom-testing-library's own (see fire-event.ts for why there is
|
|
389
|
+
// deliberately NO react-testing-library-style event remapping on top).
|
|
390
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
export * from '@testing-library/dom';
|
|
393
|
+
export { fireEvent } from './fire-event';
|
|
394
|
+
export { act } from 'octane';
|