react-native-preflight
Simplify Maestro E2E testing for React Native. Test screens in isolation, browse a dev catalog, and catch visual regressions — all with zero production overhead.
Features
- Isolated screen testing — Deep link directly to any screen with pre-injected state
- Dev catalog — Browse and preview all testable screens in one place
- Visual regression — Screenshot comparison with HTML reports
- Zero prod impact — Babel plugin strips all preflight code from production builds
- CLI tooling — Generate test skeletons, run tests, compare snapshots
- Router agnostic — Works with Expo Router (auto-detected) or React Navigation via
onNavigate
Installation
npm install react-native-preflight
Peer dependencies: expo-linking, react, react-native. expo-router is optional — if installed, navigation works automatically. Without it, provide an onNavigate prop.
Claude Code Plugin
Skip the manual setup — this package ships a Claude Code plugin that handles everything: Babel config, StateInjector, screen wrapping, and Maestro YAML generation.
# Add the marketplace and install the plugin
/plugin marketplace add RamboWasReal/react-native-preflight
/plugin install react-native-preflight@react-native-preflight-plugins
Then run:
/react-native-preflight:preflight-setup
Already using Claude Code? This is the fastest way to get started. The plugin auto-detects your project structure (Expo Router or React Navigation) and configures everything.
Quick Start (manual)
1. Initialize
npx preflight init
Creates .maestro/ directories, adds the preflight deep link scheme to app.json, scaffolds a catalog screen, and configures the Babel plugin.
2. Wrap your screens
Use scenario() to register a screen for testing. It wraps the component and makes it discoverable by the catalog and CLI.
import { scenario } from 'react-native-preflight';
export default scenario(
{
id: 'settings',
route: '/settings',
description: 'Settings screen',
inject: async () => {
// Pre-populate stores, query cache, etc.
},
test: ({ see, tap, scroll }) => [
see('Settings'),
tap('dark-mode-toggle'),
scroll('footer', 'down'),
],
},
function SettingsScreen() {
// your component...
},
);
-
id — Unique identifier, used as Maestro testID and YAML filename
-
route — Must match the file-based route (Expo Router) or screen name (React Navigation)
-
inject() — Called BEFORE navigation to set up deterministic state (zero flash)
-
test() — Optional. Generates Maestro test steps via npx preflight generate:
see('text') — assert visible text
see({ id: 'testID' }) — assert testID visible
tap('buttonId') — tap element by testID
longPress('itemId') — long press element by testID
type('inputId', 'value') — type text into input
notSee('text') — assert text not visible
wait(2000) — wait N milliseconds
scroll('elementId', 'down') — scroll until element is visible (scrollUntilVisible)
swipe('left') — swipe in a direction (default 400ms)
swipe('up', 200) — swipe with custom duration
back() — press back button
hideKeyboard() — dismiss the keyboard
raw('- setLocation:\n latitude: 45.5') — inject raw Maestro YAML
Test functions can be extracted to separate files and imported:
// e2e/tests/settings.ts
import type { TestHelpers } from 'react-native-preflight';
export const settingsTest = ({ see, tap }: TestHelpers) => [
see('Settings'),
tap('dark-mode-toggle'),
];
// app/settings.tsx
import { settingsTest } from '@/e2e/tests/settings';
export default scenario({ id: 'settings', route: '/settings', test: settingsTest }, SettingsScreen);
The generator follows single-level imports to resolve test steps from external files.
-
variants — Optional. Test multiple states of the same screen. Each variant inherits route, inject, and test from the base config unless overridden:
export default scenario({
id: 'dashboard',
route: '/dashboard',
variants: {
'with-data': {
inject: () => { /* populate stores with mock data */ },
test: ({ see }) => [see('Welcome back')],
},
'empty-state': {
inject: () => { /* clear all stores */ },
test: ({ see }) => [see('Get started')],
},
},
}, DashboardScreen);
Generates screens/dashboard/with-data.yaml and screens/dashboard/empty-state.yaml.
3. Add StateInjector
Wrap your root layout with StateInjector. It listens for preflight:// deep links, calls inject, then navigates.
Expo Router (auto-detected):
// app/_layout.tsx
import { StateInjector } from 'react-native-preflight';
export default function RootLayout() {
return (
<StateInjector>
<Stack />
</StateInjector>
);
}