@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 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';