@pwtap/plugin-appium
v1.2.0
Published
Mobile testing for Playwright via Appium — a raw WebdriverIO session (app: WebdriverIO.Browser) over Android + iOS simulator, macOS-first
Readme
@pwtap/plugin-appium
Mobile testing for the Playwright Test Automation Platform via Appium — Android (UiAutomator2) + iOS simulator (XCUITest), macOS-first. One app fixture: a raw WebdriverIO session, no curated facade on top.
Install
Into a @pwtap project (wires the fixture, an env-gated appium project, env keys, and examples):
npx create-pwtap add appiumThe app fixture
import { test, expect } from '@fixtures';
import { devices } from '@pwtap/plugin-appium';
test.use({ appium: devices.android }); // or { platform: 'android', device: 'Pixel_API_35' }
test('sign in', async ({ app }) => {
await app('~Login').click();
await app('~Username').setValue('John Doe');
const dashboard = app('~Dashboard');
await expect.poll(() => dashboard.isDisplayed()).toBe(true);
});app is callable as a selector shorthand — app('~Login') is app.$('~Login') — and still the full
WebdriverIO Browser otherwise: every protocol command and
element method is available directly on it ($$, execute('mobile: ...'), saveScreenshot, …).
Unlike @pwtap/plugin-maestro, there is no per-command step reporting — wrapping raw WebdriverIO
calls one-by-one isn't worth the maintenance cost of proxying its full API, so wrap your own
test.step() around a logical action if you want that in the report. WebdriverIO elements don't
auto-wait like Playwright locators either — expect.poll(...) gives you the same retry-until-true
behavior.
Installing an app under test: pass app (a local path or http(s) URL to an APK / iOS .app/.zip)
in the appium option, or set APPIUM_APP_ANDROID/APPIUM_APP_IOS — it becomes the appium:app
capability, so the driver installs it during session creation (no separate adb/simctl step). For
a built-in app (e.g. the Settings/Preferences example), skip app and target it directly via the
capabilities escape hatch (appium:appPackage/appium:appActivity on Android, appium:bundleId on
iOS) — see the scaffolded tests/appium/settings.appium.ts.
Appium API cookbook
test('appium api cookbook', async ({ app, device }) => {
await app('~Login').click();
await app('~Username').setValue('John Doe');
await app('~Password').setValue('secret');
await app('~Submit').click();
const row = app('~Order row');
await row.waitForDisplayed({ timeout: 10_000 });
await expect.poll(() => row.isDisplayed()).toBe(true);
const items = await app.$$('android=new UiSelector().className("android.widget.TextView")');
await expect(items.length).toBeGreaterThan(0);
if (device.platform === 'android') {
await app.execute('mobile: scrollGesture', {
left: 0,
top: 0,
width: 300,
height: 700,
direction: 'down',
percent: 0.7,
});
} else {
await app.execute('mobile: scroll', { direction: 'down' });
}
});Locators
Priority order (fastest/most stable first): accessibility ID (~loginButton, works on both
platforms), Android UiAutomator (android=new UiSelector().text("Login")), iOS Predicate String
(-ios predicate string:label == "Login") or Class Chain, then XPath as a last resort — it's the
slowest strategy and the most brittle across app updates:
const submit =
device.platform === 'android'
? app('android=new UiSelector().text("Submit")')
: app('-ios predicate string:label == "Submit"');
await submit.waitForDisplayed({ timeout: 10_000 });
await submit.click();See docs/APPIUM_TESTING.md (scaffolded into your project) for the full locator reference, waiting
patterns, and gesture commands (execute('mobile: scrollGesture' | 'mobile: swipe', {...})).
Locator strategy cheat sheet
| Priority | Strategy | Best use case | Notes |
| -------- | --------------------------- | -------------------------------------------- | ----------------------------------------------------- |
| 1 | ~accessibilityId | Cross-platform, stable UI controls | Fastest + most maintainable; ask devs for stable IDs. |
| 2 | id=... / resource-id | Android elements with stable unique IDs | Good fallback when accessibility IDs are missing. |
| 3 | android=UiSelector(...) | Android-only, rich attribute filtering | Prefer short selectors over long chained trees. |
| 4 | -ios predicate string:... | iOS-only attribute queries | Usually faster and cleaner than XPath. |
| 5 | -ios class chain:... | iOS hierarchical matching with better perf | Use when predicate is not enough. |
| 6 | XPath | Last resort for hard-to-reach legacy screens | Most brittle/slow; avoid absolute deep paths. |
Recommended flow: start with ~accessibilityId, then platform-specific selectors, use XPath only if
you have no stable alternative.
Running
npm run test:appium # runs Appium tests + auto-generates test-results/appium-report/index.html
npm run report:appium # regenerates only the Appium HTML report from existing results.jsonreport:appium is Appium-focused (separate from Playwright HTML): it summarizes Appium-only tests,
diagnostics attachments, and server-command/error counts.
A bare npm test stays UI + API — the appium project is gated behind APPIUM=1.
The Appium server
By default the fixture spawns the appium CLI itself, one server per Playwright worker
(4723 + workerIndex), and waits for GET /status to report ready. Point at a server you manage
yourself instead with APPIUM_SERVER_URL (its lifecycle is then your responsibility). Override the
binary with APPIUM_BIN if appium isn't the right name on PATH.
Parallel (the device pool)
The appium project is fullyParallel, and each test reserves its device with a cross-process lock
(<platform>:<device>). That pairing is the device pool: tests on the same device serialize
(they wait, not skip); tests on different devices or platforms run concurrently:
APPIUM=1 npx playwright test --project=appium --workers=3Devices
Select with test.use({ appium }): a named device (Android AVD / iOS simulator name or UDID)
auto-boots if not running; omit it to use any booted device. When no matching device is available
the test skips (never fails). Create a device via Android Studio's AVD Manager / Xcode's Simulator
app, or run:
npm run mobile:create-deviceThe script appends the created device alias into both plugin devices catalogs, so the same
alias can be referenced from Appium and Maestro test blocks.
Devices the framework auto-booted are shut down automatically after the run by the
appium-teardown project (headed or headless) — set APPIUM_KEEP_DEVICES=1 to keep them for faster
reruns. Devices you booted yourself are left running. This registry is shared with
@pwtap/plugin-maestro — running both plugins in one project still shuts every auto-booted device
down exactly once.
Evidence — video, screenshot, device log
Screen recording and screenshots aren't mobile-specific settings — this fixture reads Playwright's
own built-in video/screenshot options (use.video/use.screenshot in playwright.config.ts, or a
project/describe override), so one central setting controls both for chromium and appium alike:
all seven video modes (off / on / retain-on-failure / on-first-retry / on-all-retries /
retain-on-first-failure / retain-on-failure-and-retries), and all four screenshot modes
(off / on / only-on-failure / on-first-failure) — captured once at test end (Appium has no
per-command concept like Maestro's step screenshots):
// playwright.config.ts
use: { video: 'retain-on-failure', screenshot: 'only-on-failure' }, // now applies to appium tooAPPIUM_DEVICE_LOG=1 attaches the device's own system log for the whole test (Android logcat,
iOS the unified system log) — off by default.
AI judge example (visual assertion)
If @pwtap/plugin-ai-judge is installed, you can assert a mobile screen with rubric scoring:
import { test, expect } from '@fixtures';
test('home screen passes ai rubric', async ({ app }, testInfo) => {
const shot = testInfo.outputPath('home.png');
await app.saveScreenshot(shot);
await expect({
image: shot,
rubric: 'Home screen shows "Welcome", primary CTA is visible, and no error banner is present.',
}).toPassRubric({ minScore: 80 });
});Reference-image comparison is also supported:
await expect({ image: shot }).toMatchImage('testData/baselines/home.png', { minScore: 90 });Environment variables (quick reference)
| Key | One-line description |
| --------------------------------------- | ---------------------------------------------------------------------- |
| APPIUM_PLATFORM | Default platform for tests that do not set test.use({ appium }). |
| APPIUM_DEVICE | Default device alias/UDID when test-level device is not set. |
| APPIUM_HEADLESS | Controls emulator/simulator visibility (true hidden, false shown). |
| APPIUM_APP_ANDROID / APPIUM_APP_IOS | Default app artifact path/URL applied to appium:app. |
| APPIUM_SERVER_URL | Reuses an externally managed Appium server instead of spawning one. |
| APPIUM_DEVICE_LOG | Attaches device OS log (logcat / log show) for each test. |
| APPIUM_DIAGNOSTICS | Controls diagnostics bundle (off, fail, always). |
| APPIUM_KEEP_DEVICES | Keeps framework-booted devices alive after the run. |
| APPIUM_BIN | Custom Appium CLI binary path/name instead of default appium. |
Requirements
- Appium CLI (
npm install -g appium) + theuiautomator2/xcuitestdrivers (appium driver install uiautomator2/xcuitest). - Android: Android SDK (
ANDROID_HOME) + an emulator. iOS: Xcode + a simulator (simulator-only; real iOS devices are not yet supported). - Node ≥ 22.23.
create-pwtap add appiumruns an advisory host check for these.
License
MIT
