Run Appium tests
Your test code stays the same; only the driver setup and the capabilities change. iOS needs a Limrun fork of the XCUITest driver, while Android works with any upstream Appium driver over an ADB tunnel.
Before you start
- The
limCLI andLIM_API_KEYset up as in the Quickstart. - Node 20 or newer to run the Appium server.
- An Appium test that already runs against a local device.
How the integration works
Each platform takes a different route to the same Appium API.
iOS uses @limrun/appium-xcuitest-driver, a fork of the upstream XCUITest driver. The fork keeps the public driver protocol identical to upstream but redirects three things that upstream assumes are local:
xcrun simctlcalls, forwarded to the macOS host running your simulator.- File copies, uploaded through the Limrun API instead of writing to disk.
- Safari debugging, proxied through a tunnel because upstream scans local UNIX sockets under
/tmp/.
WebDriverAgent itself is upstream. You install it on the instance as a normal app, and the driver talks to it over an authenticated HTTPS tunnel.
Android uses any upstream Appium driver (UiAutomator2, Espresso, Flutter) connected through an ADB tunnel opened by the Limrun SDK or CLI. Once adb devices shows the remote emulator on 127.0.0.1, Appium treats it exactly like a USB-attached device.
Test code is portable: the same webdriverio or appium-python-client script that runs against a local simulator runs against a Limrun instance with only the capabilities changed.
Run an iOS test
iOS needs more setup than Android: install a forked driver, provision an instance with WebDriverAgent on it, build the URL Appium uses to reach WDA, then fill in the capabilities. If you only need Android, skip to Run an Android test.
Install Appium and the driver
npm i --location=global appium
appium driver install --source npm @limrun/[email protected]Start the Appium server in another terminal:
appiumThe fork registers itself as @limrun/xcuitest (both driverName and automationName), so it coexists with the upstream xcuitest driver. Pick it via appium:automationName in your capabilities.
Provision an instance with WebDriverAgent
WebDriverAgent isn't on a fresh instance by default. Hand the platform a download URL for the WDA build when you create the instance, and the platform will install it and auto-launch it before create returns:
import { Limrun, Ios } from '@limrun/api';
const limrun = new Limrun({ apiKey: process.env.LIM_API_KEY });
// On success, WDA is installed and running on the simulator.
const instance = await limrun.iosInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { name: 'appium-ios' } },
spec: {
initialAssets: [
{
kind: 'App',
source: 'URL',
url: 'https://github.com/appium/WebDriverAgent/releases/download/v10.4.5/WebDriverAgentRunner-Build-Sim-arm64.zip',
launchMode: 'ForegroundIfRunning',
},
],
},
});import os
from limrun_api import Limrun
limrun = Limrun(api_key=os.environ["LIM_API_KEY"])
instance = limrun.ios_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"name": "appium-ios"}},
spec={
"initial_assets": [
{
"kind": "App",
"source": "URL",
"url": "https://github.com/appium/WebDriverAgent/releases/download/v10.4.5/WebDriverAgentRunner-Build-Sim-arm64.zip",
"launch_mode": "ForegroundIfRunning",
},
],
},
)import (
"context"
"os"
limrun "github.com/limrun-inc/go-sdk"
"github.com/limrun-inc/go-sdk/option"
"github.com/limrun-inc/go-sdk/packages/param"
)
lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
instance, err := lim.IosInstances.New(context.TODO(), limrun.IosInstanceNewParams{
Wait: param.NewOpt(true),
ReuseIfExists: param.NewOpt(true),
Metadata: limrun.IosInstanceNewParamsMetadata{
Labels: map[string]string{"name": "appium-ios"},
},
Spec: limrun.IosInstanceNewParamsSpec{
InitialAssets: []limrun.IosInstanceNewParamsSpecInitialAsset{
{
Kind: "App",
Source: "URL",
URL: param.NewOpt("https://github.com/appium/WebDriverAgent/releases/download/v10.4.5/WebDriverAgentRunner-Build-Sim-arm64.zip"),
LaunchMode: "ForegroundIfRunning",
},
},
},
})The Python SDK covers instance operations (create, list, get, delete) but has no device client, so the WDA health check below is in TypeScript and Go. The Appium session itself runs in whatever language your test code is in.
reuseIfExists returns an existing instance only when its labels match exactly, so give each CI job or dev session its own label set. See Concepts for how reuse and placement interact.
When the call resolves, three fields on the returned instance object feed into Appium over the next few sections:
instance.status.apiUrl, the instance API URL. The Limrun device client talks to it, and the Appium driver uses it forsimctlcalls and file uploads.instance.status.token, the instance token. Every URL on this instance authenticates with it, including WDA.instance.status.targetHttpPortUrlPrefix, an HTTPS prefix that proxies to any port inside the simulator. The next section uses it to build the URL Appium hits on WDA's port 8100.
For production, push the WDA .zip to Asset Storage once and reference it by name, instead of pulling from GitHub on every run.
Point Appium at WDA on the instance
Appium drives iOS by sending HTTP requests to WebDriverAgent. When WDA runs on your laptop, Appium talks to http://localhost:8100. With Limrun, WDA is on a Mac in the cloud, so you need a public URL that reaches port 8100 inside the simulator.
targetHttpPortUrlPrefix from the previous section is exactly that. To reach a specific port on the simulator, append the port number directly to the prefix, with no slash. WDA at port 8100 lives at <prefix>8100. There's one more quirk: Appium's URL parser needs an explicit port on the hostname, otherwise it builds invalid URLs when it makes follow-up requests. Insert :443 (the HTTPS port) into the hostname:
// Before: https://<host>.limrun.net/v1/ios_usea_01.../targetHttpPort
// After: https://<host>.limrun.net:443/v1/ios_usea_01.../targetHttpPort8100
const wdaUrl =
instance.status.targetHttpPortUrlPrefix!.replace('limrun.net', 'limrun.net:443') + '8100';import "strings"
// Before: https://<host>.limrun.net/v1/ios_usea_01.../targetHttpPort
// After: https://<host>.limrun.net:443/v1/ios_usea_01.../targetHttpPort8100
wdaURL := strings.Replace(instance.Status.TargetHTTPPortURLPrefix, "limrun.net", "limrun.net:443", 1) + "8100"Pass wdaUrl to the driver as the appium:webDriverAgentUrl capability, and set appium:wdaLocalPort: 443 so the two agree.
WDA can crash during long runs, and the auto-launch you set up at create time only fires on first install. Before each session, ping WDA's /status endpoint with the instance token. If it doesn't answer cleanly, relaunch WDA with simctl through the Limrun device client:
// The device client runs `simctl` on the simulator's host and reports the
// UDID. The Appium session itself goes through the WDA URL, not this client.
const ios = await Ios.createInstanceClient({
apiUrl: instance.status.apiUrl!,
token: instance.status.token!, // absent for read-only API keys and viewer seats
});
// Probe WDA's /status. A healthy WDA returns 200 with a JSON body.
const ok = await fetch(`${wdaUrl}/status`, {
headers: { Authorization: `Bearer ${instance.status.token}` },
signal: AbortSignal.timeout(3000),
}).then((r) => r.ok).catch(() => false);
// If WDA is gone, relaunch it.
if (!ok) {
await ios.simctl(['launch', 'booted', 'com.facebook.WebDriverAgentRunner.xctrunner']).wait();
}
// Keep `ios` open for the capabilities below; call ios.disconnect() when the suite ends.import (
"context"
"net/http"
"os"
iosws "github.com/limrun-inc/go-sdk/websocket/ios"
)
// Device client for the simulator. Used only to call `simctl`.
client, err := iosws.NewClient(instance.Status.APIURL, instance.Status.Token)
if err != nil { panic(err) }
defer client.Close()
// Probe WDA's /status with the instance token in the Authorization header.
req, _ := http.NewRequest("GET", wdaURL+"/status", nil)
req.Header.Set("Authorization", "Bearer "+instance.Status.Token)
resp, err := http.DefaultClient.Do(req)
ok := err == nil && resp.StatusCode == 200
if resp != nil { resp.Body.Close() }
// If WDA is gone, relaunch it on the booted simulator.
if !ok {
cmd := client.Simctl(context.TODO(), "launch", "booted", "com.facebook.WebDriverAgentRunner.xctrunner")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}iOS capabilities
Pick your Appium client below. The capability keys are the same in every client; only the surrounding code differs.
import { remote } from 'webdriverio';
const driver = await remote({
hostname: '127.0.0.1',
port: 4723,
path: '/',
capabilities: {
platformName: 'iOS',
browserName: 'safari', // omit for a native app session
'appium:automationName': '@limrun/xcuitest',
'appium:udid': ios.deviceInfo.udid,
'appium:webDriverAgentUrl': wdaUrl,
'appium:wdaLocalPort': 443,
'appium:usePreinstalledWDA': true,
'appium:useNewWDA': false,
'appium:noReset': true, // keep WDA installed across sessions
'appium:fullReset': false,
'appium:limInstanceApiUrl': instance.status.apiUrl,
'appium:limInstanceToken': instance.status.token,
'appium:wdaRequestHeaders': {
Authorization: `Bearer ${instance.status.token}`,
},
},
});import json
import subprocess
from appium import webdriver
from appium.options.common import AppiumOptions
# `instance` is the create response from the Python SDK above.
api_url = instance.status.api_url
token = instance.status.token # absent for read-only API keys and viewer seats
wda_url = instance.status.target_http_port_url_prefix.replace('limrun.net', 'limrun.net:443') + '8100'
# The Python SDK has no device client, so read the simulator UDID through the CLI.
info = subprocess.run(['lim', 'ios', 'info', '--id', instance.metadata.id, '--json'],
check=True, capture_output=True, text=True)
udid = json.loads(info.stdout)['udid']
opts = AppiumOptions()
opts.set_capability('platformName', 'iOS')
opts.set_capability('browserName', 'safari') # omit for a native app session
opts.set_capability('appium:automationName', '@limrun/xcuitest')
opts.set_capability('appium:udid', udid)
opts.set_capability('appium:webDriverAgentUrl', wda_url)
opts.set_capability('appium:wdaLocalPort', 443)
opts.set_capability('appium:usePreinstalledWDA', True)
opts.set_capability('appium:useNewWDA', False)
opts.set_capability('appium:noReset', True) # keep WDA installed across sessions
opts.set_capability('appium:fullReset', False)
opts.set_capability('appium:limInstanceApiUrl', api_url)
opts.set_capability('appium:limInstanceToken', token)
opts.set_capability('appium:wdaRequestHeaders', {'Authorization': f'Bearer {token}'})
driver = webdriver.Remote('http://127.0.0.1:4723', options=opts)The Limrun-specific capabilities:
| Capability | Required | What it does |
|---|---|---|
appium:limInstanceApiUrl | yes | The instance API URL (instance.status.apiUrl). The fork uses it for simctl, file uploads, and Safari socket tunneling. |
appium:limInstanceToken | yes | The instance token (instance.status.token). Authenticates the URL above. |
appium:limLogLevel | no | One of none, error, warn, info, debug. Defaults to info. |
appium:wdaRequestHeaders | yes | Extra HTTP headers added to every WDA call. Use it to send the instance token. |
appium:webDriverAgentUrl | yes | The WDA URL built above (the port gateway URL with :443 inserted, plus 8100). |
appium:wdaLocalPort | yes | Must be 443 to match the WDA URL. |
appium:usePreinstalledWDA | yes | Tells the driver to attach to the WDA you already launched, instead of building its own. |
appium:noReset | recommended | Stops Appium from wiping WDA between sessions. |
Everything else is standard XCUITest: appium:bundleId for a native app, browserName: 'safari' for the browser context, locator strategies, contexts, all unchanged.
A minimal Safari test
Once the driver is connected, drive Safari the same way you would locally. The example below opens Hacker News, switches into the WebView context so CSS selectors work, and clicks the "More" link:
// Tell the open Safari tab to navigate. `executeScript` runs JS in the current page;
// `arguments[0]` is the first item we pass in the second argument.
await driver.executeScript(
'window.location.assign(arguments[0])',
['https://news.ycombinator.com/'],
);
// Appium starts every session in the native context (NATIVE_APP). To use CSS or
// XPath against the page DOM, switch into a WEBVIEW context. There can be more
// than one, so we pick whichever WEBVIEW is currently available.
const contexts = await driver.getContexts();
const webview = contexts.find((c) => String(c).includes('WEBVIEW'));
if (!webview) throw new Error('WEBVIEW context not found');
await driver.switchContext(webview as string);
// Now selectors target the DOM. `driver.$` is webdriverio's element lookup.
await driver.$('a.morelink').click();
// End the session so WDA reclaims its state. The instance keeps running
// (and billing) until you delete it or its inactivity timeout fires.
await driver.deleteSession();For a native-app session, install the app first (either at instance create through initialAssets or after boot through installApp), drop browserName, and add appium:bundleId:
await ios.installApp('https://example.com/builds/MyApp.app.zip');
const driver = await remote({
/* ... host/port/path as above ... */
capabilities: {
platformName: 'iOS',
'appium:automationName': '@limrun/xcuitest',
'appium:udid': ios.deviceInfo.udid,
'appium:bundleId': 'com.example.MyApp',
'appium:webDriverAgentUrl': wdaUrl,
'appium:wdaLocalPort': 443,
'appium:usePreinstalledWDA': true,
'appium:noReset': true,
'appium:limInstanceApiUrl': instance.status.apiUrl,
'appium:limInstanceToken': instance.status.token,
'appium:wdaRequestHeaders': { Authorization: `Bearer ${instance.status.token}` },
},
});
await driver.$('~startButton').click(); // accessibility id locatorThe full runnable example is at typescript-sdk/examples/appium-ios.
Run an Android test
Android is simpler: open an ADB tunnel, then use any upstream Appium driver. UiAutomator2, Espresso, and Flutter drivers all work unchanged.
Open the ADB tunnel
Create an emulator and open an ADB tunnel to it. The CLI does both in one step; the SDKs open the tunnel after the create call returns:
# `create` waits for the instance to be ready and opens an ADB tunnel by default.
# `--no-connect` skips the tunnel; `connect` opens one against an existing instance.
lim android create
# The tunnel keeps running in the background.
adb devices
# 127.0.0.1:<port> device ← port is chosen at runtime, e.g. 127.0.0.1:53412import { Limrun, createInstanceClient } from '@limrun/api';
const limrun = new Limrun({ apiKey: process.env.LIM_API_KEY });
const instance = await limrun.androidInstances.create({ wait: true });
const client = await createInstanceClient({
apiUrl: instance.status.apiUrl!,
adbUrl: instance.status.adbWebSocketUrl!,
token: instance.status.token!, // absent for read-only API keys and viewer seats
});
// After this, `adb devices` sees the emulator and Appium can attach.
const { address, close } = await client.startAdbTunnel();
console.log(`adb connected on ${address.address}:${address.port}`);
// `close()` tears down the tunnel when your tests finish.import (
"context"
"log"
"os"
limrun "github.com/limrun-inc/go-sdk"
"github.com/limrun-inc/go-sdk/option"
"github.com/limrun-inc/go-sdk/packages/param"
"github.com/limrun-inc/go-sdk/tunnel"
)
lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
instance, err := lim.AndroidInstances.New(context.TODO(), limrun.AndroidInstanceNewParams{
Wait: param.NewOpt(true),
})
if err != nil { panic(err) }
// After this, `adb devices` sees the emulator and Appium can attach.
t, err := tunnel.NewADB(instance.Status.AdbWebSocketURL, instance.Status.Token)
if err != nil { panic(err) }
if err := t.Start(); err != nil { panic(err) }
defer t.Close()
log.Printf("adb connected on %s", t.Addr())From Appium's side, the emulator is indistinguishable from a USB-attached device.
Install Appium
npm i --location=global appium
appium driver install uiautomator2 # or espresso, flutter, etc.
appiumAndroid capabilities
import { remote } from 'webdriverio';
const driver = await remote({
hostname: '127.0.0.1',
port: 4723,
path: '/',
capabilities: {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:udid': '127.0.0.1:<port>', // copy from `adb devices`
'appium:appPackage': 'com.example.app',
'appium:appActivity': '.MainActivity',
},
});from appium import webdriver
from appium.options.common import AppiumOptions
opts = AppiumOptions()
opts.set_capability('platformName', 'Android')
opts.set_capability('appium:automationName', 'UiAutomator2')
opts.set_capability('appium:udid', '127.0.0.1:<port>') # copy from `adb devices`
opts.set_capability('appium:appPackage', 'com.example.app')
opts.set_capability('appium:appActivity', '.MainActivity')
driver = webdriver.Remote('http://127.0.0.1:4723', options=opts)There are no Limrun-specific capabilities for Android. The tunnel makes the device local, so any capability documented for the upstream UiAutomator2 driver works as-is.
To install an APK before the session, push it through the tunnel like a normal local device:
adb -s 127.0.0.1:<port> install ./app-debug.apkOr pre-install it at instance creation with initialAssets. See Run an emulator for the create flow.
Run from a Linux CI runner
Neither iOS nor Android automation needs a macOS host. The Mac lives on Limrun's side. A standard ubuntu-latest GitHub Actions job is enough.
name: Appium iOS
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Appium and driver
run: |
npm i -g appium
# Pin a specific `-lim.N`. See npmjs.com/package/@limrun/appium-xcuitest-driver for the latest.
appium driver install --source npm @limrun/[email protected]
- name: Start Appium
run: appium --log-level error &
- name: Run tests
env:
LIM_API_KEY: ${{ secrets.LIM_API_KEY }}
run: npm testFor Android, replace the Install Appium and driver step above with the upstream UiAutomator2 driver and make sure adb is on the runner's PATH (the GitHub-hosted ubuntu-latest image ships it under $ANDROID_HOME/platform-tools/):
- name: Install Appium and driver
run: |
npm i -g appium
appium driver install uiautomator2
echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATHThe CI runner doesn't need Xcode, the iOS SDK, or a local Android emulator. For iOS it needs Node, Appium, and LIM_API_KEY. For Android, add adb on the PATH (Appium's UiAutomator2 driver shells out to it, regardless of whether you open the tunnel through the CLI or startAdbTunnel).
The job above creates instances from your test code, so label them there, for example metadata: { labels: { session: process.env.GITHUB_RUN_ID! } }, and delete them in your suite's teardown (such as afterAll), including on failure. Inactivity timeouts handle the rest if a job crashes. Listing and deleting by label is covered in Run a simulator and Run an emulator.
Troubleshooting
Next steps
Run a simulator
Create, configure, and drive iOS simulators: the control surface Appium sits on.
Run an emulator
ADB tunnel, instance configuration, and the Android device client.
Playwright
Drive Chrome on Android emulators over CDP.
Asset Storage
Store the WebDriverAgent bundle and your builds once instead of downloading them on every job.
Was this guide helpful?