An Appium + WebdriverIO end-to-end automation framework for iOS and Android, in TypeScript.
This is the framework only — configs, a page-object base, cross-cutting helpers, tooling
and conventions. It ships no page objects, specs or test cases for any particular app; those
are yours to add. It was extracted from a production mobile suite: where a comment explains
why something is shaped oddly, it is recording a debugging cycle somebody already paid for.
The long-form versions are in docs/ARCHITECTURE.md.
| Three wdio configs | iOS local (simulator and real device, one file), iOS real-device cloud, Android — all sharing one base so they cannot drift. |
| Page-object base class | wait → act built in, so no page object has to remember to wait. |
| Waiting primitives | waitForValue, race, withoutRetries — the snapshot-vs-poll trap, solved once. |
| iOS alert handling | For runs where autoAcceptAlerts has to be off. |
| StoreKit sandbox purchases | App-agnostic driver for Apple's payment sheet, which XCUITest cannot see. |
| Feature-flag pinning | Proxy Map Local + app restart, so a spec gets the variant it was written for. |
| Secret masking | Credentials typed into the app never reach a log file. |
| Pre-flight gating | Fail on a dead test account before leasing a device for fifteen minutes. |
| Real-device hygiene | Orphaned-WebDriverAgent cleanup, preinstalled-WDA support, build provenance stamps. |
| Live-device inspection | The official Appium MCP server, capabilities generated from the same .env — so locators get discovered, not guessed. |
| Reporting | Allure + spec reporter, failure screenshots named after the case, a Markdown summary for CI. |
| Conventions | A test-case catalogue that maps 1:1 to specs, and a Claude Code agent that follows it. |
- Node 22 (
.nvmrc) - Xcode + Command Line Tools (iOS), Android SDK platform-tools (Android)
- Appium 3 drivers:
xcuitest,uiautomator2—npm run doctorchecks both
npm install # or npm ci — the lockfile is committed
cp .env.example .env # fill in APP_NAME, IOS_BUNDLE_ID, device details
npm run doctor # verify the Appium drivers
npm run typecheck && npm run lint # should both be silentThen wire up your app:
- Identify it. Set
APP_NAME,IOS_BUNDLE_ID,ANDROID_APP_PACKAGE,ANDROID_APP_ACTIVITYin.env. Nothing else hardcodes the app — seeconfig/app.config.ts. - Supply a binary. Drop an
.ipa/.apkinapps/, or build a simulator app withnpm run refresh:ios-sim-app(setAPP_SOURCE_DIRfirst). With no binary, Appium launches whatever is already installed. - Write your first screen. Copy
templates/example.page.tsintotest/ios/pageobjects/<feature>/. Don't guess selectors — read them off the device, see Inspecting a live device. - Write its case, then its spec.
templates/example.testcase.md→testcases/ios/<name>.md, thentemplates/example.spec.ts→test/ios/specs/<name>.spec.ts. Same name on both sides. - Add pre-flight checks for whatever makes a run unrunnable — see
test/checks.ts.
| Target | Command |
|---|---|
| iOS simulator, one spec | npm run wdio:ios:sim -- --spec test/ios/specs/<name>.spec.ts |
| iOS real device, one spec | npm run wdio:ios -- --spec test/ios/specs/<name>.spec.ts |
| iOS real device, everything | npm run wdio:ios |
| iOS cloud (real device) | npm run wdio:ios:cloud |
| Android | npm run wdio:android |
| Pre-flight only | npm run preflight |
| Typecheck | npm run typecheck |
| Lint (before finishing) | npm run lint |
| Failure summary | npm run summary |
Non-obvious requirements:
- A real device needs the RemoteXPC tunnel up:
sudo node_modules/.bin/appium driver run xcuitest tunnel-creation. - Simulator runs need a built
.appinapps/(npm run refresh:ios-sim-app). npm run wdio:ios:simwith no arguments runs--suite simulator, and that suite ships empty — fill insuites.simulatorinwdio.ios.conf.tswith the specs that pass without a real device, or the run exits with "No specs found".- Any spec with a one-way side effect — a real purchase, an account deletion — goes in
test/<platform>/manual/, notspecs/. The configs' spec glob does not reach that directory, so a suite run can never trigger it, and you run it by hand:npm run wdio:ios -- --spec test/ios/manual/<name>.spec.ts. Never loop one to "try again": nothing reverts it, and a human has to reset state before the next run.
Never pipe a raw wdio log into your terminal or an agent's context — one real-device run
is 6,000+ lines. Redirect it to a file, read the summary, then grep for the [Prefix] you
care about.
npm run wdio:ios -- --spec test/ios/specs/foo.spec.ts > run.log 2>&1
npm run summary
grep -E '\[ApplePay\]|\[alert\]|\[skip\]' run.logCI is .github/workflows/ios-cloud-tests.yml — workflow_dispatch only, because a cloud
real-device run costs device minutes and can mutate shared account state. It typechecks,
lints, runs the cloud suite, and publishes the Allure report to GitHub Pages.
.mcp.json wires up the official Appium MCP server,
so an agent working in this repo can drive a real device or simulator and read the actual
element tree instead of guessing a selector.
- Capabilities come from your
.env, generated byscripts/write-mcp-capabilities.tson every server launch. There is no second place to configure the app under test. - The session attaches to the installed build and does not reset it — reinstalling would
wipe the state you wanted to look at.
MCP_INSTALL_APP=trueif you do want an install. autoAcceptAlertsis off there, the reverse of a test run, so the dialogs a run would swallow stay on screen where you can read them.- First launch downloads the server through
npxand takes a few minutes; after that it is cached. Claude Code will ask you to trust the project server once —/mcplists it.
Screenshots the session takes land in mcp-captures/ — deliberately not test-results/,
which a test run wipes on start.
npm run mcp:capabilities writes the capabilities file on its own if you want to check what
the session will get.
wdio.ios.conf.ts iOS local — simulator and real device
wdio.ios.cloud.conf.ts iOS on a real-device cloud (LambdaTest / TestMu)
wdio.android.conf.ts Android emulator or device
config/
app.config.ts the only place the app under test is identified
wdio.shared.conf.ts everything the three configs share
test/
checks.ts your pre-flight checks (ships empty)
helpers/ cross-cutting mechanics (+ its own CLAUDE.md)
ios/pageobjects/ one class per screen
ios/specs/ one spec per test-case file
ios/debug/ throwaway debug specs — outside the spec glob on purpose
ios/manual/ one-way side effects (purchases, deletions) — run by hand only
android/… same shape
preflight/ the checks that need a driver, run via `npm run preflight`
fixtures/featureFlags/ written at runtime, served by your proxy
fixtures/mcp/ capabilities for the MCP server, generated from .env
testcases/
ios/ android/ Gherkin cases, 1:1 with specs — see its README and CLAUDE.md
templates/ copy-and-adapt page object, spec, and case file
scripts/
run-ios-sim.sh detect a booted sim, override real-device vars for one run
refresh-ios-sim-app.sh build the app for the simulator, stamp its provenance
preflight.ts run the pre-flight checks without running a flow
summarize-allure.ts pass/fail table + failure messages (also a CI job summary)
appium-mcp.sh launch the MCP server with capabilities built from .env
write-mcp-capabilities.ts
upload-ios-app-to-cloud.ts
mcp-captures/ screenshots from an MCP inspection session (git-ignored)
apps/ the .ipa / .apk / .app under test (git-ignored)
.env.example every variable the framework reads, with what it is for
.github/workflows/ cloud run on workflow_dispatch, Allure report to Pages
.mcp.json the Appium MCP server, for live-device inspection
tsconfig.json strict, NodeNext, no emit — tsx runs the sources
eslint.config.js flat config, typescript-eslint
docs/ARCHITECTURE.md layering, the four guardrails, iOS pitfalls
CLAUDE.md the same ground, condensed for an agent in this repo
- TypeScript throughout, run directly by
tsx— there is no build step. Relative imports still carry a.jsextension (./constants.jsresolves toconstants.ts); that is whatmoduleResolution: NodeNextrequires.tscis only ever a checker here (--noEmit). - TypeScript is pinned to
~6.0.3, not^: typescript-eslint's peer range is<6.1.0, so a caret would pull in a version it refuses to run against. - 4-space indent, single quotes, semicolons.
npm run typecheckandnpm run lintmust both be clean. - Page objects: one class per screen, default-exported as a singleton, in
test/<platform>/pageobjects/<feature>/. No assertions, no cross-screen flows. - Locator priority: accessibility id → predicate / resource-id → class chain → xpath (last resort).
- Timeouts come from
test/helpers/constants.ts. Don't hardcode. - Specs and test-case files are same-named and 1:1. Each
it()title is quoted verbatim in its case's**Automated:**line. - Throwaway debug specs go in
test/<platform>/debug/<name>.spec.ts— outside the configs' spec globs, so a suite run never picks them up while--specstill can. They carry a comment saying what question they exist to answer, and are deleted once answered.
docs/ARCHITECTURE.md— the layering, the four guardrails that cost real debugging cycles, and the known iOS pitfalls. Read this before writing helpers.testcases/README.md— the test-case house style, including the honest-assertion rule.CLAUDE.md— the same ground, condensed for an agent working in this repo.testcases/andtest/helpers/carry their own, loaded when you work in them.