@ceraph/react-native-mcp
v0.5.2
Published
MCP server for React Native and Expo development workflow
Downloads
4,082
Maintainers
Readme
@ceraph/react-native-mcp
MCP server for React Native and Expo development. Automatic build error capture, console monitoring, reliable screen interactions, and prebuild detection.
Works with any MCP client: Claude Code, Cursor, Codex, Windsurf, and others.
⚠️ Ignore the
npm iline above. Run this once instead:npx @ceraph/react-native-mcp@latest init
Platform support
This MCP is iOS-only. Both a real iOS device (the original supported configuration) and a booted iOS Simulator now work. The runtime path (devicectl / idb / WebDriverAgent / simctl) is Apple-specific.
| | Status | Notes |
|---|---|---|
| iOS device (USB) | Supported | WebDriverAgent installs through Xcode (one-time); WDA is reached at localhost:8100. Default — no env var needed. |
| iOS Simulator | Supported | ceraph_start installs the optional appium-webdriveragent dep and builds + launches the WDA automatically the first time you target a simulator. See Simulator setup below. |
| Android (device or emulator) | Not supported | |
| Linux / Windows host | Not supported | |
| Expo Go | Not supported | Needs dev client or prebuilt app. |
If you don't have a Mac with Xcode, stop here — nothing in this package will work for you yet.
Choosing device vs simulator
By default the MCP auto-detects: if a real device is connected, it uses the device; otherwise it uses a booted simulator. The one exception is when you're already driving a simulator — once rn_wda_start (or ceraph_start's simulator path) has an active WDA session, auto keeps routing to that simulator even if a phone is also plugged in. Override explicitly:
export CERAPH_TARGET=device # always use real device
export CERAPH_TARGET=simulator # always use simulator
export CERAPH_TARGET=headless # always use a simulator with no Simulator.app window
export CERAPH_TARGET=auto # default behavior (also: unset)For a one-off override without touching the environment, pass target: "device" | "simulator" | "headless" to ceraph_start ("headless" runs on a simulator with no Simulator.app window — see Headless mode) — it takes precedence over CERAPH_TARGET for that call only, and an explicit device with no device connected fails fast (no silent simulator fallback). The target is resolved per call, so separate calls can drive the device and the simulator independently.
The rn_target_status tool reports which target is currently active and the resolved WDA base URL.
Requirements
- macOS with Xcode 14+ (Command Line Tools installed)
- iOS 16+ device connected to the Mac over USB and unlocked OR a booted iOS Simulator
- Node.js 18+
- WebDriverAgent running and reachable. On a real device, this is
localhost:8100(port-forwarded from the device by Xcode /iproxy). On a simulator, the port is captured at runtime fromrn_wda_start— see Simulator setup. - Expo dev client or prebuilt app (Expo Go is not supported)
Multi-developer Macs: The runtime signal listener binds to
localhost:8101(WDA uses 8100, Metro uses 8081). Only one developer on a given Mac can run the MCP at a time without a port collision. If you share a Mac and need parallel sessions, override the port viaCERAPH_SIGNAL_PORTwhen starting the MCP server.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
| CERAPH_SIGNAL_HOST | auto-detected | Override the Mac host the on-device signal channel posts to, for unusual networks where auto-detection picks the wrong address. Leave it unset for normal use. |
| CERAPH_SIGNAL_PORT | 8101 | Port the signal listener binds to on the Mac. Change when 8101 is taken. |
| CERAPH_TARGET | auto | Which iOS runtime to drive. device forces the real-device path even if a simulator is booted; simulator forces simctl/sim-WDA even if a device is connected; headless forces a simulator with no Simulator.app window (see Headless mode); auto prefers device when both are available, unless a simulator WDA session is already active (then it stays on that simulator). |
| CERAPH_FAST_RELOAD | true | Fast Reload (see Fast Reload). Set to false or 0 to turn it off. |
Simulator setup
On a simulator, the WDA needs the optional appium-webdriveragent package — ceraph_start (and rn_wda_start) install it automatically the first time you target a simulator, then build and launch the WDA for you. The first build is a one-time ~1 minute; later runs reuse the cache at <project>/.ceraph/wda-derived/. It's an optional dep, so device-only setups skip the ~200 MB download.
Boot any iPhone simulator (iOS ≥ 16) and run ceraph_start:
open -a Simulator
# or: xcrun simctl boot <UDID>xcrun simctl list devices booted shows what's currently up.
Once WDA is up, every screen_* and app_* tool routes to the simulator automatically. Call rn_wda_stop to tear it down (or just exit the MCP — shutdown stops the xcodebuild child cleanly).
Concurrent device + simulator: Once a simulator WDA session is active (rn_wda_start, or ceraph_start targeting a simulator), auto-detection routes to that simulator even while a phone is plugged in — an actively-driven sim outranks a merely-connected device. To drive the phone instead while a sim session is up, set CERAPH_TARGET=device (or pass target: "device"). Before any sim session exists, auto prefers a connected device.
Headless mode
To run on a simulator with no Simulator.app window, pass target: "headless" to ceraph_start (or headless: true to rn_build_ios). It builds with xcodebuild instead of expo run:ios (which always opens the GUI), boots the simulator headlessly (xcrun simctl boot), installs and launches the app, and deep-links dev-client builds to Metro. Everything else — WDA, snapshots, screenshots — works exactly as for a windowed simulator.
To make headless the default for every run — the right choice on a cloud Mac or any machine with no display, where headless is the only viable mode — set it once in the environment:
export CERAPH_TARGET=headlessWith that set, a bare ceraph_start (no target) and a bare rn_build_ios (no headless) both run headless. A per-call target / headless still overrides for that one call — target: "device" or headless: false beats the env preference.
Use it for CI, a headless or cloud Mac with no display, background runs, or just to keep the simulator window out of the way. It's also far lighter: for an actively-rendering app a windowed simulator costs roughly 4× the CPU and ~290 MB more RAM than headless (the macOS window server compositing the live frame dominates), so a headless fleet scales much further per Mac.
Notes: macOS + Xcode only. The app still loads its JS from Metro — ceraph_start manages that for you; standalone rn_build_ios with headless ensures Metro is running too. Audio still plays through your Mac (it routes independent of the window).
Quick Setup
Run this from your project root:
npx @ceraph/react-native-mcp@latest initThis automatically:
- Configures MCP servers for all detected clients (Claude Code, Cursor, Codex, VS Code, Windsurf, Antigravity)
- Installs a Claude Code hook that injects runtime errors into your conversation automatically
- Adds
.rn-errors.jsonto your.gitignore - Signs you in (browser OAuth) so Pro features (test-user provisioning) can reach the Ceraph server
Uninstalling Ceraph
npx @ceraph/react-native-mcp uninstallNon-destructive by default: it reverses what init did and reports a per-step status, leaving your camera test images and OAuth token in place so you can re-install without losing state. Only @ceraph/react-native-mcp is removed from package.json.
Flags
| Flag | Effect |
| --- | --- |
| -y, --yes | Skip confirmation prompts. |
| --purge-data | Also delete .ceraph/ (camera images + videos, design snapshots, run artifacts, hooks). Alias: --purge-images. |
| --global | Also delete ~/.ceraph/auth.json, signing you out of every Ceraph install on the machine. |
| --dry-run | Preview the step list without writing anything. |
| --project-dir <path> | Operate against a specific subpackage. Required when a monorepo has more than one React Native app. |
Not auto-reverted
A few mutations are hand-edits the uninstaller can't safely make for you. Each surfaces as a manual step with the exact lines to remove:
- Bare React Native
Info.plistandAndroidManifest.xmlscheme entries. - Expo dynamic config (
app.config.js/app.config.ts) — the file path and scheme value to remove. useEffectthat containsinstallCeraph()alongside your own statements — the file and line to edit.
Automatic auto-lock (optional)
If your consumer app has expo-keep-awake (Expo SDK installs it by default) or react-native-keep-awake, Ceraph activates it in __DEV__ so your iPhone screen stays awake during Ceraph runs. It subscribes to AppState and releases the keep-awake when the app backgrounds, then re-acquires on foreground. Production builds tree-shake the entire path.
To add the optional dep to an Expo app:
npx expo install expo-keep-awakeFast Reload
Fast Reload reconnects your app to Metro automatically after a disconnect. It runs in __DEV__ only and is ON by default.
When the bundler connection drops — laptop sleep, a Metro restart, a network blip — Fast Reload detects it and reconnects the app on its own the moment Metro is back, including after the phone returns from sleep.
You can confirm it's working from the Metro / device console — right before the auto-reload you'll see:
[ceraph-fast-reload] reconnected after Metro disconnectTurning it off
Two equivalent ways:
# Environment variable
CERAPH_FAST_RELOAD=false # or 0// In your installCeraph call
installCeraph({ fastReload: false });What it does and doesn't do
Fast Reload reconnects the JS bundle. A native or config change (a new native module, an app.json / Info.plist edit, a Babel/Metro config change) still needs a manual rebuild. A reload is also a fresh mount, so in-memory component state is lost across it, as with any reload.
USB bridge (real-device only)
Real iOS devices reach WebDriverAgent through iproxy 8100 8100 -u <udid> (libimobiledevice). The MCP's doctor auto-spawns this when:
- WDA at
localhost:8100is unreachable, AND - A real iOS device is connected (detected via
xcrun devicectl)
If iproxy isn't installed, doctor surfaces the remediation: brew install libimobiledevice. The auto-spawned child is killed cleanly on MCP server SIGINT / SIGTERM, so it doesn't hang around between sessions. Already-running iproxy (from Xcode's automatic forwarding, or a prior session) is detected and reused, not re-launched.
Manual Setup
If you prefer to configure manually, follow the instructions for your client below.
Claude Code
Add to .mcp.json in your project root:
{
"mcpServers": {
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}
}Cursor
Add to .cursor/mcp.json in your project root:
{
"mcpServers": {
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}
}Codex
Add to .codex/config.toml in your project root:
[mcp_servers.react-native-mcp]
command = "npx"
args = ["-y", "@ceraph/react-native-mcp@latest"]VS Code / Copilot
Add to .vscode/mcp.json in your project root:
{
"mcpServers": {
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}
}Windsurf
Add to ~/.codeium/windsurf/mcp_config.json (or Settings → Advanced Settings → Cascade):
{
"mcpServers": {
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}
}Antigravity
Add to ~/.gemini/antigravity/mcp_config.json (or Manage MCP Servers → View raw config):
{
"mcpServers": {
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}
}Cline / Roo Code (VS Code extensions)
Open VS Code Settings → Extensions → Cline (or Roo Code) → MCP Servers, then add:
{
"react-native-mcp": {
"command": "npx",
"args": ["-y", "@ceraph/react-native-mcp@latest"]
}
}JetBrains IDEs
Settings → Tools → MCP Servers → Add, then enter:
- Name:
react-native-mcp - Command:
npx - Args:
-y @ceraph/react-native-mcp@latest
Tools
Build & Runtime
| Tool | Description |
|---|---|
| ceraph_start | Call this first. Brings a connected iPhone to ready-to-test in one call: Metro, then build/install + foreground the app (keep-awake shim) and start WebDriverAgent concurrently, then verify on-device automation. Runs only what's missing, stops at the first failed gate with its remediation. Drives a connected device by default; pass target: "simulator" or target: "headless" to target a windowed or headless simulator instead. |
| ceraph_doctor | Diagnose readiness before driving the app: WDA reachable, device connected + awake, app installed, required env vars present, plus camera-shim setup (unwrapped <CameraView>, missing or orphaned imageKey). Returns structured failing checks with their remediation. ceraph_start runs it automatically as its final verify stage. |
| rn_build_ios | Build the app with expo run:ios. Captures Xcode output and returns structured errors (file, line, message, type). Optionally runs prebuild --clean first. Pass headless: true to build with xcodebuild and boot/install/launch on a simulator with no Simulator.app window (Headless mode). |
| rn_metro_start | Start Metro dev server. Monitors console output for runtime errors, JS exceptions, and red screens. |
| rn_get_errors | Return all captured build and runtime errors without re-running anything. |
| rn_get_console | Return recent Metro console output, filtered by log level. |
| rn_check_prebuild | Detect if prebuild --clean is needed by diffing package.json, app.json, and Podfile.lock against the last successful build. |
| rn_stop | Kill all managed React Native processes. |
Target & Simulator
| Tool | Description |
|---|---|
| rn_target_status | Report which iOS target (device vs simulator) is currently selected, the resolved WDA base URL, and whether a simulator WDA session is running. |
| rn_wda_start | Build + launch WebDriverAgent against a booted iOS simulator (requires the optional appium-webdriveragent dep). Captures the WDA port and routes every subsequent screen_* / app_* tool to the simulator automatically. Idempotent — re-calling returns the existing session. Real-device users don't need this tool. |
| rn_wda_stop | Stop the simulator WDA session started by rn_wda_start. Idempotent. Real-device WDA is unaffected. |
Screen Interaction
| Tool | Description |
|---|---|
| ceraph_snapshot | Structured accessibility-tree snapshot of the current screen — a tree of elements with roles, names, values, on-screen bounds, live state, and a stable ref per element. Deterministic, no LLM. |
| screen_tap | Tap at coordinates with automatic pixel ratio correction. Screenshot coordinates are divided by the device pixel ratio (2x/3x) so taps land where you expect. |
| screen_tap_and_verify | Find an element by text/label/type, tap its center, and optionally assert a follow-up selector is visible — resolved against one accessibility snapshot in a single W3C action. The reliable way to tap: no coordinate guessing. |
| screen_tap_chain | Tap a sequence of elements as one W3C action, all resolved against a single snapshot (zero gestures sent if any query fails). |
| screen_type_into_field | Focus an input (by text/label/type) and type into it in one W3C action. Set clearFirst: true to clear the field before typing. |
| screen_swipe | Swipe up/down/left/right. Defaults to a 60%-of-axis swipe from screen center. |
| screen_scroll_to | Repeatedly swipe until a matching element appears in the accessibility tree (no tap). |
| screen_long_press | Long-press a matched element for a configurable duration. |
| screen_press_key | Press a hardware/system button: home, volumeUp, volumeDown, lock. |
| screen_open_url | Open a URL / deep link (e.g. myapp://product/42) to jump straight to a screen — useful for testing your app's deep-link routes. |
| screen_screenshot | Capture a pixel screenshot of the current screen (a viewable image block) — for confirming actual rendered appearance. For structured screen data (elements, roles, bounds, refs to act on) use ceraph_snapshot. |
| screen_wait_for | Poll the source tree until an element appears (or disappears). Pass timeoutMs: 0 for a one-shot visible / not-visible check. |
App Lifecycle & Device
| Tool | Description |
|---|---|
| app_launch | Launch an app by bundle ID. Uses xcrun devicectl (Xcode 15+) on a real device, falls back to idb, then simctl. Terminates any existing instance first. |
| app_terminate | Terminate an app by bundle ID. |
| app_activate | Bring an app to foreground via WDA without a cold restart. |
| app_list_installed | List installed apps on the connected device (cached 30s). |
| app_info | Report the foreground app's bundleId, pid, and name. |
| rn_reset_state | Reset the running app's persisted state — AsyncStorage.clear plus any registered cleanup hooks — without a rebuild. Requires installCeraph active in the app; dev-only (no-ops in production). |
| rn_reload | Reload the app's JS bundle, wait for it to come back, then capture a screenshot. Requires installCeraph active in the app; dev-only. |
| device_ensure_awake | Ensure the device is awake and unlocked. Returns structured remediation when locked. |
| device_set_orientation | Set portrait or landscape. |
Driving the app
Ceraph gives you the two things that make driving an iOS app by hand reliable:
ceraph_snapshot— a structured, deterministic view of what's on screen (elements, roles, names, values, on-screen bounds, and a stablerefper element). Call it to see what's tappable / typeable before you act, and again after each action to observe the new state.- The
screen_*/app_*primitives —screen_tap,screen_tap_and_verify,screen_type_into_field,screen_swipe,screen_scroll_to,screen_open_url,screen_screenshot, and the rest. You plan the steps; Ceraph drives WebDriverAgent and reports the outcome.
The loop is: ceraph_start (reach a ready state) → ceraph_snapshot (observe) → primitives (act) → snapshot again. Inspect runtime errors with rn_get_errors and console output with rn_get_console between steps — the in-app shim posts JS exceptions, unhandled rejections, network failures, and console.error to Ceraph automatically once installCeraph() is active.
Test users
For anything behind sign-in or a protected screen, provision a managed test user so the app can be driven past auth. Provisioning requires sign-in (npx @ceraph/react-native-mcp@latest init — the token is shared with your Ceraph account).
| Tool | Description |
|---|---|
| ceraph_test_data_provision | Mint a managed test user on demand against your auth provider (Clerk / Auth0 / Supabase / Firebase / Cognito). Returns email + password (+ a session token when the provider exposes one); 24-hour TTL; optional tag to label it. |
| ceraph_test_data_list | List active on-demand test users for the project. |
| ceraph_test_data_cleanup | Delete on-demand test users by id, or all: true to wipe them. |
Camera & media testing
iOS blocks external processes from injecting camera frames, so Ceraph ships a __DEV__-gated shim that serves pre-configured media instead: a still image for takePictureAsync and a video clip for recordAsync. It's a subpath import of this package, so testing camera-heavy RN flows takes two lines of code. Both kinds use the same workflow — drop files keyed by name, generate a static registry, and the same <CeraphCamera imageKey> value selects the image for photo capture and the clip for recording.
Installation
Nothing extra. The shim ships in the same package — it is exposed via the @ceraph/react-native-mcp/shim subpath. react, react-native, and expo-camera are optional peer dependencies; bring whichever versions your app already uses.
Setup
1. Drop test images into .ceraph/camera-images/ in your repo root, one file per scenario, with descriptive lowercase filenames. The filename without the extension IS the imageKey you reference in code:
.ceraph/camera-images/profile.jpg → imageKey="profile"
.ceraph/camera-images/id-card.png → imageKey="id-card"
.ceraph/camera-images/product.jpg → imageKey="product"Supported extensions: .jpg, .jpeg, .png, .webp, .heic. On collision the order above is the priority. These images are committed to your repo by default so test runs are reproducible across machines and CI.
Drop test clips into .ceraph/camera-videos/ the same way — one file per scenario, the stem is the imageKey. Supported extensions: .mp4, .mov, .m4v (priority in that order). The same imageKey that selects a still for takePictureAsync selects a clip for recordAsync, so a screen can have both, or just one:
.ceraph/camera-videos/treadmill.mp4 → imageKey="treadmill"
.ceraph/camera-videos/id-scan.mov → imageKey="id-scan"2. Generate the static registry. Call the MCP tool rn_sync_camera_registry — it scans .ceraph/camera-images/ and .ceraph/camera-videos/ and writes a _registry.ts next to each set, with one require() per file (Metro requires statically analysable paths, hence the codegen step). ceraph_doctor also runs this automatically, so you usually never have to call it by hand.
3. Apply the registry at app boot, gated behind __DEV__:
// App.tsx
import { applyCeraphMediaRegistries } from "./.ceraph/_media-registries";
if (__DEV__) {
applyCeraphMediaRegistries();
}ceraph init injects this single applyCeraphMediaRegistries seam into your installCeraph({ ... }) call automatically, so you usually don't wire it by hand. The generated .ceraph/_media-registries.ts applies every camera-media registry that exists (images, video, and future kinds) — so adding a new kind is picked up on the next registry sync + a Metro reload, with no change to your layout and no re-init. The per-kind registries still call configureTestImage / configureTestVideo for you; the 1-arg configureTestImage(require(...)) form still works for back-compat, but the codegen above is the recommended workflow.
4. Replace <CameraView> with <CeraphCamera> and set imageKey per screen:
import { CeraphCamera } from "@ceraph/react-native-mcp/shim";
export function ScanProfileScreen() {
return (
<CeraphCamera
imageKey="profile"
style={{ flex: 1 }}
facing="front"
ref={cameraRef}
/>
);
}The imageKey prop selects which image the shim returns when the shutter is triggered. The AI tool consuming the MCP picks the right key per screen at code-write time, based on what that camera screen captures conceptually (face → "profile", ID document → "id-card", etc.). <CeraphCamera> takes the same prop shape as expo-camera's CameraView and forwards every other prop verbatim. In production builds, __DEV__ is false, the registries are dead-stripped, the imageKey prop is a no-op, and <CeraphCamera> lazily requires expo-camera and renders the real <CameraView> — zero overhead.
For a screen that records video, the same <CeraphCamera> handles it — recordAsync returns the clip registered under that imageKey (or falls through to the real recorder in production). A screen that both photographs and records just needs a clip AND an image registered under the same key.
Adding test images via MCP
Typical loop: the dev pastes an image into chat and says "use this for the handwriting screen." The AI assistant calls ceraph_add_camera_image to write the bytes into .ceraph/camera-images/ and regenerate _registry.ts in one step — no manual cp, no follow-up call to rn_sync_camera_registry.
// Tool call
{
"name": "ceraph_add_camera_image",
"arguments": {
"imageKey": "handwriting-sample",
"imageBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
"contentType": "image/png" // optional hint
}
}
// Result shape
{
"written": ".ceraph/camera-images/handwriting-sample.png",
"registry": ".ceraph/camera-images/_registry.ts",
"registered": ["handwriting-sample", "id-card", "profile"],
"detectedContentType": "image/png",
"detectedExt": "png",
"replaced": false,
"bytes": 48213,
"registryState": "written",
"warnings": []
}Errors return { error, message, remediation } with isError: true — e.g. an existing imageKey without overwrite: true, a payload over the 5 MB cap, or a stem that doesn't match the [a-z0-9]+(?:-[a-z0-9]+)* convention (same rule documented in Setup — the stem IS the imageKey).
Notes:
- The declared
contentTypeis a hint only. The extension is chosen from magic-byte detection of JPEG/PNG/WebP/HEIC; on mismatch the tool writes using the detected type and surfaces acontent-type-mismatchwarning. imageKeyis validated against a strict lowercase-hyphen regex (no.., no slashes, no NUL), so it can't escape.ceraph/camera-images/— path traversal is blocked by construction.overwrite: trueis required to replace an existing imageKey; if the new extension differs (e.g.profile.jpg→profile.png), the stale sibling is removed and reported asremovedSiblingPath.data:image/...;base64,...URI prefixes are accepted and stripped automatically.
Adding test clips via MCP
The video counterpart of ceraph_add_camera_image: the AI assistant calls ceraph_add_camera_video to write a clip into .ceraph/camera-videos/ and regenerate its _registry.ts in one step.
// Tool call
{
"name": "ceraph_add_camera_video",
"arguments": {
"videoKey": "treadmill",
"videoBase64": "AAAAIGZ0eXBpc29t...",
"contentType": "video/mp4" // optional hint
}
}
// Result shape — identical to ceraph_add_camera_image
{
"written": ".ceraph/camera-videos/treadmill.mp4",
"registry": ".ceraph/camera-videos/_registry.ts",
"registered": ["treadmill"],
"detectedContentType": "video/mp4",
"detectedExt": "mp4",
"replaced": false,
"bytes": 1048576,
"registryState": "written",
"warnings": []
}Everything works exactly like the image tool: contentType is a hint only (the extension is chosen from magic-byte/container detection of MP4/MOV/M4V, warning on mismatch); videoKey is regex-validated and path-traversal is blocked by construction; overwrite: true is required to replace an existing key and a stale sibling at a different extension is removed + reported as removedSiblingPath; data:video/...;base64,... prefixes are stripped. The cap is 25 MB (vs 5 MB for images), since clips are legitimately larger.
Resolution order at runtime
When <CeraphCamera> renders in dev/test mode, the active test image is chosen in this order:
- If the component received an
imageKeyprop, that key is selected. - Otherwise, the key previously selected via
selectTestImageKeyis used. - Otherwise, the
"default"key is used (only when something registered it). - Otherwise, the shim falls through to the real
<CameraView>.
Video (recordAsync) resolves the same way — imageKey → previously-selected key → "default" if registered → fall through to the real recorder — with one deliberate asymmetry: there is no bundled default clip. Images ship with a bundled 1024×1024 black PNG as the "default" fallback so an uninitialized camera still renders something; an empty placeholder clip would never be useful, so video simply falls through to the real recorder when no clip is registered for the key.
Production safety
__DEV__isfalsein any production build, soconfigureTestImage/configureTestVideoare never called and the shim always falls through to the real camera.- The configured media is referenced inside the
__DEV__branch only — Metro tree-shakes bothrequires out of the production bundle, so test images and clips are never shipped to end users. expo-camerais imported lazily viarequire()inside the production branch. Apps that do not yet useexpo-cameracan still install the shim without a missing-module error.
Hooks
A hook is your own code that Ceraph runs inside your app when it reaches a given route while driving the app — to clear an in-app blocker (an auth wall, a paywall, a feature gate, or a provisioned test user missing app-side data) so you can keep going. Because a hook runs in the app's own JS runtime, it can set your app's auth token, flip an entitlement flag, call your app's own login, and hit your backend — whatever it takes to get past the wall. Ceraph owns when it fires; you own what it does.
1. Write a hook in .ceraph/hooks/, one file per route, named *.hook.ts or *.hook.js. It exports the route it fires on and a run function, and may import your app's own modules:
// .ceraph/hooks/login.hook.ts → fires when Ceraph reaches "/login"
import { signIn } from "../../src/services/auth"; // your app's own code
export const route = "/login"; // the screen's route path
export const once = true; // fire at most once per run (the default)
export async function run(ctx) {
// ctx.user is the provisioned test identity in use.
await signIn(ctx.user.email, ctx.user.password); // mutate live app auth state
return { ok: true };
}2. That's it. Ceraph watches .ceraph/hooks/, bundles each hook into your dev app, and wires your app entry automatically. Add, edit, or remove a hook file and it re-syncs on its own — a newly added hook's wiring loads on the next full bundle (restart Metro / rebuild; a new import can't come in over Fast Refresh).
When Ceraph reaches /login while driving the app, it fires login.hook.ts in the app to clear the blocker before continuing.
The run(ctx) contract
run receives one argument and returns { ok, error? } (synchronous or async — both are accepted):
| ctx field | |
|---|---|
| ctx.route | The route Ceraph reached (the one this hook is registered for). |
| ctx.runId | Stable identifier of the current run. |
| ctx.user | The test identity in use — { email, password, sessionToken? } — or null when no test user is in use. The same identity Ceraph binds to <<CERAPH_TEST_*>> placeholders. |
Return { ok: true } once the blocker is cleared, or { ok: false, error: "…" } to record why it couldn't be. Throwing is fine too — it's caught and recorded as ok: false.
export const route(required) — the screen's route path, a string literal. A hook whose route is never reached simply never fires.export const once(optional, defaulttrue) —truefires at most once per run (a sign-in / provisioning hook); setfalseto fire on every landing (a dismiss-on-sight paywall hook).
License
FSL-1.1-MIT — free for use; competing commercial products restricted. Auto-converts to MIT 2 years after each release.
