One per adapter, all driving the same session so a difference in the dashboard is a difference in the adapter and not in the test:
pnpm demo:wdio:mobile
pnpm demo:selenium:mobile
pnpm demo:nightwatch:mobile
pnpm demo:python:mobileDEVTOOLS_MODE=live flips any of them to live mode, exactly as the desktop
demos do. Trace is the default.
DEVTOOLS_MODE=live pnpm demo:selenium:mobileThe dashboard starts itself — the adapter does it (ensureBackendStarted in the
JS adapters, enable() in Python). Nothing needs a backend started by hand.
This is an opt-in toolchain, and it is not small. Between the Android SDK and one system image, expect several gigabytes. Nothing here is bundled, and none of it is needed for any other demo or test in this repo.
-
Android SDK and an emulator, or a real device with USB debugging on. Android Studio installs both, but it is not needed — Google's CLI installer is far lighter and is the route these instructions assume:
# macOS arm64; see developer.android.com for the other builds. Downloaded # and read before it runs, rather than piped into a shell: `latest` is a # mutable URL, so piping executes whatever it returns at that moment. curl -fsSL -o /tmp/android-cli-install.sh \ https://dl.google.com/android/cli/latest/darwin_arm64/install.sh less /tmp/android-cli-install.sh # read it bash /tmp/android-cli-install.sh
That leaves
android-cliin~/.android/bin(not on your PATH) and an SDK root that it does not necessarily report correctly:android-cli infosaid~/Library/Android/sdkon the machine this was written on while the actualplatform-tools,emulatorandsystem-imageswere in/opt/homebrew/share/android-commandlinetools. The directory holdingplatform-toolsis the one to export, whateverinfoclaims:export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools # check yours export PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$HOME/.android/bin"
Appium's Android driver reads
ANDROID_HOMEand refuses the session without it, after accepting the connection — which is why an unset variable surfaces as a 40-line driver stack trace rather than as a setup problem. The preflight below catches that case and prints the export lines for whichever SDK it can find.Then create a device once, and boot it:
android-cli --sdk="$ANDROID_HOME" emulator create medium_phone # downloads an arm64 system image — a few minutes, ~3 GB emulator -avd medium_phone # leave this terminal open adb wait-for-device adb devices # must list emulator-5554 device
The emulator has to run in a terminal that stays alive — a first boot takes minutes, and anything that reaps the process group kills it mid-boot.
android-cli emulator start medium_phonealso works and waits for boot, but the rawemulatorbinary is the more predictable of the two. If a fresh API 36 image fails to boot, its own log notes that "Guest Angle is still unstable for API > 35" — add-gpu swiftshader_indirect. -
Appium 2.x or 3.x and the Android driver. Export the SDK path first, in this shell — Appium's Android driver reads its OWN environment, so exporting it where the tests run changes nothing and the session is refused with
Neither ANDROID_HOME nor ANDROID_SDK_ROOT environment variable was exported, delivered through the client as a WebDriver failure that looks like a test problem:export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools # yours may differ export PATH="$PATH:$ANDROID_HOME/platform-tools" npm i -g appium appium driver install uiautomator2 appium --address 127.0.0.1 --port 4723
The preflight reads the running Appium's environment and says so when this is what is wrong, because nothing else in the stack does.
You do not need an app. The default target is the device's own Clock app,
so there is no .apk to build, upload, or keep credentials for — which is what
kept a native example from landing before.
Every one of the four checks this before it opens a session and tells you what
is missing, because the frameworks themselves do not: WDIO reports "make sure
browser driver is running", Nightwatch reports it could not reach GeckoDriver,
and selenium-webdriver reports ECONNREFUSED with a stack trace. All three mean
Appium is not up. examples/mobile-preflight.cjs is the shared check. It separates the five
states that look alike from the outside: Appium not up; Appium up but no SDK
exported here; Appium up with no SDK in its own environment, which is the
one that wastes the most time; SDK fine but no device attached; and a remote
Appium, for which none of the local checks apply. When an SDK is present but unexported it
prints the exact export lines for the path it found.
All four run the same flow against the Clock app, which ships with every
Android system image — so there is no .apk to supply, nothing to upload and no
credentials. Clock is used rather than Settings because it gives a native
session something deterministic to do:
- open the Timers tab
- backspace until the entry is empty, so the run starts from a known zero
- key
1,0,0on the keypad — it fills from the right, so that is one minute - read the duration back and check it changed, and that backspace is now enabled
- press backspace once and check the duration changed again
They deliberately never start a timer. A running timer survives the session and replaces the setup screen with its card, so a spec that starts one is re-runnable only if it also finishes — an interrupted run would break every later one. Not starting one removes that whole class of failure, and the keypad still exercises what an example is for: real input, real state change, captured.
They also avoid the timer_preset_* chips. Those are recently-used-duration
suggestions rather than fixed controls: a freshly reset Clock offers only the
keypad, so a preset-based flow fails on any device without timer history —
including CI. Two Clock layouts exist on one app version, and the keypad is
common to both; the duration display is not, so the examples read
timer_setup_time when it is there and the separate hour/minute/second fields
otherwise.
VERIFIED ON: Android emulator sdk_gphone64_arm64, Android 16 (API 36),
Clock (com.google.android.deskclock) 9.1. The Clock app updates
independently of the Android version, so pinning a system image does not pin
these resource-ids. If a locator misses, re-read the tree rather than assuming
capture broke:
adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xmlA running countdown never reaches idle, and uiautomator dump fails outright
with ERROR: could not get idle state — so pause or clear the timer first.
Each cost a debugging round when these examples were written, and none of them points at itself:
- Selenium — use
getDomAttribute(), nevergetAttribute(). selenium-webdriver implements the latter by executing a JavaScript atom, and a native session has no JS to run it in; it fails withMethod is not implemented. It also does no implicit wait, so a tap that starts a screen transition needsdriver.wait(until.elementLocated(...))where WebdriverIO auto-waits. - Nightwatch — it rejects
-android uiautomatorwithInvalidSelectorErrorrather than forwarding it, so these use Appium'sidstrategy against a full resource-id. AndfindElements()WAITS and then throwsNoSuchElementErrorwhen nothing matches, which Nightwatch reports as a run error even when caught; the protocol-levelbrowser.elements()returns an empty list instead. - Python — the Appium client is an extra dependency the desktop examples do not need; see below.
| variable | default | meaning |
|---|---|---|
DEVTOOLS_MODE |
trace |
live opens the dashboard and streams; trace writes a zip |
DEVTOOLS_MOBILE |
native |
web drives the device's browser instead of an app — Chrome on Android, Safari on iOS |
APPIUM_APP |
— | path to an .apk/.app to drive instead of Clock |
APPIUM_HOST / APPIUM_PORT |
127.0.0.1 / 4723 |
where Appium is listening |
DEVTOOLS_MOBILE_PLATFORM |
android |
ios runs the iOS spec, in every adapter — see below |
IOS_DEVICE_NAME / IOS_PLATFORM_VERSION |
whichever simulator is booted / — | picks among the BOOTED simulators; an unmatched name is refused, see below |
IOS_UDID |
— | names a device outright, checked against nothing — for a remote or freshly created one |
DEVTOOLS_MOBILE=web pnpm demo:nightwatch:mobile # mobile web, not an app
APPIUM_APP=/tmp/my.apk pnpm demo:wdio:mobile # a real appDEVTOOLS_MOBILE=web needs one extra thing from the server — on Android.
iOS needs nothing: Safari is driven by the XCUITest driver itself. Chrome on the
device needs a matching chromedriver, and the emulator's Chrome is usually
newer than anything installed — the session then fails with No Chromedriver found that can automate Chrome '133.0.6943'. Appium can fetch one, but that is
a server feature, not a capability, so it goes on the appium command:
appium --address 127.0.0.1 --port 4723 \
--allow-insecure=uiautomator2:chromedriver_autodownloadThere is no appium:chromedriverAutodownload capability, despite how often it
is written down — the driver only reads chromedriverExecutable and
chromedriverExecutableDir, so that name is silently ignored. Pointing at a
chromedriver you already have works too:
'appium:chromedriverExecutable': '/path/to/chromedriver'Both targets are worth running, because they take different paths through
capture and the difference is deliberate. A native app has no document, so the
adapters skip every page-side call — the DOM drain, the collector injection,
the per-action element and accessibility scripts, url and title. A mobile
browser session runs on the same phone and does have a page, so it keeps
all of them. Getting that distinction wrong in either direction is a bug
(shared/src/device.ts isNativeAppSession is the one place that decides).
The desktop Python examples need nothing beyond Selenium; an Appium session is built through the Appium client:
pip install -r examples/selenium-py/requirements-mobile.txtDEVTOOLS_MOBILE=web drives Chrome on the same device instead of an app, and it
is worth running: a mobile browser session has a document, so it must keep
every page-side call a native session skips. That contrast is the whole point of
the native guards, and only the web target proves the guards did not go too far.
A mobile-web Appium session used to deadlock — the adapter issued page-side calls from inside the hook wrapping the command being captured, and Appium serialises commands per session, so the wrapped command never reached the browser. That is fixed: per-action snapshots are now skipped only where Appium has a document to probe, which is the webview half of a hybrid app. A mobile browser and a native app are both captured normally.
The traps below are about setup rather than the adapter, and you will hit them on the way.
All three report as the same thing — No Chromedriver found that can automate Chrome 'N' — so they are worth telling apart. Measured against a real
emulator:
-
Autodownload is a server feature, not a capability. Add
--allow-insecure=uiautomator2:chromedriver_autodownloadto theappiumcommand. When the flag is missing the error carries the suffix "You could also try to enable automated chromedrivers download"; when it is present the suffix disappears, which is the only way to tell from the client. -
appium:chromedriverAutodownloaddoes not exist. The driver reads onlychromedriverExecutableandchromedriverExecutableDir. The other spelling is widely copied — this repo had it inexamples/wdio/cucumber/wdio.mobile.conf.tstoo — and it is silently ignored. -
A
sudo npm i -g appiumleaves the download target root-owned. The driver tree under~/.appiumthen belongs to root, autodownload fetches the right chromedriver and fails to unzip it withEACCES, and reports the same "No Chromedriver found". Either fix the ownership:sudo chown -R "$(whoami)" ~/.appium
or give it somewhere writable, which needs no sudo:
CHROMEDRIVER_DIR=/tmp/chromedriver DEVTOOLS_MOBILE=web pnpm demo:wdio:mobile
The default URL is public, and an emulator often cannot resolve public DNS
(corporate network or VPN). 10.0.2.2 is the emulator's alias for this
machine's localhost, so serving a page here is the reliable route:
# --bind and --directory are both deliberate: the default serves the CURRENT
# directory on EVERY interface, so a checkout's contents would be readable by
# anything on the LAN or VPN. The emulator only needs this machine's loopback.
python3 -m http.server 8099 --bind 127.0.0.1 --directory /tmp/mobile-page
DEVTOOLS_MOBILE_URL=http://10.0.2.2:8099/ DEVTOOLS_MOBILE=web pnpm demo:wdio:mobileDEVTOOLS_MOBILE_URL skips the login assertions, which only exist on the
default page, and just navigates and captures.
Measured on a medium_phone AVD, API 36 (Android 16) arm64, Appium 3.7.0 with
uiautomator2 7.6.1:
- The tests pass and the trace is correct.
context-optionscarrieddevice: {platform: 'android', name: 'sdk_gphone64_arm64', version: '16'}and the device's real portrait viewport,1080 × 2400— not the 1280x720 fallback — so the player takes the device-column layout. - The
webtarget was NOT verified on a device. With the flag and a writable driver directory the session is created and chromedriver 133 is fetched, but every command then timed out at 180 s, including the capture's ownexecute/sync. That emulator's networking was independently unhealthy (ping 8.8.8.8returned 50% loss with duplicate packets), so this is unproven rather than broken. The native target on the same emulator works. - The UiAutomator2 instrumentation crashes partway through, and it is not
this repo's doing: it happens with the filmstrip poller off, and once at
session creation (
The instrumentation process cannot be initialized). After it dies everyscreenshotandsourceprobe returnscannot be proxied … the instrumentation process is not running, so the trace ends up with fewer per-action frames than commands. The assertions still pass and the zip is still written. The emulator's own log notes "Guest Angle is still unstable for API > 35", so a lower API image is the thing to try if this matters.
Not failures, and not worth chasing:
- Selenium logs three BiDi warnings per run (
BiDi LogInspector attach failed,BiDi preload unavailable,BiDi NetworkInspector attach failed). The adapter requestswebSocketUrlunconditionally and Appium serves no BiDi, so the attach fails and capture falls back to per-document injection — which is the correct path for a native session anyway. - An inherited
DEVTOOLS_MODE=livechanges what a run produces — a dashboard window and no zip, instead of a zip and no window. That is correct behaviour for live mode and surprising when the variable is left over from an earlier command, so each run now logs the mode it resolved. - Nightwatch's
describe/itinterface collapses per-test slicing to one session-scoped slice, so the config asks forsessiongranularity rather than pretending otherwise. See CLAUDE.md § Known debt.
DEVTOOLS_MOBILE_PLATFORM=ios pnpm demo:wdio:mobileAll four adapters. Each has an android/ and an ios/ spec directory, and
the runner picks between them — no branch inside a spec, because the two
platforms share no selectors.
iOS drives Settings, not Clock, because Clock is not installed on the
simulator at all — xcrun simctl listapps lists Settings, Calendar, Reminders,
Maps and Safari, and no com.apple.mobiletimer. Settings is on every simulator
and every device, which is the same property that makes Clock the Android
choice. The flow navigates into General and back, checking the navigation bar
title each way: the same shape as Android's keypad flow — change state, read it
back — with the app the platform actually ships.
The two are separate specs, not one spec with a branch, because they share no selectors: iOS locators are accessibility ids and labels rather than resource-ids.
examples/wdio/mobile/specs/
├── android/clock.e2e.ts
└── ios/settings.e2e.ts
DEVTOOLS_MOBILE=web works here too, and is cheaper to run than on
Android: it opens Safari on the simulator, which the XCUITest driver drives
itself — no chromedriver to match, nothing to add to the appium command. The
session then has a document, so the page-side capture a native run skips is
back on, which is the contrast the mode exists to show.
Which simulator gets driven is resolved to a udid, never a bare name. Naming
one that does not exist does not fail — the XCUITest driver creates
appiumTest-<uuid>-<name> and boots it, every run, beside the simulator already
running. So the examples default to whichever simulator is already booted, and
an IOS_DEVICE_NAME that matches none of them is refused with the booted list
rather than passed through.
All of that is local policy, and two things opt out of it. IOS_UDID names
a device outright and is checked against nothing: xcrun simctl lists local
simulators and nothing else, so a real device plugged into this machine has no
entry to match. And against a remote or cloud Appium (APPIUM_HOST set to
anything but localhost) an IOS_DEVICE_NAME is passed straight through — naming
a device is how such a service selects one, and the create-it-silently behaviour
that the refusal exists to prevent is the local driver's, not theirs.
What you need, beyond the Android prerequisites (none of which iOS uses):
- Xcode — the Command Line Tools alone ship no simulators:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer sudo xcodebuild -runFirstLaunch
- A simulator runtime, a separate ~8 GB download even once Xcode is in:
xcodebuild -downloadPlatform iOS
- A booted simulator — the preflight checks for one and says so if none:
xcrun simctl list devices available xcrun simctl boot "iPhone 17 Pro" - The XCUITest driver; its first run also builds WebDriverAgent, once:
appium driver install xcuitest
Two things that cost a debugging round each. Appium loads drivers at startup,
so a server that was already running when you installed xcuitest reports
"Could not find a driver for automationName 'XCUITest'" — restart it. And the
navigation bar's back button carries the parent page's title as its
accessibility id, so tapping ~Settings from General opens whichever row shares
that name (measured: it opened About). The spec navigates back through the stack
instead.
VERIFIED ON: iOS Simulator iPhone 17 Pro, iOS 26.5 (23F77), Xcode 26.
- The player uses the device column layout: the phone occupies the full height at the right, with the dock between it and the Actions list. Drag the divider between them to resize.
- The capture is framed in device chrome with the device's name above it, rather than the mock browser window a desktop trace gets.
- The filmstrip thumbnails are portrait.
- Metadata names the device (
Pixel 7 (android 14)) and the viewport the device reported — not1280 × 720. - On a native run with the WDIO service, the A11y tab is built from the
app's own view hierarchy, so it lists
android.widget.*(orXCUIElementType*) nodes rather than HTML roles. Selenium, Nightwatch and Python do not derive one yet and show an empty tree — see Known gaps.
The three JS examples have been run end to end against a stub Appium server — a local HTTP server implementing enough of the W3C protocol to complete a session — so the client-side half is known to work: session creation, the capability bag on the wire, the command path, and the guards. All three pass two tests and make zero page-script calls on a native session.
That is not the same as a device. It says nothing about whether the Clock app
has the views these examples look for, or whether back() behaves, or how a
real screenshot performs. Expect the first real run to need adjusting, and read
a failure as "the device disagreed" rather than "the example is broken".
Both are tracked, and both are visible in these examples rather than hidden:
- Selenium and Nightwatch publish no viewport (#373). The reader then falls
back to
1280 × 720, which is landscape — and the player picks the stacked layout for a landscape capture. So a native trace from those two adapters does not get the device column until that is fixed. WDIO and Python do. - A native session gets no accessibility tree from Selenium, Nightwatch or Python (#372). Only the WDIO service derives one from the app's page source.