Skip to content

Installation & First Test

This guide walks you through installing Tapsmith, writing your first test, and running it against an Android or iOS device/simulator.

Tapsmith requires Node.js 22 or newer. Check with node --version before you install. On older Node, npm can install an old Tapsmith release whose commands don’t match this guide (or fail with No matching version found).

Moving an existing suite? The migration guides map your current tool onto Tapsmith and port a test side by side: from Maestro, from Detox and from Appium.

Setting up with an AI coding agent? See Using Tapsmith with AI coding agents for the non-interactive setup loop (doctor --json → init --yes → verify --json).

Before you begin, make sure you have the following installed:

Requirement Minimum version How to check
Node.js 22+ node --version
ADB (Android Debug Bridge) Any recent version adb --version
Android device or emulator Android 8.0+ (API 26+) adb devices

ADB comes with the Android SDK. If you don’t have one yet, install Android Studio (or the Android command-line tools, then sdkmanager platform-tools), set ANDROID_HOME to the SDK location (Android Studio’s default is ~/Library/Android/sdk on macOS and ~/Android/Sdk on Linux), and add $ANDROID_HOME/platform-tools to PATH.

If you are using a single emulator or device, you can start it yourself and let Tapsmith detect it automatically. Tapsmith can also launch emulator instances for you when configured with launchEmulators and avd.

Requirement Minimum version How to check
Node.js 22+ node --version
Xcode 15+ xcodebuild -version
iOS Simulator iOS 17+ xcrun simctl list devices

Install Xcode from the Mac App Store (the Command Line Tools alone have no simulators), open it once to finish setup, then select it: sudo xcode-select -s /Applications/Xcode.app.

On an Apple Silicon Mac, use an arm64 build of Node (node -p process.arch prints arm64). An x64 Node, common after Migration Assistant from an Intel Mac, runs under Rosetta: npm then installs the x64 builds of Tapsmith’s packages, which run translated, and the prebuilt iOS simulator agent among them cannot run on the Mac’s arm64 simulators. Tapsmith still works — it builds an arm64 agent on the first iOS run and picks arm64 emulator images — but tapsmith doctor, init and test warn until you switch Node and reinstall (rm -rf node_modules && npm install).

Tapsmith manages iOS simulators automatically. Set the simulator config option to the simulator to boot (for example simulator: "iPhone 17"; xcrun simctl list devices lists the names). There is no default: without simulator (or device), Tapsmith looks for a single paired physical iPhone instead, and the run fails before any test starts when there isn’t one.

For physical iOS devices, additional prerequisites apply (libimobiledevice, Apple Developer account, device pairing). See iOS physical devices for the full walkthrough.

Add Tapsmith to your project as a dev dependency, with the package manager the project uses:

Terminal window
npm install -D tapsmith

This installs the TypeScript SDK, test runner, the Tapsmith daemon binary for your platform, and the Android agent APKs (via the @tapsmith/agent-android optional dependency).

These docs run the CLI as npx tapsmith …. With Yarn, run yarn tapsmith … instead, and with pnpm, pnpm exec tapsmith … (npx tapsmith also works in a pnpm project).

On npm 11.17 and later, the install may end with an npm warn allow-scripts (npm 12: npm warn install-scripts) list naming protobufjs, esbuild and, on macOS, fsevents. Tapsmith’s own packages have no install scripts. These three come in through gRPC and tsx, and Tapsmith works with their scripts blocked: protobufjs only prints an advisory, esbuild only re-checks the binary npm already installed, and fsevents ships prebuilt. To record that decision and silence the warning, add this to your project’s package.json:

"allowScripts": { "protobufjs": false, "esbuild": false, "fsevents": false }

pnpm blocks dependency build scripts unless the project approves them. From pnpm 11, pnpm add -D tapsmith installs everything and then exits 1 with ERR_PNPM_IGNORED_BUILDS: Ignored build scripts: esbuild, protobufjs (pnpm 10 prints the same list as a warning). They are the same scripts as in the npm warning above, and Tapsmith works without them. Only the project can approve or deny them, so to record that decision and silence the error, set them to false under allowBuilds in the pnpm-workspace.yaml beside your pnpm-lock.yaml (in a monorepo, the one at the workspace root; create it if there isn’t one). Some pnpm versions have already added them there with the value set this to true or false: replace that value rather than adding a second allowBuilds:.

allowBuilds:
esbuild: false
protobufjs: false

Then run pnpm install. If tapsmith init ran the install for you, it prints this snippet.

Tapsmith doesn’t run under Yarn Plug’n’Play, the default for new Yarn 2+ projects (a .pnp.cjs file next to package.json). It starts tsx, its daemon and its device agents as programs, and Plug’n’Play keeps packages inside zip archives, so tapsmith test, verify and mcp-server stop with an error naming the fix, and tapsmith doctor reports it. Switch the project to the node-modules linker (React Native and Expo projects already use it):

.yarnrc.yml
nodeLinker: node-modules

Then run yarn install.

Tapsmith installs and launches a build of your app; it does not build it for you. You need:

  • Android: an .apk. Any variant your emulator or device can install works.
  • iOS Simulator: a .app built for the simulator (-sdk iphonesimulator, under a …-iphonesimulator/ products folder).
  • iOS physical device: a .app built for iphoneos and code-signed for the device. Simulator and device builds are not interchangeable; see iOS physical devices.

tapsmith init offers the builds it finds under android/**/build/outputs/apk/ and ios/**/*-iphonesimulator/ (or *-iphoneos/). A build anywhere else, such as Xcode’s default DerivedData folder, can still be used: enter its path.

Terminal window
# Android: writes android/app/build/outputs/apk/debug/app-debug.apk
cd android && ./gradlew assembleDebug
# iOS Simulator: writes ios/build/Build/Products/Debug-iphonesimulator/MyApp.app
cd ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp \
-configuration Debug -sdk iphonesimulator -derivedDataPath build build

Without -derivedDataPath, Xcode (and a build from the Xcode UI) writes to ~/Library/Developer/Xcode/DerivedData/<MyApp>-<hash>/Build/Products/Debug-iphonesimulator/MyApp.app. Passing -derivedDataPath build keeps the build at a stable path you can put in app.

A React Native Debug build does not contain your JavaScript: it loads it from the Metro dev server at launch. Without Metro running (npx expo start or npx react-native start) it opens on a red error screen instead of your app. Even with Metro running, an Expo build that includes expo-dev-client opens the dev launcher rather than the app on a cold launch, and a physical Android device reaches Metro only after adb reverse tcp:8081 tcp:8081. A Release build bundles the JavaScript into the app and runs on its own, which makes it the simplest choice for tests, locally and in CI:

Terminal window
# Android (the React Native template signs release builds with the debug keystore)
cd android && ./gradlew assembleRelease # android/app/build/outputs/apk/release/app-release.apk
# iOS Simulator
cd ios && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp \
-configuration Release -sdk iphonesimulator -derivedDataPath build build

When a debug APK is also lying around, the wizard lists it first and tapsmith init --yes picks it over the release one: choose the release APK in the wizard, or pass --apk android/app/build/outputs/apk/release/app-release.apk.

Expo projects have no android/ or ios/ folder until you generate them (tapsmith init recognises an Expo project and suggests these commands when it finds no build):

  • npx expo prebuild generates the native projects; then build them with the commands above.
  • npx expo run:android --variant release generates, builds and installs in one step, leaving the APK in android/app/build/outputs/apk/release/. npx expo run:ios --configuration Release does the same for iOS, but builds into Xcode’s default DerivedData folder (the path above), where tapsmith init does not look: enter that path, or build with the xcodebuild … -derivedDataPath build command above after expo prebuild.
  • With EAS, use a build profile that produces simulator and installable builds, for example "e2e": { "ios": { "simulator": true }, "android": { "buildType": "apk" } } in eas.json, then eas build --profile e2e --platform ios --local (or download the build). An iOS simulator build arrives as a .tar.gz; extract it and point app at the .app inside.

If the app mounts @tapsmith/react-native, its reset hooks are on in Debug builds but switched off in Release builds unless you turn them on for your test build. In an Expo project, set EXPO_PUBLIC_TAPSMITH_HOOKS=1 at build time (Expo inlines EXPO_PUBLIC_* variables into the bundle). A bare React Native app does not inline that variable, so pass the hooks’ enabled prop from a build-time flag of your own instead.

The interactive setup wizard detects your environment, walks you through platform configuration, and generates your config file:

Terminal window
npx tapsmith init

The wizard walks through these steps:

  1. Environment detection — checks for ADB, Xcode, simulators, emulators, and reports what’s available
  2. Platform selection — choose Android, iOS, or both
  3. Android — pick your APK from the builds the wizard finds under android/ (the same ones init --yes looks for) or enter a path, which must exist; the wizard reads the package name from the APK and asks only when it can’t (in an Expo project, with expo.android.package from the app config pre-filled). Then choose emulators, physical devices or both, and, for emulators, the AVD Tapsmith should launch
  4. iOS — choose simulators, physical devices or both. For simulators, pick your simulator .app the same way and the simulator to use, from every iOS simulator Xcode has (an already-booted one comes first and is selected, as it saves a boot). For physical devices, the wizard runs a code-signing preflight, offers to build the device agent, and asks for your device-signed (iphoneos) .app. It reads each build’s bundle ID and asks only when it can’t (in an Expo project, with expo.ios.bundleIdentifier pre-filled). Choosing both writes two projects, ios (simulator) and ios-device (physical device), so npx tapsmith test --project ios-device runs on the device alone (the layout of Running simulator and device together, with these project names)
  5. Network capture — optionally record HTTP/HTTPS traffic; saying yes writes trace: { mode: 'retain-on-failure' }, which device.route() needs, and lists the per-platform setup still to do
  6. iOS simulator agent — if no simulator agent build is found, offers to build it now (~30 s)
  7. Example test — optionally creates tests/example.tapsmith.ts
  8. AGENTS.md — optionally adds a Tapsmith section to AGENTS.md for AI coding agents
  9. Files — writes tapsmith.config.ts with the app, its package and, for iOS, platform: 'ios' and the simulator, plus testMatch: ['**/*.tapsmith.ts'] (see Tapsmith tests and your unit tests), the example test and the AGENTS.md section you asked for, and adds tapsmith-results/ (traces and screenshots) and tapsmith-report/ (the HTML report) to .gitignore. It checks it can write every file before writing any (and, before the platform questions, that it can write to the project directory at all), and writes the config last: if a file can’t be written (a read-only directory, say), the wizard stops there with exit code 1, saying which file, how to fix it and which files, if any, it had already written
  10. Install — if the project doesn’t have Tapsmith yet (you ran npx tapsmith init before installing it), offers to install it with your package manager (npm i -D tapsmith, yarn add -D tapsmith, …), since the config and example test import it. Decline and the command is the first of the next steps. In a folder with no package.json it doesn’t offer: the command then starts with npm init -y (or your package manager’s equivalent), because a bare npm i -D tapsmith would install into the nearest parent folder that has a package.json. Install Tapsmith first, as in Installation: with no local install, npx tapsmith init runs whichever version npx downloads, not the one your project will use

The wizard ends with your next steps: after the install, if it still needs doing, npx tapsmith verify (see below) comes first. It does not ask about workers; see Parallel runs to add them.

After setup, check the environment:

Terminal window
npx tapsmith doctor

tapsmith doctor runs a non-interactive health check and reports the status of each prerequisite, grouped by platform. It checks the platforms your config targets; a platform it does not target shows – skipped: … instead of its checks. For example, on a Mac set up for both platforms, with a config that tests both:

Tapsmith Doctor
Core
✓ Node.js 22.21.0
✓ Tapsmith daemon found (…/node_modules/@tapsmith/core-darwin-arm64/tapsmith-core)
✓ Config file found (tapsmith.config.ts)
Android
✓ ADB 37.0.0
✓ ANDROID_HOME (/Users/you/Library/Android/sdk)
⚠ No Android devices connected
↳ Start an emulator or connect a device with USB debugging enabled
✓ Android agent (@tapsmith/agent-android)
iOS
✓ Xcode 26.0
✓ iOS simulators available
✓ Simulator xctestrun found (auto-build cache, SDK 26.0)
Network Capture
✓ MITM CA exists (~/.tapsmith/ca.pem)
✓ AVD system images support HTTPS capture (1 AVD checked)
✓ mitmproxy installed (Homebrew cask)
✓ Network Extension enabled
✓ macOS system proxy not set by Tapsmith
14 checks passed, 1 warning

A warning (⚠) or error (✗) that Tapsmith knows how to fix is followed by a ↳ line saying how, often the exact command to run. tapsmith doctor --json prints the same checks for scripts and AI agents, with a fix field wherever there is one. The command exits 1 if any check is an error, including when neither Android nor iOS can run on the machine. Run it whenever tests fail in unexpected ways to rule out setup issues.

Then prove the whole loop works before writing tests:

Terminal window
npx tapsmith verify

tapsmith verify runs one real test through tapsmith test (starting the daemon, launching the emulator or simulator, installing the app) and reports whether it passed. It runs tests/example.tapsmith.ts if the wizard created it, or your first test file; with no test files yet it runs a throwaway smoke test and removes it afterwards.

Jest and Vitest run every *.test.ts and *.spec.ts file in the project by default, so a Tapsmith test with one of those names would run in your unit-test suite too, and fail there. That’s why tapsmith init names its example tests/example.tapsmith.ts and writes testMatch: ['**/*.tapsmith.ts'] into the config: name your Tapsmith test files *.tapsmith.ts and each suite leaves the other’s files alone. Keep them out of __tests__ folders, though: Jest runs every file in one, whatever its name.

If you’d rather keep *.test.ts names, set testMatch in tapsmith.config.ts to ['tests/**/*.test.ts'] (or remove it to use the default, which also matches *.test.ts files outside tests/), and exclude the Tapsmith test directory from the unit-test runner: testPathIgnorePatterns: ['/node_modules/', '<rootDir>/tests/'] in your Jest config, or exclude: [...configDefaults.exclude, 'tests/**'] under test in your Vitest config (configDefaults comes from vitest/config). Re-running tapsmith init warns about any Tapsmith tests the new config’s testMatch won’t run.

Tapsmith resets your app between test files by wiping its data and cold-launching — several seconds per file. React Native / Expo apps can do far better: mount @tapsmith/react-native once at the root and Tapsmith resets the app in-process, in well under a second — no config changes. Files that need a fresh app before every test opt in with one more line: test.use({ appResetScope: "test" }) — still warm, still sub-second.

import { TapsmithTestHooks } from "@tapsmith/react-native"
// in your root layout:
<TapsmithTestHooks urlPrefix={Linking.createURL("/")} clear={[AsyncStorage]} />

See the Warm app reset guide — including why the hooks must only ever be compiled into test builds, never a store release.

If you prefer to configure manually, create tapsmith.config.ts in your project root:

import { defineConfig } from "tapsmith";
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
apk: "./app/build/outputs/apk/debug/app-debug.apk",
package: "com.example.myapp",
timeout: 30_000,
screenshot: "only-on-failure",
});

testMatch makes Tapsmith run *.tapsmith.ts files, the names tapsmith init uses, which your Jest or Vitest run leaves alone (why); without it Tapsmith runs *.test.ts and *.spec.ts files. apk is the path to the Android APK you want to test; Tapsmith installs it. package is its package name, and you need it too: without package, Tapsmith never launches the app and never resets it between test files, so tests start on whatever happens to be on screen. activity is optional and usually not needed.

import { defineConfig } from "tapsmith";
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
platform: "ios",
app: "./ios/build/Build/Products/Debug-iphonesimulator/MyApp.app",
package: "com.example.myapp",
simulator: "iPhone 17",
timeout: 30_000,
screenshot: "only-on-failure",
});

For iOS, platform: "ios" is required: Tapsmith does not infer the platform from app, and a config that sets app or simulator without it is treated as Android: the run looks for an Android device (and fails there when there is none), and is refused when tests start (tapsmith doctor reports it up front as config-platform). Set app to the .app bundle built for the iOS Simulator, package to its bundle identifier (needed to launch and reset the app, as on Android), and simulator to the simulator to boot.

See the Configuration guide for all available options.

For parallel Android emulator runs, use:

import { defineConfig } from "tapsmith";
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
apk: "./app/build/outputs/apk/debug/app-debug.apk",
package: "com.example.myapp",
workers: 4,
launchEmulators: true,
avd: "Pixel_9_API_35",
});

When avd is set, Tapsmith defaults to using that AVD for provisioned emulator capacity. Set deviceStrategy: "prefer-connected" if you want connected devices to win instead.

On your own machine, each emulator Tapsmith launches opens a window and quick-boots from the AVD’s snapshot. In CI it runs headless, and emulatorLaunchOptions: { headless: true } does the same locally, at the cost of a slower cold boot. Emulators Tapsmith launches keep running after the run so the next one can reuse them. The run names each one and says how to stop it. See How Tapsmith launches emulators.

For parallel iOS simulator runs:

import { defineConfig } from "tapsmith";
export default defineConfig({
testMatch: ["**/*.tapsmith.ts"],
platform: "ios",
app: "./ios/build/Build/Products/Debug-iphonesimulator/MyApp.app",
package: "com.example.myapp",
workers: 4,
simulator: "iPhone 17",
});

Tapsmith provisions additional simulator clones automatically for multi-worker iOS runs.

Create a file at tests/smoke.tapsmith.ts:

import { test, expect } from "tapsmith";
test("app launches and shows welcome screen", async ({ device }) => {
// Wait for the welcome text to appear
await expect(device.getByText("Welcome")).toBeVisible();
});
test("can navigate to settings", async ({ device }) => {
// Tap a button by its accessibility role and name
await device.getByRole("button", { name: "Settings" }).tap();
// Verify we arrived at the settings screen
await expect(device.getByText("Settings")).toBeVisible();
});

A few things to note:

  • Tests receive a device fixture automatically. This is your primary interface for interacting with the app.
  • getByText(), getByRole(), and the other getBy* methods are Playwright-style locators that identify UI elements. See the Locators Guide for the full list.
  • expect() creates assertions that auto-wait. toBeVisible() polls until the element appears or the timeout expires.
  • Tests in the same file share the app. Tapsmith resets the app to a clean state once at the start of each test file, not before every test. So the second test above starts on whatever screen the first one left, with any state it created, such as a signed-in user. This differs from Playwright, where every test gets a fresh browser context. If a file’s tests each need a fresh app, add test.use({ appResetScope: "test" }) to that file. Without in-app hooks, each of those resets is a full clear and relaunch, which takes a few seconds per test. React Native and Expo apps that mount the warm reset hooks reset in well under a second instead. Test isolation covers the other reset options.
Terminal window
npx tapsmith test

Tapsmith will:

  1. Connect to the Tapsmith daemon (starting it if needed).
  2. Detect your connected device or emulator.
  3. Install the APK under test and the Tapsmith agent.
  4. Discover the test files your config’s testMatch matches (**/*.tapsmith.ts in a config from tapsmith init; **/*.test.ts and **/*.spec.ts when the config doesn’t set it).
  5. Run each test sequentially and report results.

For multi-worker runs, Tapsmith will assign one device per worker. If launchEmulators: true is configured, it will launch additional emulator instances automatically. If avd is set, those instances will use that AVD.

Terminal window
npx tapsmith test tests/smoke.tapsmith.ts
Terminal window
npx tapsmith test --workers 4

Or configure workers in tapsmith.config.ts. Each worker gets its own device. See CI Setup for sharding across CI machines.

If you need to debug against one known device, specify which one to use:

Terminal window
npx tapsmith test --device emulator-5554

For normal parallel runs, prefer workers + launchEmulators + avd in config.

Tapsmith prints results to the terminal with pass/fail status and timing for each test:

Found 2 test file(s)
tests/smoke.tapsmith.ts
Results:
PASS app launches and shows welcome screen (1204ms)
PASS can navigate to settings (2841ms)
Summary: 2 passed | 4.05s

When a test fails, Tapsmith prints the error message, a partial stack trace, and the path to a screenshot captured at the moment of failure:

FAIL can navigate to settings (30012ms)
Expected element {"text":"Settings"} to be visible, but it was not
Screenshot: tapsmith-results/screenshots/can_navigate_to_settings-1710345600000.png

You can use describe blocks to group tests, and test.use() to set options for a group:

import { test, describe, expect } from "tapsmith";
describe("Login flow", () => {
// Start every test signed out. Without this, "invalid credentials" would
// start where "successful login" left off: already signed in.
test.use({ appResetScope: "test" });
test("successful login", async ({ device }) => {
await device.getByRole("textfield", { name: "Email" }).type("user@example.com");
await device.getByRole("textfield", { name: "Password" }).type("password123");
// Close the keyboard first: many screens (any React Native ScrollView, by
// default) spend the first tap outside a field on dismissing it.
await device.hideKeyboard();
await device.getByRole("button", { name: "Sign In" }).tap();
await expect(device.getByText("Welcome back")).toBeVisible();
});
test("invalid credentials", async ({ device }) => {
await device.getByRole("textfield", { name: "Email" }).type("bad@example.com");
await device.getByRole("textfield", { name: "Password" }).type("wrong");
await device.hideKeyboard();
await device.getByRole("button", { name: "Sign In" }).tap();
await expect(device.getByText("Invalid credentials")).toBeVisible();
});
});

test.use() inside a describe applies only to the tests in that group. Hooks (beforeAll, beforeEach, afterEach, afterAll) are also available. You don’t need one to reset the app, because the reset is declared with appReset and appResetScope instead. See Writing Tests.