How to use
Set up Imitor in a React Native / Expo app: install, wrap Metro, describe flows, choose stand-ins, review and publish a run.
Generated from the README at every deploy, so this page and the repository say the same thing.
What you get
preview/
index.html one page with every shot, grouped by flow
manifest.json what was rendered, from which commit, and what was not covered
screens/*.png <flow>-<shot>-<device>-<scheme>.png
captures/ screenshots and videos taken on request by agents or scripts
app/ the web build itself (static; host it anywhere for a live preview)Set up
In the app's folder, install Imitor and Playwright with its Chromium.
Not on npm yet: until the first release is published, install from GitHub instead: npm i -D github:joseamijares/imitor playwright.
npm i -D imitor playwright
npx playwright install chromiumThen, in the same folder:
npx imitor init --dry-run # show every change it would make, as diffs
npx imitor init # make themimitor init works on Expo apps (with or without their own ios/ and android/ folders). It stops and explains on a bare React Native app without Expo. It never installs anything: it ends with the exact install command for what is missing, for the package manager your lockfile shows (npm, yarn, pnpm or bun), and the next commands to run. It is safe to run twice; the second run changes nothing. An agent can do the same through the imitor_init MCP tool.
What init does
These are the steps imitor init takes. Do them by hand if you prefer, or when it asks you to.
Wrap your Metro config: keep building
configas you do now (getDefaultConfig, Sentry'sgetSentryExpoConfig, your transformers) and wrap what you export. It does nothing unless Imitor is running the build.// metro.config.js module.exports = require("imitor/metro").withImitor(config);init makes this one-line edit when
metro.config.js(or.cjs) exports a variable or a call, creates the standard Expo config already wrapped when there is none, and otherwise leaves the file alone and prints these instructions (an ES modulemetro.config.mjsor.ts, or a shape it does not recognise).- Keep the output folder (
preview/by default) out of git: init adds it to.gitignore, and to.easignorewhen there is one. If the app's runtime version comes from Expo's fingerprint ("runtimeVersion": { "policy": "fingerprint" }, or afingerprint.config.js), editing.gitignorewould change the runtime version and force new store builds, so init leaves it alone and sets"out"to a folder it already ignores, such asdist/imitor. It does the same, and says why, when it cannot evaluate a dynamicapp.config.jsorapp.config.ts(for example before the app's packages are installed), since that config may set the policy. It also tells you when an ESLint flat config would lint the output folder; it does not edit lint config. Add
imitor.config.jsonnext topackage.json. init writes stand-ins for the app's dependencies (see Stand-ins), one device, the colour schemes the app has (userInterfaceStyle) and a first flow that opens/and takes one shot. It lists packages it does not know that have native code and no web build as "may need a stand-in", without adding them; most Expo packages need none. If the file exists, init shows what would change and writes nothing unless you pass--force. A fuller config:{ "shims": { "expo-sqlite": "sqljs", "@shopify/react-native-skia": "skia", "some-native-only-package": "inert" }, "devices": ["iphone-16", "pixel-9"], "schemes": ["light", "dark"], "flows": [ { "name": "first-run", "start": "/", "steps": [ { "shot": "welcome" }, { "tap": "Continue" }, { "fill": { "placeholder": "Your name", "value": "Ada" } }, { "tap": { "label": "Next" } }, { "shot": "home" } ] } ] }Run it in the app folder:
npx imitorimitor buildrunsexpo export --platform webwith your environment, soEXPO_PUBLIC_*values are inlined into the build as usual. If the app needs them to start (a backend URL, a public key), pass harmless placeholders for that command rather than real ones, for exampleEXPO_PUBLIC_API_URL=http://127.0.0.1:9 npx imitor build; the app then sees an unreachable backend.
Other commands: imitor init, imitor build, imitor shoot (reuse the last build), imitor serve (click around by hand), imitor app (the review interface), imitor export (the interface as a static site), imitor push (uploads a run to an Imitor Cloud instance; in development).
Flow steps
| Step | Meaning |
|---|---|
{ "shot": "name" } | Screenshot. Add "fullPage": true for the whole scroll height of the page. Most React Native Web screens scroll inside a ScrollView, not the page, and then get the viewport only: use scroll steps and more shots. |
{ "tap": "Visible text" } | Tap by exact text. Also { "tap": { "testId" | "label" | "text" | "css": ... } }. |
{ "fill": { "placeholder" | "label" | "testId": ..., "value": "..." } } | Type into a field. The value can also come from an environment variable, "value": { "env": "TEST_PASSWORD" } (Sign in with a test account). |
{ "goto": "/route" } | Navigate (full reload). |
{ "waitFor": "Visible text" }, { "wait": 500 }, { "scroll": 400 } | Wait or scroll (the wheel, in the middle of the screen). |
Each flow runs in a fresh browser, once per device and colour scheme (an app that only has a light appearance can set "schemes": ["light"]). A failed step is recorded in the report with a screenshot of where it stopped, and the command exits non-zero.
Browser dialogs (window.alert, confirm, prompt, which many apps use for errors on web) never appear in a screenshot. Imitor dismisses them and records their text as a dialog problem with the step that raised it; imitor flow, screenshot and record list them under dialogs.
What a flow types never leaves the run: in manifests, the runs store, results, the gate's text, the pull-request comment and an exported site, every fill value is shown as ••• (or, for a value from the environment, as its { "env": "NAME" }). Only imitor app on your machine has the literal values, because its live preview replays them.
Other config keys: after, state, seed and clock (Start a flow from a state), secrets (Sign in with a test account), locale (default en-US), timezone (UTC), settleMs (600), reducedMotion (true), out (preview). Devices: iphone-16, iphone-se, pixel-9, small-android, or { "name", "width", "height", "scale" }.
Stand-ins
| Value | Replaces | Behaviour |
|---|---|---|
sqljs | expo-sqlite | Real SQLite (sql.js, wasm) in memory, including the synchronous API. Mirrored to sessionStorage so a reload keeps data. A flow's seed.sql runs in it. |
skia | @shopify/react-native-skia | <Canvas> keeps its box but draws nothing. Shots containing one are labelled in the report. |
canvaskit | @shopify/react-native-skia | Real Skia drawing: the package's own web build, drawing with CanvasKit (Skia compiled to WebAssembly). Not a stand-in, so shots are not labelled. See Real Skia drawing. |
inert | any module | Every export exists and does nothing. Components render nothing. |
./path/to/file.js | any module | Your own stand-in. Inside it, require of the module it replaces gets the real one, so a stand-in can re-export most of a package and replace one function (for example a backend client's createClient, to show signed-in screens with invented data). |
A key matches the module name as the app imports it ("some-sdk" also covers "some-sdk/sub/path"), before path aliases are resolved.
Stand-ins only apply to the web build Imitor makes (IMITOR=1). Your iOS and Android builds are untouched.
imitor init proposes stand-ins from [src/standins.json](https://github.com/joseamijares/imitor/blob/main/src/standins.json): packages checked by hand against a real install (does it ship a web build, does it load its native module at import), each with the version checked and why. So far it covers the packages of two Expo SDK 54 apps; only expo-sqlite and @shopify/react-native-skia needed a built-in kind, and everything else there works on the web as it is (its own web build, or a fallback that does nothing). For @shopify/react-native-skia it proposes canvaskit (real drawing) when canvaskit-wasm can be found from the app, the same way imitor build looks for it, and skia (an empty box) otherwise, and says which and why. A package with native code that is not in the table and has no web build is listed as "may need a stand-in": try the build, and add "inert" or your own file if it fails.
Real Skia drawing
With "@shopify/react-native-skia": "canvaskit", Imitor bundles the real React Native Skia and draws every <Canvas> with CanvasKit, as React Native Skia does on any website. Nothing in the app changes: imitor build copies CanvasKit from the app's own canvaskit-wasm (the copy @shopify/react-native-skia depends on, so the versions match) into the build, and loads it before the app's scripts. If the app already calls LoadSkiaWeb itself, that call finds CanvasKit loaded and does nothing. If canvaskit-wasm cannot be found, the build stops and says which version to install. If CanvasKit (or sql.js) then fails to load in the page, the app never starts: the flow fails with "the app did not start" instead of taking a shot of a blank page, and the gate blocks.
What can still differ from a phone:
- The first load is heavier: CanvasKit's
.wasmis about 8 MB (8,001,100 bytes for canvaskit-wasm 0.40.0, 3.2 MB gzipped), loaded before the app starts. Useskiawhen you only need the layout. - Fonts: on the web, Skia has no system fonts (
Skia.FontMgr.System()is an empty font provider), so text drawn inside a<Canvas>with a font found by system name (matchFont) finds no typeface and draws nothing. Fonts the app loads from files withuseFontoruseFontsare drawn. Text drawn by React Native around the canvas is not affected. - Pixel ratio: the canvas is drawn at the device's
scalein Imitor (2 for every built-in device), not at the phone's own (3 on recent iPhones), so fine lines and edges are a little softer than on the phone. - Animation: shots catch whatever frame is on screen. With
reducedMotionon (the default) an app that stops its animations for reduced motion gives the same shot every run; with it off, a looping animation (waves, bubbles) is in a different place each run. Aclock(Start a flow from a state) fixes the starting instant but does not freeze time, so it does not stop such an animation. - GPU: headless Chromium draws WebGL in software (SwiftShader). Antialiasing and gradients may differ slightly from the phone's GPU; Chromium also logs performance warnings ("GPU stall due to ReadPixels"), which are not counted as page errors.
The app
npx imitor app starts the local review interface, where a person looks over what agents and CI produced: runs and their shots, captures, problems, and which agents are connected.
npx imitor app # http://127.0.0.1:4173 (or --port n)It reads plain files from the output folder: runs/ (the last 50 full runs), captures/index.jsonl, activity.jsonl (one line per MCP tool call) and state.json, which the CLI and the MCP server write as they work. It binds to 127.0.0.1 only, so it is reachable from this machine and nowhere else.
The live preview (/live, or Live on a run) shows the built app itself, running in a device frame. imitor app serves the build on a second port of its own, so the app's router and reloads work as at a site root, and adds a small driver script to its pages so the interface can replay a flow inside the frame up to the shot you pick: taps, fills and waits, as the runs do in headless Chromium, after the steps of any flow it runs after. Each replay clears what the app stored, then opens the app with ?imitor-flow=<name>, so the page starts with that flow's state and seed (the Flow state panel lists them). Reload starts again from the first flow's state.
The Appearance and Reduce motion controls rely on an override that imitor build adds to every page of the build: with ?imitor-scheme=dark|light or ?imitor-motion=reduce|no-preference in the address, the app's matchMedia answers prefers-color-scheme and prefers-reduced-motion accordingly (it is kept for the browser tab's session; auto clears it). Builds made before this have no override: the live preview says so and the app follows the computer's settings until you run imitor build again. An app that ignores the system scheme (for example userInterfaceStyle: "light" and no useColorScheme) looks the same in both.
The iPhone status bar and the Android camera are drawn above the app, not over it: the web build has no safe-area insets, so drawing them on top would hide what the shots show.
A public demo of the interface, with invented data for an invented plant-care app ("fernwood"), runs at imitor.dev/demo. It is built from demo/ with npm run demo (it writes demo/site/). The root vercel.json deploys the public site in site/ (npm run site): its How to use and For agents pages are generated from this README, and the demo is exported into its /demo/ folder.
Publish a run
npx imitor export # writes preview/site/
npx imitor export --out review-site # or anywhere else
npx imitor export --preview-url https://my-preview.example.com
npx imitor export --base /review # to serve it under /review/ instead of a domain rootimitor export writes the interface and its data as plain static files, for any static host (Vercel, Netlify, GitHub Pages, an S3 bucket): the interface, every API response as a .json file, and the shots, failure screenshots and captures those responses point to (nothing else from the output folder). It is read-only: marking a problem seen is kept in each visitor's browser, and nothing can start a run. The live preview needs a running build, so it is left out and the Live page says so, unless --preview-url names a deployed web build of the app. Serve the folder at the root of a domain, or under the path given with --base; it includes a 404.html, a vercel.json and a _redirects file so deep links like /runs/<id> load the interface.
The site holds the app's screenshots and videos. Publish it only where those may be seen, for example a team-only or password-protected deployment, not a public URL for an unreleased app.
What it does not show
- Native navigation chrome, transitions and gestures
- Keyboard behaviour
- The phone's exact font rendering
- Entering and layout animations (the web lays these out differently)
- Anything behind a stand-in
- Platform-specific code paths: the build runs as
Platform.OS === "web" - Widgets, notifications, Live Activities, watch apps