React Testing Library for the octane UI framework.
The split mirrors RTL's own architecture (and
docs/react-library-compat-plan.md §2): @testing-library/dom is
framework-agnostic and reused verbatim — every query, screen, within,
waitFor/waitForElementToBeRemoved, findBy*, fireEvent, prettyDOM,
configure — while only react-testing-library's thin React layer is ported to
octane: render, cleanup, renderHook, the act re-export, and the
dom-testing-library config wiring (eventWrapper/asyncWrapper) that makes
every dispatch/wait commit octane's scheduled work before your assertions run.
import { render, screen, fireEvent, cleanup } from '@octanejs/testing-library';
import { Counter } from './Counter.tsrx';
afterEach(cleanup); // automatic when your runner exposes a global afterEach
test('increments', () => {
render(Counter, { props: { step: 2 } });
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('button').textContent).toBe('Count: 2');
});render(ui, options?)→{ container, baseElement, ...queries, rerender, unmount, asFragment, debug }cleanup()— unmounts everythingrendermounted (auto-registered afterEach when a globalafterEach/teardownexists; opt out withRTL_SKIP_AUTO_CLEANUP=trueor import@octanejs/testing-library/pure)renderHook(callback, { initialProps, wrapper, ... }?)→{ result, rerender, unmount }act— octane'sact, re-exported (always async; alwaysawaitit)fireEvent,screen,waitFor,within, … — dom-testing-library, re-exported
Components are values in plain-.ts tests — there's no JSX in a .ts
file, so render (and rerender) take two forms:
render(Counter, { props: { step: 2 }, wrapper: Providers }); // body + props option
render(createElement(Counter, { step: 2 }), { wrapper: Providers }); // RTL-style element
rerender(Counter, { props: { step: 3 } }); // symmetric with render's options
rerender({ props: { step: 3 } }); // shorthand: original component, new propsSame component ⇒ props update in place; a different component tears down and remounts — exactly RTL's rerender semantics.
Octane dispatches native, delegated DOM events — there is no synthetic event
layer — so fireEvent is dom-testing-library's, deliberately without RTL's
React-specific remappings:
fireEvent.changefires a nativechange;fireEvent.inputa nativeinput. In React,onChangehandlers actually run off nativeinputevents, so RTL tests habitually drive text inputs withfireEvent.change. In octaneonChangemeans the platformchangeevent (fires on commit/blur), andonInputfires per keystroke — port such tests tofireEvent.input(or better,@testing-library/user-event, which emits real event sequences). Controlled components ARE supported (2026-07-08): avalue/checkedprop drives the DOM property and reasserts on every commit and after discrete events, exactly like React — only the syntheticonChangenormalization is absent, so a controlled text input updates its state fromonInput.- Text commit behavior is separate and intentional.
user.type(input, text)emits nativeinputevents; a textonChangedoes not run until the edit is committed, such as whenawait user.tab()blurs the field. A commit-only host should usedefaultValue, nativeonChange, andsuppressNativeChangeWarning. The hint only acknowledges intent; it does not remap or dispatch an event. - Checkables need click activation when activation is under test.
await user.click(checkbox)produces the nativeclick→input→changesequence and automatic checked-state transition.fireEvent.change(checkbox)is only an explicit change dispatch; it does not model the click, activation, cancellation/rollback, or full event ordering. - No enter/leave/focus double-dispatch. RTL's
fireEvent.mouseEnteralso firesmouseover(andfocusfiresfocusin,selectfireskeyup, …) purely to feed React's plugin system, which listens to different native events than the handler names suggest. Octane'sonMouseEnterreceives the realmouseenter(non-bubbling events are capture-delegated), sofireEvent.mouseEnteralone triggers it — no compensation needed or wanted. - Commit timing is wired, not synthesized. Octane already commits discrete
events (
click,input,keydown, …) synchronously; this package additionally wraps everyfireEventdispatch influshSync+ an effect drain via dom-testing-library'seventWrapper, so non-discrete/programmatic events also commit — with theiruseEffectcascades — beforefireEventreturns (the equivalent of RTL'sact()around each dispatch). - Host elements at the root are
container.firstChild, like RTL.render(createElement('div', …))goes through octane's value-position renderer, which mounts a lone host element anchorless (the element self-delimits, no comment markers) — so RTL'scontainer.firstChildidiom works as-is. Component roots (render(App, …)) mount their template directly, also without anchors. renderHookand hook slots. Octane hooks are keyed by compiler-assigned call-site slots. Hook callbacks written in your test files Just Work — the vite plugin's surgical pass slots base-hook calls in plain.ts, and.tsrx/.tsxhooks are fully compiled. The harness additionally runs your callback under awithSlotpath, so calling a single pre-built binding hook (renderHook(() => useStore(api))) works even unslotted. A hand-written callback that calls two or more base hooks directly without compilation (e.g. authored outside the vite plugin) still needs explicit slot symbols:useState(0, Symbol.for('a')).- Not ported (no octane equivalent, by design):
reactStrictMode/ StrictMode double-render,legacyRoot,onCaughtError/onRecoverableErrorroot options, and RTL'sconfigure({reactStrictMode})wrapper —configure/getConfighere are dom-testing-library's own. hydrate: trueadopts server-rendered DOM already insidecontainervia octane'shydrateRoot(the container must hold octane SSR output).
Like RTL, importing the package root auto-registers afterEach(cleanup) and
arms octane's "update was not wrapped in act(...)" warning for the run — but
only when your runner exposes global test hooks (vitest globals: true,
jest). With globals: false, register it yourself:
import { cleanup } from '@octanejs/testing-library';
import { afterEach } from 'vitest';
afterEach(cleanup);@octanejs/testing-library/pure skips the side effects entirely, exactly like
@testing-library/react/pure.
Works as-is — no octane adapter needed. user-event is framework-agnostic and
dispatches real native events, which is exactly octane's event model (a
better fit than React, where it relies on the synthetic layer picking natives
up). Install it alongside this package and use it unchanged; the pairing is
pinned by tests/user-event.test.ts (click, type() per-keystroke onInput,
text commit on tab(), checkbox click ordering, keyboard()).
Current scope, known divergences, and verification status are tracked in the
generated bindings status table, sourced from
this package's status.json.