Skip to content

Latest commit

 

History

History
451 lines (364 loc) · 22.1 KB

File metadata and controls

451 lines (364 loc) · 22.1 KB

Mobile examples

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:mobile

DEVTOOLS_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:mobile

The dashboard starts itself — the adapter does it (ensureBackendStarted in the JS adapters, enable() in Python). Nothing needs a backend started by hand.

What you need

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.

  1. 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-cli in ~/.android/bin (not on your PATH) and an SDK root that it does not necessarily report correctly: android-cli info said ~/Library/Android/sdk on the machine this was written on while the actual platform-tools, emulator and system-images were in /opt/homebrew/share/android-commandlinetools. The directory holding platform-tools is the one to export, whatever info claims:

    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_HOME and 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_phone also works and waits for boot, but the raw emulator binary 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.

  2. 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.

What the examples drive

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:

  1. open the Timers tab
  2. backspace until the entry is empty, so the run starts from a known zero
  3. key 1, 0, 0 on the keypad — it fills from the right, so that is one minute
  4. read the duration back and check it changed, and that backspace is now enabled
  5. 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.xml

A running countdown never reaches idle, and uiautomator dump fails outright with ERROR: could not get idle state — so pause or clear the timer first.

Three per-adapter details worth knowing

Each cost a debugging round when these examples were written, and none of them points at itself:

  • Selenium — use getDomAttribute(), never getAttribute(). selenium-webdriver implements the latter by executing a JavaScript atom, and a native session has no JS to run it in; it fails with Method is not implemented. It also does no implicit wait, so a tap that starts a screen transition needs driver.wait(until.elementLocated(...)) where WebdriverIO auto-waits.
  • Nightwatch — it rejects -android uiautomator with InvalidSelectorError rather than forwarding it, so these use Appium's id strategy against a full resource-id. And findElements() WAITS and then throws NoSuchElementError when nothing matches, which Nightwatch reports as a run error even when caught; the protocol-level browser.elements() returns an empty list instead.
  • Python — the Appium client is an extra dependency the desktop examples do not need; see below.

Choosing what to drive

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 app

DEVTOOLS_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_autodownload

There 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).

Python needs one extra package

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.txt

What the web target exercises

DEVTOOLS_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.

The web target has three separate traps

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:

  1. Autodownload is a server feature, not a capability. Add --allow-insecure=uiautomator2:chromedriver_autodownload to the appium command. 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.

  2. appium:chromedriverAutodownload does not exist. The driver reads only chromedriverExecutable and chromedriverExecutableDir. The other spelling is widely copied — this repo had it in examples/wdio/cucumber/wdio.mobile.conf.ts too — and it is silently ignored.

  3. A sudo npm i -g appium leaves the download target root-owned. The driver tree under ~/.appium then belongs to root, autodownload fetches the right chromedriver and fails to unzip it with EACCES, 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

And the emulator has to reach the page

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:mobile

DEVTOOLS_MOBILE_URL skips the login assertions, which only exist on the default page, and just navigates and captures.

Observed on a real emulator

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-options carried device: {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 web target 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 own execute/sync. That emulator's networking was independently unhealthy (ping 8.8.8.8 returned 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 every screenshot and source probe returns cannot 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.

Expected noise

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 requests webSocketUrl unconditionally 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=live changes 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/it interface collapses per-test slicing to one session-scoped slice, so the config asks for session granularity rather than pretending otherwise. See CLAUDE.md § Known debt.

iOS

DEVTOOLS_MOBILE_PLATFORM=ios pnpm demo:wdio:mobile

All 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):

  1. Xcode — the Command Line Tools alone ship no simulators:
    sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
    sudo xcodebuild -runFirstLaunch
  2. A simulator runtime, a separate ~8 GB download even once Xcode is in:
    xcodebuild -downloadPlatform iOS
  3. A booted simulator — the preflight checks for one and says so if none:
    xcrun simctl list devices available
    xcrun simctl boot "iPhone 17 Pro"
  4. 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.

What to look for

  • 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 — not 1280 × 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.* (or XCUIElementType*) nodes rather than HTML roles. Selenium, Nightwatch and Python do not derive one yet and show an empty tree — see Known gaps.

What has been verified, and what has not

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".

Known gaps

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.