Help us improve
Share bugs, ideas, or general feedback.
Integrates react-native-preflight into a React Native project. Handles scenario wrapping, StateInjector setup, Babel plugin config, and Maestro YAML generation for Expo Router or React Navigation.
npx claudepluginhub rambowasreal/react-native-preflight --plugin react-native-preflightHow this skill is triggered — by the user, by Claude, or both
Slash command
/react-native-preflight:preflight-setupThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Your job is to configure the project and create working scenario wrappers.
Guides Test-Driven Development for React Native using Jest (jest-expo) and @testing-library/react-native. Use before implementing features, bugfixes, or refactors.
Automates mobile app testing on iOS and Android using Maestro MCP: launches apps, interacts with UI elements, captures screenshots, runs flows, collects evidence. Use to verify implementations before completion.
Guides writing and configuring Detox E2E tests for React Native mobile apps, including setup, matchers, actions, and CI integration.
Share bugs, ideas, or general feedback.
Your job is to configure the project and create working scenario wrappers.
DO NOT just show templates or explain concepts. Actually read the project files, make changes, and produce a working integration.
Before making any changes, check what's already in place:
Check if the package is installed and up to date:
Read package.json for react-native-preflight in dependencies. If not installed, run npm install react-native-preflight (or yarn/pnpm equivalent).
Check the installed version against the latest on npm. If outdated, suggest upgrading: npm install react-native-preflight@latest.
After an upgrade, run npx preflight generate to regenerate all YAML files with the latest format.
Check for existing Maestro setup — Look for .maestro/ directory, any .yaml test files, maestro in package.json scripts, or a .maestro.yaml config file.
Check for existing preflight setup — Look for react-native-preflight in imports, scenario() calls, StateInjector usage, or the babel plugin in babel.config.js.
Check for existing deep link scheme — Read app.json for existing scheme config.
If Maestro tests already exist:
scenario(), match existing test IDs and flowsnpx preflight generate regenerates all YAML files from scenario() definitions on every runIf preflight is partially configured:
If nothing exists, proceed normally.
Detect the framework and source directory before making any changes. This determines how screens are scaffolded and where to scan.
package.json "preflight" key or preflight.config.js for explicit srcDir. If set, use it.app/_layout.tsx — If app/_layout.tsx (or .ts, .jsx, .js) exists at project root → framework is Expo Router, srcDir is app/.src/app/_layout.tsx — Same check in src/app/ → framework is Expo Router, srcDir is src/app/.src/screens/ — If src/screens/ directory exists → framework is React Navigation, srcDir is src/screens/.src/ — If src/ exists → framework is unknown, srcDir is src/.Detected: Expo Router (app/)
— or —
Detected: React Navigation (src/screens/)
If the detection seems wrong (e.g., src/ fallback), ask the user to confirm or provide the correct path.
Read babel.config.js. If react-native-preflight/babel is NOT already present, add ['react-native-preflight/babel', { strip: process.env.NODE_ENV === 'production' }] to the plugins array. Do not replace existing plugins.
Read app.json. Add "preflight" to expo.scheme only if not already present. Handle string (convert to array), array (push if missing), or absent (set it).
Find the root layout based on the detected framework:
{srcDir}/_layout.tsxApp.tsx, src/navigation/index.tsx, or similar)If StateInjector is NOT already imported, wrap the outermost navigator:
<StateInjector><Stack /></StateInjector><StateInjector onNavigate={(route) => navigation.navigate(route)}>{children}</StateInjector>Add .maestro-output/ if not already present.
The appId must match the development build bundle identifier (the one running on the simulator/device). Maestro uses this to target the app.
Detection order:
app.config.js or app.config.ts first — it may use environment variables or EAS profiles. Look for ios.bundleIdentifier and android.package. If values differ per environment, use the development variant (e.g., com.company.app.dev, not com.company.app).app.config.js, check app.json — look at expo.ios.bundleIdentifier and expo.android.package.eas.json for build profiles — the development profile may override the bundle identifier with a .dev suffix.Ask the user to confirm: "I detected appId com.company.app.dev from your development config. Is this the bundle ID of the build you test on locally?" If they say no, ask them for the correct one.
Add the confirmed appId to package.json under "preflight": { "appId": "..." } (only if not already set).
Multi-platform: If iOS and Android have different bundle identifiers (common with EAS build profiles), use an object:
{
"preflight": {
"appId": {
"ios": "com.company.app.dev",
"android": "com.company.app.staging"
}
}
}
This generates appId: ${APP_ID} in the YAML — Maestro resolves the value at runtime via -e APP_ID=.... Tests must be run with --platform ios or --platform android. Ask the user if their iOS and Android IDs differ.
If the detected srcDir is not the default app/, persist it in package.json under "preflight": { "srcDir": "..." } so that generate and test commands pick it up automatically.
After completing Phase 1, tell the user what was configured (and what was skipped because it was already done) and move to Phase 2.
.tsx/.ts file in {srcDir}/ recursively. Skip _layout.tsx, files starting with _, and anything in __dev/ or (group)/ layout files.<Stack.Screen component={...} /> or <Tab.Screen component={...} /> patterns, then follow the imports to find the actual screen files.useXxxStore, useContext, custom hooks that fetch data)useQuery, useSWR, useFetch, or custom hooks that wrap them like useGetXxx, etc.)testID props already existFound 4 screens:
1. /home (app/index.tsx)
State: useAppStore
Data: useGetDashboard → ['dashboard']
UI: header, main content, action buttons
2. /settings (app/settings.tsx)
State: useSettingsStore
UI: toggle switches, save button
3. /notifications (app/notifications.tsx)
Data: useGetNotifications → ['notifications']
UI: notification list, empty state
4. /details (app/details.tsx)
Data: useGetItem → ['item', itemId]
UI: item details, action buttons
...
Which screens should I wrap with scenario()? (all / numbers / none)
Wait for the user's response before continuing.
For each selected screen, read the full file and modify it:
import { scenario } from 'react-native-preflight'; at the topscenario(config, Component):
id: kebab-case from the route (e.g., /settings → settings)route: the actual route pathdescription: one line describing what the screen showsinject(): pre-populate ALL data the screen needs before it mounts. This includes:
.setState() with realistic values based on the store's type definitionqueryClient.setQueryData(queryKey, data) to pre-fill the cache. Follow the custom hook to find the query key and return type. The queryClient must be imported from the app's query setup (e.g., import { queryClient } from '@/lib/queryClient').mutate(key, data, false) to pre-fill the cacheclient.writeQuery({ query, data }) to pre-fill the cachetest(): write 2-5 basic assertions based on the UI elements visible in the component. Available helpers:
see('text') — assert visible textsee({ id: 'testID' }) — assert testID visible (use this for testID-based assertions)tap('buttonId') — tap element by testIDlongPress('itemId') — long press element by testIDtype('inputId', 'value') — type text into inputnotSee('text') — assert text not visiblewait(2000) — wait N millisecondsscroll('elementId', 'down') — scroll until element is visible (generates scrollUntilVisible)swipe('left') — swipe in a direction (default 400ms duration)swipe('up', 200) — swipe with custom durationback() — press back buttonhideKeyboard() — dismiss the keyboardnavigate('/settings') — open an in-app route via the configured schemeopenLink('myapp://settings') — open an explicit deep link or app link unchangedraw('- setLocation:\n latitude: 45.5') — inject raw Maestro YAML for any unsupported commandtest: myImportedTest, actions: myImportedActions). The generator follows single-level imports to resolve steps. Use this to keep screen files lean when test logic grows large.variants: optional. Use when a screen needs to be tested in multiple states (e.g., logged in vs logged out). Each variant gets its own YAML in a subdirectory.testID props to interactive elements (buttons, inputs) and key display elements (titles, counts) if they don't already have themscenario()Variants example — when a screen has distinct states to test:
export default scenario({
id: 'profile',
route: '/profile',
variants: {
'logged-in': {
inject: () => { /* populate stores with mock data */ },
test: ({ see }) => [see('Welcome back')],
},
'empty-state': {
inject: () => { /* clear all stores */ },
test: ({ see }) => [see('Get started')],
},
},
}, ProfileScreen);
This generates screens/profile/logged-in.yaml and screens/profile/logged-out.yaml. Each variant inherits the base route, description, and inject unless overridden.
Note on HOC compatibility: scenario() accepts React.ComponentType<any>, so it works with HOC-wrapped components (e.g., withSecurityGate(MyScreen), withSuspenseBoundary(MyScreen)). You can safely pass the HOC result directly.
The result must compile and run. Do not leave placeholder comments like // add your state here. Use real store methods and real values.
Scaffold depends on the detected framework:
Expo Router: Create {srcDir}/__dev/preflight.tsx:
import { Preflight } from 'react-native-preflight';
export default function PreflightScreen() {
return <Preflight />;
}
React Navigation: Create {srcDir}/PreflightScreen.tsx:
import { Preflight } from 'react-native-preflight';
export default function PreflightScreen() {
return <Preflight />;
}
Then tell the user to register it in their navigator:
Add to your navigator:
<Stack.Screen name="Preflight" component={PreflightScreen} />
Run npx preflight generate to create .maestro/screens/*.yaml from the scenario() calls. Note: npx preflight test auto-regenerates YAML before running, so manual generation is only needed if you want to inspect the YAML files.
npx preflight test — interactive multi-select pickernpx preflight test <id> — run a specific scenario (regenerates only that YAML)npx preflight test --all — run all scenariosIf the user wants to test complete user journeys (onboarding, checkout, multi-step forms), add a flow property to the starting scenario. The flow YAML is auto-generated by npx preflight generate.
Add flow: [...] to the starting scenario's config:
export default scenario({
id: 'onboarding',
route: '/onboarding',
inject: () => { /* set up initial state */ },
test: ({ type, tap }) => [
type('name-input', 'Jane'),
tap('next-btn'),
],
flow: [
{ screen: 'setup', actions: ({ tap }) => [tap('skip-btn')] },
{ screen: 'settings', actions: ({ navigate }) => [navigate('/settings')] },
{ screen: 'home' },
],
}, SignupScreen);
This generates two files:
screens/onboarding.yaml — isolated screen test (deep link + test steps + screenshot)flows/onboarding.yaml — full flow (deep link + test steps + navigate through subsequent screens + screenshot)Both appear in the interactive picker (npx preflight test). Flows are tagged with [flow].
test() runs first, then flow continues to subsequent screens via real navigation (tapping buttons).screen must match the id of another scenario. The assertVisible uses that ID as the testID.actions uses the same helpers as test(): tap(), type(), see(), scroll(), swipe(), back(), hideKeyboard(), longPress(), navigate(), openLink(), raw().navigate(route) for in-app routes. It generates Maestro openLink with the configured scheme, so navigate('/settings') becomes preflight://settings by default. Use openLink(url) when you need an exact URL, callback, or external app link.skipIf makes a step conditional: { screen: 'onboarding', skipIf: 'home', actions: ... } — skip this step if home testID is already visible (user already past this screen)..setState() for state management, queryClient.setQueryData() for React Query, mutate() for SWR. inject runs before the component mounts — the screen should never show a loading state in preflight.useGetBadges(), read the hook implementation to find the query key (e.g., ['user-badges']) and return type, then generate queryClient.setQueryData(['user-badges'], mockData) in inject().see({ id: 'testID' }) for testID assertions, see('text') for visible text./^[a-zA-Z0-9_-]+$/.app/, save it to the preflight config so CLI commands work without re-detection.npx preflight generate always overwrites existing YAML files from scenario() definitions. The scenario() is the source of truth.waitForAnimationToEnd before takeScreenshot — Maestro waits for the screen to settle, no manual delay needed.screens/{baseId}/{variantKey}.yaml and snapshots/{baseId}/{variantKey}/current.png. Screens without variants stay flat.strip must be explicitly set to true — without it, the plugin does nothing. Always use { strip: process.env.NODE_ENV === 'production' }.true after a preflight deep link is handled. Use it to bypass security gates, onboarding flows, and permission modals during E2E tests. When the app has HOCs or guards that block navigation (PIN, biometry, consent), check isPreflightActive() to skip them instead of hacking the inject().env: { KEY: 'value' } in scenario config for parameterized tests (test emails, passwords). Generates a Maestro env: block in the YAML.npx preflight test --retry 2 re-runs all tests up to 2 times on failure.