@rozenite/testing
v2.4.0
Published
In-process test doubles for Rozenite plugin communication — run a panel and its react-native.ts entry against each other in Node, without Metro or a simulator.
Maintainers
Readme

In-process test doubles for Rozenite plugin communication.
Testing a plugin change today usually means running Metro, a simulator, the playground app, and React Native DevTools together — right for pre-release verification, but far too expensive for the most frequent check: that a panel and its react-native.ts entry still agree on the protocol (messages and RPCs).
@rozenite/testing gives you an in-memory Channel pair and a couple of waitFor helpers built on @rozenite/plugin-bridge's channel injection seam, so you can run both halves of a plugin — the real, unmodified panel code and the real, unmodified device code — against each other in Node, in milliseconds. No Metro, no simulator, no DevTools.
Features
- In-process fake channel pair —
connectFakePair()gives you the two ends of a realChannel; hand one to the device side and one to the panel side viagetRozeniteDevToolsClient/useRozeniteDevToolsClient'schanneloption, and both run their actual protocol code. - Render the real components —
RozeniteChannelProviderhands a channel to everyuseRozeniteDevToolsClient()in a subtree, so a plugin's own panel components can be rendered with React Testing Library (or any renderer) unmodified — nochannelprop threaded through them for tests only. - Transport simulation — drop or delay messages in either direction, to test a side that never mounts or a slow transport.
waitForhelpers with a deadline —waitForMessage,waitForChannelMessage, and the RPC-awarewaitForRpcFrameall take a requiredtimeoutMsand reject with a clearWaitForTimeoutErroron expiry, instead of hanging your test suite forever.- Runner-agnostic — no dependency on Vitest, Jest, or any runner globals. Everything here returns plain values and promises; keep using your own runner's assertions.
Installation
Install the testing package alongside @rozenite/plugin-bridge, which it builds on:
npm install --save-dev @rozenite/testing @rozenite/plugin-bridgeQuick Start
import { getRozeniteDevToolsClient, createRozeniteRpc } from '@rozenite/plugin-bridge';
import { connectFakePair, waitForMessage } from '@rozenite/testing';
// Two ends of one in-memory Channel.
const { device, panel } = connectFakePair();
// Give one end to each side's real, unmodified client — this is the
// injection seam `@rozenite/plugin-bridge` exposes for exactly this.
const deviceClient = await getRozeniteDevToolsClient('my-plugin', { channel: device });
const panelClient = await getRozeniteDevToolsClient('my-plugin', { channel: panel });
// Wire up the device side's real message handler...
deviceClient.onMessage('get-items', () => {
deviceClient.send('items', { items: ['a', 'b'] });
});
// ...and drive it from the panel side.
panelClient.send('get-items', {});
const response = await waitForMessage(panelClient, 'items', { timeoutMs: 1000 });
// response is { items: ['a', 'b'] }The same pattern works for RPC methods built with createRozeniteRpc:
import { createRozeniteRpc } from '@rozenite/plugin-bridge';
const deviceRpc = createRozeniteRpc(deviceClient);
const panelRpc = createRozeniteRpc(panelClient);
deviceRpc.handle('getSnapshot', async () => ({ items: [] }));
const snapshot = await panelRpc.method('getSnapshot').invoke();Rendering a panel with React Testing Library
Plugin components call useRozeniteDevToolsClient({ pluginId }) themselves and take no channel prop — so to render one in a test, wrap it in RozeniteChannelProvider and give it one end of a fake pair. Every useRozeniteDevToolsClient() below the provider uses that channel instead of resolving a real one from the environment.
import { render, screen } from '@testing-library/react';
import { getRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
import { connectFakePair, RozeniteChannelProvider } from '@rozenite/testing';
import { MyPluginPanel } from '../src/panel';
const { device, panel } = connectFakePair();
// The device side, wired up exactly as `react-native.ts` wires it.
const deviceClient = await getRozeniteDevToolsClient('my-plugin', { channel: device });
deviceClient.onMessage('get-items', () => {
deviceClient.send('items', { items: ['alpha', 'beta'] });
});
render(
<RozeniteChannelProvider channel={panel} role="panel">
<MyPluginPanel />
</RozeniteChannelProvider>,
);
expect(await screen.findByText('alpha')).toBeTruthy();role tells the provider which side of the protocol the subtree stands in for. Only the device side announces itself with the plugin-mounted lifecycle message, so role="panel" keeps that message off the wire (and role="device" puts it there) without your test having to set the process-wide __ROZENITE_PANEL__ global. Leave role unset to keep the global's behavior.
The device side can be rendered the same way when it lives in a hook rather than in plain functions:
render(
<RozeniteChannelProvider channel={device} role="device">
<DeviceSide />
</RozeniteChannelProvider>,
);A channel passed directly to useRozeniteDevToolsClient({ pluginId, channel }) still wins over the provider, and there is no provider in production — plugins render without one and resolve a real channel as before.
Transport simulation
const { device, panel, dropDeviceToPanel, delayPanelToDevice } = connectFakePair();
// Simulate the panel never mounting: every message the device sends is dropped.
dropDeviceToPanel(true);
// Simulate a slow transport in the other direction.
delayPanelToDevice(500);waitFor helpers
import { waitForMessage, waitForChannelMessage, waitForRpcFrame } from '@rozenite/testing';
// Client-level: waits for the next message of `type`, optionally filtered.
await waitForMessage(
client,
'storage:list-response',
{ timeoutMs: 1000 },
(payload) => payload.requestId === myRequestId,
);
// Channel-level: below the pluginId/type demultiplexing a client does.
await waitForChannelMessage(channel, (message) => isMyEnvelope(message), {
timeoutMs: 1000,
});
// RPC-aware: waits for a frame on the reserved `rozenite:rpc` message type.
await waitForRpcFrame(client, (frame) => frame.kind === 'request', { timeoutMs: 1000 });Every one of these rejects with a WaitForTimeoutError — not a hang — when nothing matching arrives before timeoutMs.
Made with ❤️ at Callstack
rozenite is an open source project and will always remain free to use. If you think it's cool, please star it 🌟.
Callstack is a group of React and React Native geeks, contact us at [email protected] if you need any help with these or just want to say hi!
Like the project? ⚛️ Join the team who does amazing stuff for clients and drives React Native Open Source! 🔥
