SpawnDroid

CI tests

Run automated UI tests against a hosted Appium runner and report the result back to your pipeline.

SpawnDroid hosts an Appium 2 server with the UiAutomator2 driver for every CI session. Your pipeline brings its own test framework, so anything that speaks the W3C WebDriver protocol works: WebdriverIO, Selenium, or an Appium client in any language. There is no SDK to vendor and no test format to adopt.

The whole flow is four calls: upload the build, start a runner, run the test, report the result.

1. Upload the build

A CI upload stores the build and returns its id. It does not boot a device.

APK_ID=$(curl --silent --show-error --request POST https://app.spawndroid.io/api/apps \
  --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
  --form "file=@app/build/outputs/apk/release/app-release.apk" \
  | jq --raw-output '.app.id')

For builds near the 512 MB limit, upload straight to object storage first with POST /api/apps/presign, then register the key. See the API reference.

2. Start a runner

RESPONSE=$(curl --silent --show-error --request POST https://app.spawndroid.io/api/v1/sessions \
  --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"artifactId\": \"$APK_ID\"}")

SESSION_ID=$(echo "$RESPONSE" | jq --raw-output '.sessionId')
WD_URL=$(echo "$RESPONSE" | jq --raw-output '.webdriverUrl')
UDID=$(echo "$RESPONSE" | jq --raw-output '.device.serial')
PACKAGE=$(echo "$RESPONSE" | jq --raw-output '.app.package')

Provisioning is asynchronous. The device boots and the APK is installed after the call returns, so poll until the state is ready.

for attempt in $(seq 1 60); do
  STATE=$(curl --silent --show-error https://app.spawndroid.io/api/v1/sessions/$SESSION_ID \
    --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
    | jq --raw-output '.state')
  [ "$STATE" = "ready" ] && break
  [ "$STATE" = "failed" ] && exit 1
  sleep 5
done

If the workspace has no free parallel slot, the start call returns 429 with code: "concurrency". Fail fast in that case rather than retrying, and either free a slot or add one.

3. Run the test

Point your framework's remote URL at $WD_URL. The build is already installed and never launched for you, so the test owns the app lifecycle.

// webdriverio.mjs
import { remote } from "webdriverio"

const url = new URL(process.env.WD_URL)
const driver = await remote({
  hostname: url.hostname,
  path: url.pathname, // /ci/sess_<id>/<token>/wd/hub
  protocol: "https",
  capabilities: {
    platformName: "Android",
    "appium:automationName": "UiAutomator2",
    "appium:udid": process.env.UDID,
    "appium:appPackage": process.env.PACKAGE,
    "appium:autoGrantPermissions": true,
  },
})

await driver.startActivity(process.env.PACKAGE, process.env.LAUNCH_ACTIVITY)
// …your assertions…
await driver.deleteSession()

There is no appium:app capability, because the build is pre installed on the session device.

4. Report the result and tear down

The teardown request carries your test outcome. The console records it in the execution history and forwards it to your webhooks.

curl --silent --show-error --request DELETE https://app.spawndroid.io/api/v1/sessions/$SESSION_ID \
  --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"result": "passed"}'

Set result to passed or failed. Without a body the run closes as passed, which keeps older pipelines working.

Teardown destroys the device, retains the final logcat dump and the screen recording, and returns what was stored.

{ "ok": true, "result": "passed", "logcatStored": true, "videoStored": true }
  • The retained logcat is available at GET /api/v1/sessions/:id/logcat, either as a download or, with ?format=json, as parsed lines.
  • The screen recording is available at GET /api/v1/sessions/:id/video. Recording starts when the device is ready and covers the first three minutes of the run.
  • In the console, the Test Logs panel shows the outcome, the logcat viewer and the video link for every execution.

GitHub Actions example

name: UI tests
on: pull_request
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build APK
        run: ./gradlew assembleDebug
      - name: Start device
        id: session
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          APK_ID=$(curl --silent --show-error --request POST https://app.spawndroid.io/api/apps \
            --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
            --form "file=@app/build/outputs/apk/debug/app-debug.apk" \
            | jq --raw-output '.app.id')
          RESPONSE=$(curl --silent --show-error --request POST https://app.spawndroid.io/api/v1/sessions \
            --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
            --header "Content-Type: application/json" \
            --data "{\"artifactId\": \"$APK_ID\"}")
          echo "session_id=$(echo "$RESPONSE" | jq --raw-output '.sessionId')" >> "$GITHUB_OUTPUT"
          echo "wd_url=$(echo "$RESPONSE" | jq --raw-output '.webdriverUrl')" >> "$GITHUB_OUTPUT"
          echo "udid=$(echo "$RESPONSE" | jq --raw-output '.device.serial')" >> "$GITHUB_OUTPUT"
          echo "package=$(echo "$RESPONSE" | jq --raw-output '.app.package')" >> "$GITHUB_OUTPUT"
      - name: Wait for ready
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          for attempt in $(seq 1 60); do
            STATE=$(curl --silent --show-error https://app.spawndroid.io/api/v1/sessions/${{ steps.session.outputs.session_id }} \
              --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
              | jq --raw-output '.state')
            [ "$STATE" = "ready" ] && exit 0
            [ "$STATE" = "failed" ] && exit 1
            sleep 5
          done
          exit 1
      - name: Run tests
        env:
          WD_URL: ${{ steps.session.outputs.wd_url }}
          UDID: ${{ steps.session.outputs.udid }}
          PACKAGE: ${{ steps.session.outputs.package }}
        run: node webdriverio.mjs
      - name: Report result
        if: always()
        env:
          SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}
        run: |
          RESULT=${{ job.status == 'success' && 'passed' || 'failed' }}
          curl --silent --show-error --request DELETE https://app.spawndroid.io/api/v1/sessions/${{ steps.session.outputs.session_id }} \
            --header "Authorization: Bearer $SPAWNDROID_API_KEY" \
            --header "Content-Type: application/json" \
            --data "{\"result\": \"$RESULT\"}"

Notes for pipelines

  • Slot accounting is immediate. A start call either gets a runner or returns 429, so your job never waits in an invisible queue.
  • Runners are reaped automatically when idle, which protects the fleet if a pipeline exits without tearing down. Reporting the result explicitly is still the best practice, because it records the outcome and keeps the artifacts.
  • Device profiles are chosen per session. Pass profileId when starting a runner to pin an Android version or form factor, for example android-13-phone or android-14-tablet.

On this page