Stop Setting Up Test Users Manually for React Native and Expo Apps
Most of the interesting screens in a mobile app sit behind something: a login, a paywall, an admin role, a feature flag, a half-finished onboarding. When an AI coding agent changes one of those screens, testing it means first becoming the right user. A fresh sign-up can't see the premium dashboard. A premium account can't see the trial-expiry banner. An account that finished onboarding can't reach step three of onboarding. E2E testing behind login is really a test user provisioning problem, and the user has to fit the change.
Ceraph gives your agent a way to solve it in your app's own terms. It's an MCP server that lets your AI coding agent drive and test your React Native or Expo app on real devices, simulators, and emulators. Its agent hooks let the agent establish the exact persona a change needs, through code you own, and then hand you the credentials so you can sign in and check the change yourself.
What a hook is
A hook is a project-owned file in .ceraph/hooks/, named *.hook.ts or *.hook.js. It exports a route, an optional description, an optional isAvailable predicate, and a run(ctx) function. The hook runs inside the live development app, so it can import your app's own auth, API, navigation, and state modules: the same configuration your app already uses to talk to its development backend.
Ceraph runs the hook; your code decides what it does. Ceraph doesn't create users, doesn't hold your auth provider's secrets, and doesn't need to know which provider you use. Clerk, Supabase, Firebase, Auth0, Cognito, or a home-grown backend all work the same way, because the hook calls your code.
Hooks are development-only and are excluded from production builds.
Picking the correct user
The agent starts from the change. Say the diff touches a premium-only analytics screen. The agent reads the screen with ceraph_snapshot, and the snapshot's meta.availableHooks lists the hooks available right now, each with its description:
/paywall: "Create and sign in a disposable premium user"/onboarding: "Create a user who has finished step 1 of onboarding"/admin: "Sign in as a disposable admin in the dev org"
The agent matches the change to the persona. A premium screen calls for the /paywall hook, a trial banner for a trial-expiring user, and a moderation tool for an admin. Each description tells the agent what state the hook establishes, so a good description is how you steer that choice.
The agent fires the hook with ceraph_run_hook({ route: "/paywall" }), then continues the representative flow: it opens the analytics screen the way a premium user would reach it and verifies the change from there. It doesn't jump straight to the edited component.
Two rules keep this honest. The agent is told to prefer the real UI whenever the real UI can establish the prerequisite, because walking the path is itself coverage. And when the change is the login or payment flow, the agent exercises that flow instead of skipping it with a hook. Hooks are for prerequisites, not for the thing under review.
An example hook
Here's a hook that creates and signs in a disposable premium user through the app's own development helpers, tagged by its creation call. A shared development helper retains those tags for cleanup:
// src/dev/createdTestUsers.ts
// Use dev-only persistent storage here if cleanup must survive an app reload.
export const createdTestUserTags = new Set<string>();
// .ceraph/hooks/paywall.hook.ts
import { createDevUser, grantDevEntitlement } from "../../src/dev/testUsers";
import { signInWithPassword } from "../../src/auth";
import { createdTestUserTags } from "../../src/dev/createdTestUsers";
export const route = "/paywall";
export const description = "Create and sign in a disposable premium user";
export async function run(ctx) {
const email = `qa+${ctx.runId}@example.com`;
const password = `dev-${ctx.runId}`;
createdTestUserTags.add(ctx.runId);
const user = await createDevUser({ email, password, tag: ctx.runId });
await grantDevEntitlement({ userId: user.id, plan: "premium" });
await signInWithPassword(email, password);
return { ok: true, data: { email, password, userId: user.id } };
}
Each hook call has its own ctx.runId. The setup hook retains its creation tag before starting, so cleanup can find users even if a later setup step fails. The hook reuses the app's existing auth configuration and embeds no secrets. When setup can't finish, run returns { ok: false, error: "…" } with a reason the agent can act on.
A companion cleanup hook removes the users created by these setup calls in the current app session. Keep the app running until cleanup finishes, or persist the tags in your development helpers if cleanup must survive a reload. It doesn't need to be advertised on any screen; the agent can invoke a known utility route directly:
// .ceraph/hooks/cleanup.hook.ts
import { deleteDevUsersTagged } from "../../src/dev/testUsers";
import { createdTestUserTags } from "../../src/dev/createdTestUsers";
export const route = "/test-user/cleanup";
export const description = "Delete disposable users created by a test run";
export const isAvailable = () => false;
export async function run() {
for (const tag of createdTestUserTags) {
await deleteDevUsersTagged(tag);
createdTestUserTags.delete(tag);
}
return { ok: true };
}
isAvailable controls discovery, not authorization. Every registered route can still be invoked directly, so a hook enforces its own checks: point it at a disposable, non-production target and never at real users. Ceraph picks up hooks you add or remove automatically.
Getting the credentials back
A hook can return a data object, any plain JSON object up to 32 KB, and it comes back to the agent from ceraph_run_hook as { ok, route, data }. The agent uses it to continue the test, and it passes the disposable credentials to you in its summary, so you can sign in on your own device and look at the change yourself:
Verified the premium analytics screen on the iOS Simulator.
Test user (disposable, dev backend):
email: qa+run-hook-7f3c…@example.com
password: dev-run-hook-7f3c…
plan: premium
Path: Home → Insights → Analytics. The new weekly chart renders
with seeded data; no runtime errors. Run /test-user/cleanup when
you're done to delete the user.
Ceraph never writes hook data to its run logs, recordings, or analytics, so the credentials reach your agent's conversation and nowhere else in Ceraph. On a remote connection, where results pass through Ceraph's servers, Ceraph withholds data and tells the agent it did.
Provider shortcuts worth knowing
Your hook can lean on the development affordances your auth provider already offers:
- Clerk: in development instances, any address with the
+clerk_testsubaddress is a test email. For email-code verification, Clerk sends no code email and accepts424242. Email-link verification still sends an email. - Supabase: in local development, email confirmation is off by default, so a user created with
signUp()can sign in immediately. - Firebase: the Authentication Emulator lets test setup create accounts through its REST endpoints and clear them between runs.
Where this fits
The same mechanism covers more than users. Hooks can also seed backend state, set roles, subscriptions, and feature flags, simulate webhook or push state, and dismiss development-only blockers. Test user provisioning is the case almost every app hits first.
To have your agent provision the right user for a change, get started.