NativeProof
A Playwright-feeling native mobile E2E layer for Appium/WebdriverIO. NativeProof brings Playwright's
developer experience — runner-native describe / it, direct native.* interactions,
locators, auto-waiting expect, route-style network interception, fixtures, evidence capture — to
native iOS and Android apps, layered on Appium / WebdriverIO (which can drive the
native surfaces Playwright itself cannot).
import { expect, native } from "./nativeproof.config";
describe("login", () => {
it("should be able to log in", async () => {
await native.navigate("/login");
await native.tap("Log in");
await expect(native.getByText("Welcome back")).toBeVisible();
});
});One command scaffolds the project, then one command runs it on a device or emulator:
npx nativeproof init --android
npm run test:e2eContents
- Features
- Requirements
- Install
- Quick start
- Project setup
- Android setup
- iOS setup
- Writing tests
- Running
- CI
- Configuration
- Troubleshooting
- API reference
- How it works
Features
- Reads like a normal test — runner-native
describe/it/expect, with directnative.navigate,native.tap, andnative.getByTextcalls in the spec. - Locators —
by.text/testId/label/desc/idandpage(driver).getByText/getByTestId/ getByLabel/getById/getByRole, each mapped to the right native attribute per platform (so you never guesscontent-descvsaccessibilityIdentifier), with built-in auto-waiting andtap()/clear()/fill()/check()for interaction. Each takes a string (exact) or aRegExp(getByText(/Save( draft)?/));.nth()/.first()/.last()/.count()handle multiple matches. - Auto-waiting
expect—expect(locator).toBeVisible()/toShow()/toHaveText()/toBeChecked()/ toHaveCount()and.not, each polling until the condition holds (default 10s); plus synchronousexpect(value)matchers (toBe/toEqual/toContain/…) so non-UI checks need no second assertion library. - Network interception — a first-party HTTP + WebSocket mock server with
route().fulfill/reject/abort(likepage.route()) andexpect(mock).toHaveSent()/ toHaveReceived()traffic assertions. No per-app adapter. - One config —
nativeproof.config.tsowns device projects, app paths, artifacts, Appium/WebdriverIO tuning, and any app-specificnative.navigate/native.launchhooks. - Cross-platform — the same spec runs on Android (UiAutomator2) and iOS (XCUITest).
- One command —
nativeproofresolves your config, ensures Appium is up, and runs the suite. - TypeScript-first, strict, with evidence (redacted screenshots + page source) for an auditable green run.
Requirements
- Node.js ≥ 20
- Appium 3 + the platform driver(s):
uiautomator2(Android) and/orxcuitest(iOS, macOS only) - Android: Android SDK (platform-tools + emulator) and JDK 17
- iOS: macOS with Xcode + Command Line Tools
- A debug / E2E build of the app under test (
.apkfor Android,.app/.ipafor iOS)
Install
npm i -D nativeproof
# install the Appium driver(s) you need
npx appium driver install uiautomator2 # Android
npx appium driver install xcuitest # iOS (macOS only)
# optional: verify the toolchain is wired up
npx appium driver doctor uiautomator2Quick start
Scaffold the starting files with one command, then fill in the app path and the app-owned route hook:
npx nativeproof init --android
# or
npx nativeproof init --ios
# same scaffold shortcut if you prefer an init-specific bin
npx nativeproof-init --android
npx nativeproof-init --iosThen four steps from zero to a green run on Android — or set it all up by hand:
1. Configure — one nativeproof.config.ts at the project root owns the app/device control:
import { createNative, defineConfig, expect, wdioDriver } from "nativeproof";
const driver = () => wdioDriver();
export const native = createNative({
driver,
async navigate(route) {
if (route !== "/login") {
throw new Error(`Configure native.navigate(${JSON.stringify(route)}) in nativeproof.config.ts`);
}
// Put app-specific deep links, reset flows, or mock-backend state here.
},
});
export { expect };
export default defineConfig({
testDir: "tests",
artifacts: { dir: ".e2e-artifacts" },
projects: [
{
name: "android",
platform: "android",
capabilities: {
"appium:app": "./app/build/outputs/apk/debug/app-debug.apk",
"appium:deviceName": "Android Emulator",
},
},
],
});2. Write a spec — tests/home.spec.ts:
import { expect, native } from "../nativeproof.config";
describe("login", () => {
it("should be able to log in", async () => {
await native.navigate("/login");
await native.tap("Log in");
await expect(native.getByText("Welcome back")).toBeVisible();
});
});3. Boot a device — an emulator (Android) or simulator (iOS) must be running (see Android setup / iOS setup).
4. Run:
npm run test:e2enativeproof discovers the config, starts Appium if it isn't already up, and runs the suite.
Project setup
Everything lives in one nativeproof.config.ts — the playwright.config.ts analogue. It
declares device projects, app paths, artifacts, Appium/WebdriverIO tuning, and the app-owned
native.navigate / native.launch hooks. Specs import native and expect from this file, while
describe / it stay runner-native. The CLI auto-discovers the config and synthesises the
WebdriverIO run, so there's no hand-written wdio.conf.ts.
A typical project:
my-app-e2e/
├─ nativeproof.config.ts # the single config — native, device projects, artifacts
├─ package.json # includes npm run test:e2e
├─ tests/
│ ├─ home.spec.ts
│ └─ chat.spec.ts
└─ app/
├─ android/app-debug.apk
└─ ios/MyApp.app
The full, cross-platform config:
// nativeproof.config.ts
import { createNative, defineConfig, expect, wdioDriver } from "nativeproof";
const driver = () => wdioDriver(); // the live WebdriverIO/Appium session
export const native = createNative({
driver,
async navigate(route) {
// Keep app-specific routing here: deep links, reset flows, mock-backend state, etc.
if (route !== "/login") {
throw new Error(`Configure native.navigate(${JSON.stringify(route)}) in nativeproof.config.ts`);
}
},
});
// specs do: import { expect, native } from "../nativeproof.config";
export { expect };
export default defineConfig({
testDir: "tests",
artifacts: { dir: ".e2e-artifacts" },
projects: [
{
name: "android",
platform: "android",
capabilities: {
"appium:app": "./app/build/outputs/apk/debug/app-debug.apk",
"appium:deviceName": "Android Emulator",
},
},
{
name: "ios",
platform: "ios",
capabilities: {
"appium:app": "./build/ios/MyApp.app",
"appium:deviceName": "iPhone 15",
},
},
],
});File names are conventions, not requirements. The important rule is that native app/device control lives in
nativeproof.config.ts; NativeProof synthesises the WebdriverIO run from it.
Android setup
- SDK & JDK. Install Android Studio (or the command-line tools) and set:
export ANDROID_HOME="$HOME/Library/Android/sdk" # Linux: ~/Android/Sdk export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH" export JAVA_HOME="$(/usr/libexec/java_home -v 17)" # JDK 17 - Driver:
npx appium driver install uiautomator2. - Emulator. Create an AVD (Android Studio → Device Manager, or
avdmanager) and boot it:emulator -avd Pixel_7_API_34 -no-window -no-audio & adb wait-for-device adb devices # should list the emulator - App build. Set
projects[].capabilities["appium:app"]innativeproof.config.tsto your debug/E2E.apk(or useappium:appPackage+appium:appActivityfor an already-installed build). - Mock host. From an emulator, the host machine is reachable at
10.0.2.2— build your E2E app so its backend base URL ishttp://10.0.2.2:18113(the mock server's port). Bind the mock withhost: "0.0.0.0"so the device can reach it (a real device uses your Mac's LAN IP, e.g.http://192.168.1.20:18113). Thenmock.route(...)andexpect(mock)see the traffic.
iOS setup
iOS requires macOS (Appium's
xcuitestdriver builds on Xcode and is macOS-only).
- Xcode + Command Line Tools. Install Xcode from the App Store, then:
xcode-select --install xcodebuild -version # confirms the toolchain is wired up sudo xcodebuild -license accept - Driver:
npx appium driver install xcuitest. On the first run it builds WebDriverAgent (the on-device agent Appium drives). For the simulator this is automatic; for a real device you must sign WDA once — setappium:xcodeOrgId(your Apple Team ID) andappium:xcodeSigningId("Apple Development"), or opennode_modules/appium-xcuitest-driver/.../WebDriverAgent.xcodeprojin Xcode and select a signing team. Give the first launch room withappium:wdaLaunchTimeout. - Simulator. List and boot one (Xcode → Settings → Components installs runtimes):
Matchxcrun simctl list devices # names + UDIDs of installed simulators xcrun simctl boot "iPhone 15" # or boot by UDID open -a Simulator # optional: watch it runappium:deviceName/appium:platformVersionto a simulator that exists, or pin a specific one withappium:udid. - App build.
- Simulator: set
projects[].capabilities["appium:app"]innativeproof.config.tsto a simulator-built.app(anarm64/x86_64simulator binary, not a device build), e.g.app/ios/MyApp.app. - Real device: set
projects[].capabilities["appium:app"]to a signed.ipa, setappium:udid, and use a provisioning profile that covers both the app and WebDriverAgent. - Already installed: skip
appium:appand setappium:bundleIdinstead.
- Simulator: set
- Mock host. The simulator shares the host's network, so the backend base URL is
http://127.0.0.1:18113(the mock server's port). A real device must reach your Mac by its LAN IP instead — bind the mock withhost: "0.0.0.0"so both ends use the same interface.
Writing tests
Test blocks (describe / it)
Use the runner's own describe / it / beforeEach / describe.skip words. NativeProof should
make native controls feel first-class inside those tests, not replace the runner:
import { expect, native } from "../nativeproof.config";
describe("login", () => {
it("should be able to log in", async () => {
await native.navigate("/login");
await native.tap("Log in");
await expect(native.getByText("Welcome back")).toBeVisible();
});
});Keep meaningful setup visible:
beforeEach(async () => {
await native.launch({ route: "/login", reset: true });
});Where imports come from: specs import
native/expectfrom yournativeproof.config.ts. Everything else —page,by, the gesture helpers (swipe/tapAt),captureState, and the types — imports from thenativeproofpackage directly.
For stateful flows where a fixture genuinely exposes intent better than visible setup, the lower-level fixture APIs remain available. Keep them out of generated projects and first-read specs.
Fixtures, roles & the app seam
This is the legacy/advanced compatibility surface for suites that already need shared scenario
fixtures. New specs should prefer runner-native describe / it with visible setup and direct
native.* calls.
A scenario's context is provisioned once before its behaviours and torn down once after
(the analogue of a Playwright scoped fixture / describe.serial) — so a single sign-in underpins
many ordered checks instead of re-logging-in per test. The order is: driver → mock →
login(role) → join(role) → build screens.
The role string from a harness scenario flows into login/join, so one app definition drives
many roles:
const app = defineApp({
driver: () => wdioDriver(),
mock: () => startMockServer({ port: 18113, host: "0.0.0.0" }),
secrets: [/\b\d{6}\b/], // e.g. a 6-digit OTP — redacted from evidence
// runs once per describe, before screens are built:
login: async ({ driver, role }) => {
const p = page(driver);
await p.getByTestId("email").fill(`${role}@example.com`); // taps to focus, then types
await p.getByRole("button", { name: "Sign in" }).tap();
},
// enters the role's main surface after login:
join: async ({ driver, role }) => {
if (role === "member") await page(driver).getByText("My rooms").tap();
},
screens: {
member: ({ driver }) => ({ messages: page(driver).getByTestId("message-list") }),
guest: ({ driver }) => ({ banner: page(driver).getByTestId("signup-banner") }),
},
// optional lifecycle hooks:
teardown: async ({ driver }) => { /* release app resources before the session is deleted */ },
onFailure: async (ctx, { title, error }) => { await captureState(`fail-${title}`); }, // evidence on any failed behaviour
});test.describe("a signed-in member", "member", () => {
test.beforeEach(async ({ member }) => { /* reset to a known state before each behaviour */ });
test.afterEach(async ({ member }) => { /* per-behaviour cleanup, if any */ });
test("renders the latest message", async ({ member, mock }) => { /* … */ });
});
test.describe("a guest", "guest", () => { /* uses { guest, mock } */ });The session fixture is provisioned once per describe (the slow login + join) and shared across
its behaviours; beforeEach / afterEach run around each behaviour with the context injected — a
repeatable reset without per-spec boilerplate. teardown runs before the mock stops and the session
is deleted (e.g. force-stop the app); onFailure runs when a behaviour throws, before the failure
propagates, so on-failure evidence lives in one place instead of every behaviour.
If setup discovers that a scenario cannot run on the current native app build or device, call
skipScenario(reason) from the fixture. NativeProof marks the whole scenario skipped and still runs
teardown(undefined), so app-owned seam checks stay in one fixture instead of leaking raw Mocha
this.skip() calls into specs:
import { skipScenario, type ScenarioFixture } from "nativeproof";
export function speechStallScenario(): ScenarioFixture<SpeechCtx> {
return {
async setup() {
if (!process.env.WORDLY_E2E_PRESENTER_SPEECH_RESULT_STATE) {
skipScenario("speech-result seam is not enabled for this app build");
}
return startSpeechSession();
},
async teardown(context) {
await context?.mock.stop();
},
};
}NativeProof locators read, tap, clear and fill text entry.
fill()is Playwright-style: it focuses the field, clears existing text, then types the replacement value. For custom keyboards or key chords, keep the low-level WebdriverIO call insidenativeproof.config.tssetup/control code instead of hiding it in a spec helper.
Locators
Build locators by intent; NativeProof maps each to the right native attribute per platform — so
you address elements the way a person describes them, not by content-desc vs name:
const p = page(driver);
p.getByText("Sign in"); // visible text
p.getByText(/Sign ?in/i); // ...or a RegExp, matched against the element's decoded value
p.getByTestId("login-button"); // your test id
p.getByLabel("Sign out"); // accessibility label
p.getById("message-list"); // resource id
p.getByRole("button", { name: "Send" }); // role + accessible name
p.getByRole("checkbox"); // by role/class — checkbox/switch/button/textfield/image
p.locator(by.desc("Open menu")); // escape hatch: a raw selectorEvery getBy* (and by.*) takes a string (exact match) or a RegExp (tested against the
element's decoded value), so a human pattern matches even entity-escaped source — getByText(/Save( draft)?/),
getByLabel(/^Remove /), getByRole("checkbox", { name: /terms/i }). The Playwright muscle memory carries over.
A string selector is exact — case-, space- and punctuation-sensitive.
getByText("Sign In")does not match aSign inlabel; it just times out as "not found", with no hint that you were one capital letter away. When you don't yet know a label verbatim — porting an existing suite, or writing the spec before you've seen the screen — reach for aRegExp(getByText(/sign in/i)) or read the real label off the page source first (see Troubleshooting). A wrong guess fails the same way a genuinely-missing element does, so confirm the string against the device.
How each maps to the page source:
| Locator | Android attribute | iOS attribute |
|---|---|---|
getByText / by.text |
text or content-desc |
label or value |
getByLabel / by.label |
content-desc |
label |
getByRole(role, { name }) |
widget class + own or in-bounds content-desc/text |
XCUITest type + own or in-bounds label/value |
getByRole(role) / by.role (no name) |
widget class |
XCUITest type |
getByTestId / by.testId |
resource-id |
name |
getById / by.id |
resource-id |
name |
by.desc |
content-desc |
name |
getByTextis forgiving. A visible label surfaces astextorcontent-descon Android (Jetpack Compose) and aslabelorvalueon iOS (SwiftUI), sogetByText/by.textfinds the label wherever the toolkit put it — not just the node's owntext. Reach forgetByLabel/by.descwhen you specifically want the accessibility description.
Named roles understand split native labels. Compose/SwiftUI can expose a button or text field's visible name on a child/sibling label while the semantic role lives on a separate role node with the same bounds.
getByRole("button", { name: "Search" })andgetByRole("textfield", { name: /email/i })match that common shape, while unrelated same-named text outside the control bounds is ignored.
A Locator is a lazy, awaitable handle with built-in waiting:
await member.messages.isVisible(); // boolean, no waiting
await member.roomTitle.textContent(); // the node's own text, or null
await member.spinner.waitFor(); // wait until visible (throws on timeout)
await member.sendButton.tap(); // wait for it, then tap its centre
await member.sendButton.tap({ timeout: 2_000, interval: 100 }); // tune the wait
await member.row.tap({ clickableAncestor: true }); // tap the clickable parent of a non-clickable label
await member.composer.clear(); // focus the field (tap), then clear existing text
await member.composer.fill("Hello team"); // focus, clear existing text, then type
await p.getByText("Item").count(); // how many elements match
await p.getByText("Item").nth(1).tap(); // the 2nd match (.first() / .last(); negative counts from the end)
await member.terms.check(); // checkbox/switch → tap to checked (no-op if already there); also uncheck()
await member.terms.isChecked(); // boolean
await p.getByRole("checkbox").near(p.getByText("Wi-Fi")).check(); // the checkbox in the Wi-Fi row
const AcceptAgreementCheckbox = p.getByRole("checkbox", { name: /Accept Agreement/ });
await AcceptAgreementCheckbox.check();
await expect(AcceptAgreementCheckbox).toBeChecked();Relative locators. getByRole(role) matches by element class/type (checkbox, switch, button,
textfield, image), and Locator.near(anchor, { maxDistance? }) scopes to the match nearest an
anchor's element — so a control beside a label is addressed by the label, not by coordinates:
page(driver).getByRole("checkbox").near(page(driver).getByText("Wi-Fi")). Compose with .check() /
expect(locator).toBeChecked().
Some iOS apps expose custom checkbox controls as XCUIElementTypeButton nodes rather than native
switches. If the accessible name identifies the node as a checkbox, getByRole("checkbox") still
matches it, and toBeChecked() understands iOS checked state from value, selected traits, and
checked/unchecked labels.
tap() resolves the element's bounds from the page source and taps the centre — a coordinate
tap that works even on Compose / SwiftUI nodes Appium reports as non-clickable.
On Compose / SwiftUI the visible label often sits on a non-clickable child of the real touch
target (a list row, a card). tap({ clickableAncestor: true }) taps the smallest
clickable="true" ancestor that fully contains the matched node instead of the node's own centre,
falling back to the node itself when nothing clickable wraps it.
clear(opts?) focuses the field with a tap() and clears existing text. fill(text, opts?)
does the Playwright thing: focus, clear, then type replacement text through the device keyboard.
Both need a driver with focused text clearing and input (the bundled wdioDriver() has them) and
throw a clear error otherwise. opts is the same { timeout?, interval? } as tap().
Assertions
Assertions auto-wait (poll until the condition holds or the timeout elapses, default
10s / 250ms interval), accept a string or RegExp, and .not inverts:
await expect(member.messages).toBeVisible();
await expect(member.messages).toShow("Welcome to the room"); // present + text anywhere on screen
await expect(member.roomTitle).toHaveText(/Room: \w+/); // the node's OWN text (substring or regex)
await expect(member.terms).toBeChecked(); // checkbox / switch is on
await expect(member.results).toHaveCount(3); // exactly 3 elements match
await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });toBeVisible(opts?)— the selector matches a node in the source.toShow(text, opts?)— the selector is present andtextappears in the source.toHaveText(text, opts?)— the matched node's own text contains / matchestext.toBeChecked(opts?)— the matched checkbox / switch is checked (checked="true").toBeEnabled(opts?)/toBeDisabled(opts?)— the matched element'senabledstate. If the visible label is a non-clickable child inside a clickable control, NativeProof reads the clickable ancestor's state, soexpect(native.getByText("Search")).toBeDisabled()follows the real button.toHaveCount(n, opts?)— exactlynelements match the selector.optsis{ timeout?, interval? }(ms).
Value matchers — expect(value) also takes a plain value, for the non-UI assertions a spec
still needs (counts, ids, parsed payloads). The locator and mock matchers auto-wait and return
promises; value matchers assert a value you already have, so they are synchronous (no await)
and .not inverts:
const frames = await mock.frames();
expect(2 + 2).toBe(4); // strict identity (Object.is)
expect(frames).toContain(someFrame); // membership (arrays) or substring (strings)
expect({ id: 7, name: "Ada" }).toEqual(user); // deep structural equality
expect(member.unreadBadge).toBeTruthy();
expect(reply.error).toBeNull();
expect(reply.id).toBeDefined();
expect(reply.id).not.toBe(previousId);toBe(expected)— strict identity (Object.is).toEqual(expected)— deep structural equality.toContain(expected)— substring (for strings) or membership (for arrays).toBeTruthy()/toBeFalsy()/toBeDefined()/toBeNull()— value guards.
This keeps every assertion in a spec under one expect, so there's no second assertion library to
import for the checks the UI/traffic matchers don't cover.
Network interception & assertions
The mock backend works like Playwright's page.route(). Intercept a path to control its
reply, and assert the traffic the app exchanged — over both REST and WebSocket:
import { expect, mock, native } from "../nativeproof.config";
describe("send failures", () => {
it("surfaces a rejected send", async () => {
// Interception — routes apply to the next request/connect on that path:
mock.route("/messages").reject({ code: 503 }); // HTTP status, or WS close code (3000–4999)
await native.tap("Send");
await expect(native.getByText("Couldn't send message")).toBeVisible();
});
});
describe("loading a room", () => {
it("renders history fetched on open", async () => {
// fulfill answers the request/connect with a canned frame/body:
mock.route("/messages").fulfill({ type: "history", messages: ["Hello", "Hi there"] });
await native.navigate("/rooms/general");
await expect(native.getByText("Hi there")).toBeVisible();
});
});
describe("chat room", () => {
it("sends a message and receives the next one", async () => {
await native.fill("Message", "Hello");
await native.tap("Send");
// Assertions — matched by path + type + any payload field:
await expect(mock).toHaveSent({ path: "/messages", type: "create" }); // a WS message
await expect(mock).toHaveSent({ path: "/profile", type: "request", method: "GET" }); // a REST call
await expect(mock).toHaveReceived({ path: "/messages", type: "new" });
await expect(mock).not.toHaveSent({ type: "error" });
});
});mock.route(path).fulfill(frame)— answer with a canned frame/body (WS connect reply or HTTP JSON).mock.route(path).reject({ code })— fail it (HTTP status, or WebSocket close code 3000–4999).mock.route(path).abort()— drop the connect/request entirely.expect(mock).toHaveSent(match)/toHaveReceived(match)—matchis a partial frame:path/typeplus any payload fields. Each field is a string (exact / deep-equal) or aRegExp(toHaveSent({ path: /\/users/, type: "request" })) to match a query-suffixed or variable path.
Frame types — real protocol messages keep their own type (a WS JSON message's type); the
server also synthesises types for primitives so you can assert on them:
| What the app did | Recorded as |
|---|---|
| Opened a WebSocket | { type: "open", direction: "sent" } |
| Sent a WS JSON message | { type: <message.type>, direction: "sent", payload } |
| Made an HTTP request | { type: "request", direction: "sent", payload: { method } } |
| Got a fulfilled reply | `{ type: <frame.type ?? "response" |
startMockServer() is a real HTTP + WebSocket server (url / wsUrl), so there's no per-app
adapter — your app just points at it. It can also push a server-initiated frame to open sockets
with server.send(path, frame) (useful for simulating an incoming message in a config-level helper).
When specs need traffic assertions, export the mock handle from nativeproof.config.ts next to
native so the test still reads directly.
Bring your own backend
startMockServer is the batteries-included option, but an app with its own protocol or an existing
mock server can export a small config-owned adapter that exposes the MockBackend contract — then
route() and the traffic assertions work unchanged:
import {
createNative,
defineConfig,
expect,
type MockBackend,
type MockFrame,
type MockRoute,
wdioDriver,
} from "nativeproof";
function adapt(server: MyExistingMock): MockBackend {
return {
frames: async (): Promise<MockFrame[]> =>
server.log.map((f) => ({
path: f.path,
type: f.kind,
direction: f.outbound ? "sent" : "received",
payload: f.body,
})),
route: (path: string): MockRoute => ({
fulfill: (frame) => server.stub(path, frame),
reject: ({ code }) => server.fail(path, code),
abort: () => server.drop(path),
}),
stop: () => server.close(),
};
}
export const mock = adapt(startMyMock());
export const native = createNative({
driver: () => wdioDriver(),
async navigate(route) {
// Use route plus mock.url/mock.wsUrl to put the app in the state this test needs.
},
});
export { expect };
export default defineConfig({
projects: [{ name: "android", platform: "android", capabilities: { "appium:app": "./app.apk" } }],
});The framework depends only on the MockBackend interface, never a concrete server — so
expect(mock).toHaveSent(...) reads your backend's traffic with no other changes.
Just need the assertions? If your mock can't implement
route/stop(e.g. it only writes a request/socket log), expose a frames-onlyFrameLog—{ frames(): Promise<MockFrame[]> }, whichMockBackendextends — and pass that toexpect(...):expect(traffic).toHaveSent({ path, type })/.toHaveReceived(...)auto-wait over it, noroute/stopneeded.
Gestures & scrolling
For motion the locator layer doesn't cover (scrolling a list, swiping a carousel), use the low-level pointer helpers — coordinates are in screen pixels:
import { swipe, tapAt, pause } from "nativeproof";
await swipe(540, 1500, 540, 500); // drag up to scroll a feed down (fromX, fromY, toX, toY, duration=600)
await tapAt(540, 120); // a raw coordinate tap
await pause(250); // idle (e.g. let an animation settle)A common pattern: swipe until the target appears, then assert.
while (!(await member.messages.shows("Older message"))) {
await swipe(540, 1500, 540, 600);
}
await expect(member.messages).toShow("Older message");Evidence & secrets
A passing mobile test should prove app state, not just "the runner didn't throw". Capture a screenshot + a redacted page-source snapshot at any meaningful step:
import { captureState } from "nativeproof";
await member.sendButton.tap();
await captureState("after-send"); // writes .e2e-artifacts/after-send.png + after-send.xml (redacted)- Artifacts land in
.e2e-artifacts/by default; setartifacts: { dir: "..." }innativeproof.config.tsto keep that control in config. - Source is redacted before it touches disk: built-in patterns strip 4–8 digit values,
passcodefields, andBearertokens; add your app's own patterns viasecrets/redactindefineApp.
Running
One command, in the spirit of playwright test:
nativeproof init --android # scaffold config, package script and sample spec
nativeproof init --ios # same, for an iOS project
nativeproof-init --android # init-specific bin alias
nativeproof-init --ios # init-specific bin alias
nativeproof # auto-discovers nativeproof.config.ts, runs the suite
nativeproof --platform android # or: --platform ios
nativeproof --android # shorthand for --platform android
nativeproof --ios # shorthand for --platform ios
nativeproof --project tablet # a named project from nativeproof.config.ts
nativeproof --spec tests/chat.spec.ts
nativeproof --no-appium # use an Appium server you started yourself
nativeproof --helpnativeproof discovers nativeproof.config.ts, ensures an
Appium server is reachable (starting one with --relaxed-security unless --no-appium), and runs
the suite with PLATFORM / SPEC / NATIVEPROOF_PROJECT set for you. Appium host/port/path live
in nativeproof.config.ts under appium. A device or emulator must already be running — the mobile
analogue of needing a display.
CI
It's a normal WebdriverIO suite, so CI is one command — the only requirement is a device.
Android (GitHub Actions, hardware-accelerated emulator):
jobs:
android-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24 }
- run: npm ci
- run: npx appium driver install uiautomator2
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: npx nativeproof --platform androidiOS (GitHub Actions, macOS runner + simulator):
jobs:
ios-e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 24 }
- run: npm ci
- run: npx appium driver install xcuitest
- run: xcrun simctl boot "iPhone 15" || true
- run: npx nativeproof --platform iosTo offload either platform, point appium.host / appium.port in nativeproof.config.ts at a
device farm (BrowserStack, Sauce Labs, Firebase Test Lab) — NativeProof is just Appium, so no
test changes are needed.
The framework's own unit suite (npm test) needs no device and runs anywhere.
Configuration
defineConfig({ ... })
| Field | Type | Default | What |
|---|---|---|---|
app |
App |
— | optional fixture surface from defineApp |
projects |
DeviceProject[] |
— | device targets; each { name, platform, capabilities } |
testDir |
string |
"tests" |
directory holding the specs |
testMatch |
string |
"**/*.spec.ts" |
glob within testDir |
appium |
{ host?, port?, path? } |
127.0.0.1 : 4723 /wd/hub |
Appium connection |
artifacts |
{ dir? } |
.e2e-artifacts |
screenshot/source output |
mochaTimeout |
number |
240000 |
per-test timeout (ms) |
createNative({ ... }) — direct spec control
| Field | Type | What |
|---|---|---|
driver |
() => Driver |
acquire the device (e.g. wdioDriver()) |
navigate? |
(route, { driver, native }) => Promise |
app-owned route/deep-link/reset hook |
launch? |
(options, { driver, native }) => Promise |
optional app launch/reset hook for visible setup |
defineApp({ ... }) — the seam
| Field | Type | What |
|---|---|---|
driver |
() => Driver |
acquire the device (e.g. wdioDriver()) |
mock |
() => MockBackend |
start the mock backend (e.g. startMockServer(...)) |
screens |
Record<string, factory> |
screen-object factories, bound to the device context |
login? |
({ driver, mock, role }) => Promise |
reach a logged-in state for the role |
join? |
({ driver, mock, role }) => Promise |
enter the role's main surface |
secrets? |
RegExp[] |
patterns kept out of captured evidence |
redact? |
RegExp[] |
extra evidence-redaction patterns |
CLI flags & env
| Flag | Env it sets | Default |
|---|---|---|
--platform <android|ios> |
PLATFORM |
— |
--project <name> |
NATIVEPROOF_PROJECT |
first project |
--spec <glob> |
SPEC |
all specs in testDir |
--no-appium |
— | auto-start Appium |
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
Appium is not reachable … |
No device, or --no-appium set without a server. Boot the emulator/simulator; drop --no-appium to let NativeProof start Appium. |
no nativeproof.config.ts found |
Run from the project root, or run nativeproof init --ios / nativeproof init --android. |
| "No specs found" | Specs must match testDir/testMatch (default tests/**/*.spec.ts), or pass --spec. |
| App can't reach the mock | Emulator → use 10.0.2.2; real device → your machine's LAN IP. Bind the mock with host: "0.0.0.0". |
expect(...) times out |
The selector never matched — confirm the attribute mapping (see the Locators table) and the exact value (see below); raise { timeout } for slow screens. |
| iOS first run hangs | WebDriverAgent is building/signing. Set appium:wdaLaunchTimeout and, on a real device, the signing capabilities. |
A selector won't match? Read the source — don't re-guess. A string that's off by a capital letter,
a trailing space, or (1) vs (1) fails as a silent timeout, identical to a genuinely-absent element.
The fix is always the same: look at what the device actually exposes.
- On any failure, the
onFailurehook /captureState(prefix)writes the page source to your artifacts dir. Grep it for the real label:grep -oE 'text="[^"]+"' <file>andcontent-desc="…"on Android,label="…"/value="…"on iOS — then match it exactly, or with aRegExpif it varies. - Or read it live mid-spec:
console.log(await wdioDriver().source()).
This one-step loop — fail → read the real attribute → match it — is behind most "element not found" mysteries, and it beats guessing label strings every time.
API reference
createNative({ driver, navigate?, launch? })→native— the direct control surface for runner-native specs:native.navigate,native.launch,native.tap,native.fill, andnative.getByText/getByTestId/getByLabel/getById/getByRole.defineApp(definition)→app— the fixture seam;app.session(role?)is a scenario fixture.definitionalso takes optionalteardown(ctx)(before mock stop / session delete) andonFailure(ctx, { title, error }).createHarness(app)→{ test, expect }— legacy/advanced fixture harness for existing suites that need a shared scenario context. Do not use it in generated projects; prefer runner-nativedescribe/itand visible setup.defineConfig({ projects, testDir?, testMatch?, appium?, artifacts?, mochaTimeout? })— the config the CLI runs.appis optional for fixture-heavy suites.by.text/desc/id/testId/label(string orRegExp),page(driver).getByText/getByTestId/getByLabel/getById/getByRole,page(driver).locator(selector),new Locator(driver, selector)— locators (isVisible,textContent,bounds,shows,waitFor,tap,clear,fill,isChecked,check,uncheck,count,nth,first,last—tap({ clickableAncestor })for non-clickable labels).expect(locator)→toBeVisible/toShow/toHaveText/toBeChecked/toHaveCount(+.not), each(value?, { timeout?, interval? }).expect(mock | frameLog)→toHaveSent/toHaveReceived(+.not), matched by partial frame; accepts anyFrameLog({ frames() }).expect(value)→toBe/toEqual/toContain/toBeTruthy/toBeFalsy/toBeDefined/toBeNull(+.not) — synchronous matchers for plain values.skipScenario(reason)— call from aScenarioFixture.setup()precondition to skip every behaviour in that scenario while still running teardown.startMockServer({ port?, host? })→ aMockServer(url,wsUrl,route(),frames(),send(),stop()).swipe,tapAt,pause— low-level pointer gestures.captureState(prefix)/captureScreenshot/captureText/redactEvidenceText— evidence.- Device commands — Android (
adb):adbForceStop,resetAppAndBrowserState,adbTap,adbDump,adbLogcat*; iOS (simctl):iosTerminate,iosLaunch,iosInstall,iosUninstall,iosBoot,iosShutdown,resetAppState,iosLogShow. wdioDriver()→ the liveDriver;useRunner(hooks)to host on a non-Mocha runner.
How it works
The engine is Appium/WebdriverIO; NativeProof is the DX layer. It's app-agnostic by contract:
a consuming app keeps all specifics in nativeproof.config.ts through createNative and, where useful,
defineApp; nothing in the package imports app code (the dependency is one-way, app → framework).
The whole DX self-verifies against
an in-memory fake device — see test/demo.test.ts and run npm test (no emulator needed).
Package layout: native.ts (createNative), app.ts (defineApp), harness.ts (legacy fixture harness), config.ts
(defineConfig) + runner-config.ts (the wdio bridge), fixtures.ts, locator.ts +
page.ts, expect.ts, mock.ts + mock-server.ts, driver.ts, runner.ts, cli.ts
(the nativeproof bin), plus source/wait/gesture/adb/ios/log/evidence primitives.
License
See LICENSE.