dánwòdánwò

Platforms

Android tests

Bind an emulator to the run, then write the same locators, checks, agent steps and invariants as on the web.

An Android test reads like a web test. screen finds elements on the device's screen, expect waits for checks, and an invariant compares your backend with Stripe. danwo boots the emulator, routes its traffic through the proxy, and installs your build.

A test on a device

tests/buy-credits.danwo.ts
import type { DeviceFixtures } from "@danwo/device"
import { type StripeFixtures, test as base } from "@danwo/sdk"

const test = base.extend<StripeFixtures & DeviceFixtures>()

test("buys 500 credits", async ({ screen, expect, device }) => {
  await screen.getByTestId("buy").tap()

  /* Checkout opens in a Custom Tab; the same locators read the web page. */
  expect((await device.foreground()).browser).toBe(true)
  await screen.getByLabel("Email").fill("buyer@example.com")
  await screen.getByRole("button", { name: "Pay $5.00" }).tap()

  await expect(screen.getByTestId("credits")).toHaveText("Credits: 500")
})

getByTestId matches React Native's testID. A Custom Tab, the in-app browser a checkout often opens, is part of the screen.

Every tap waits for its element to be enabled, so a button that is greyed out while the app loads is tapped once it turns on. Just before it presses, danwo reads the screen again and finds the element there, so a form the keyboard slid up is tapped where it is now.

Bind the emulator in the config

withAndroidDevice wraps your environment and binds an emulator to it:

danwo.config.ts · the device
return withAndroidDevice(environment, {
  avd: "danwo_spike",
  worker: workerIndex,
  snapshot: "danwo_clean",
  app: { package: "com.danwo.shopmobile", apk: build.apk, scheme: "danwoshop" },
})

The full file, with the backend and Stripe behind the proxy, is in examples/shop-mobile. For each worker, danwo:

  1. boots the AVD headless, or uses one that already runs;
  2. runs adb root and puts the run's certificate in the trust store;
  3. turns off animations, Chrome's first-run screens and the captive-portal probe;
  4. routes the device's traffic through the proxy;
  5. installs the APK.
OptionWhat it does
avdthe AVD to boot
workergives each worker its own emulator (console port 5554 + 2 × worker)
snapshotboots from a saved AVD snapshot: seconds instead of a minute
serialuses a device that already runs, as it is
permissionsruntime permissions to grant before each launch
app.schemethe link scheme app.open(path) uses, as <scheme>://<path>

Pass blocked: ANDROID_BACKGROUND_HOSTS from @danwo/device to your services. It blocks the emulator's own background traffic (Chrome sync, Play services), so the matrix never faults it.

What a test can do

FixtureOn Android
screengetByTestId, getByRole, getByLabel, getByText; tap, fill, check, press, selectOption
expectthe same checks as the web, including toHaveScreenshot
app.openopens a deep link; with no path, restarts the app
deviceforeground(), back(), openLink(url), logs(), swipe(), dismissKeyboard()
agentact, assert, extract, waitFor, with device tools

Every attempt starts the app afresh: its data cleared, its permissions granted.

Swipe and put the keyboard away

device.swipe drags one finger across the screen. A slow swipe turns a carousel's page or pulls a list to refresh:

tests/feed.danwo.ts
import type { DeviceFixtures } from "@danwo/device"
import { test as base } from "@danwo/sdk"

const test = base.extend<DeviceFixtures>()

test("turns the carousel and refreshes the feed", async ({ screen, expect, device }) => {
  /* The finger moves left, so the carousel shows its next page. */
  await device.swipe({ direction: "left", durationMs: 600 })
  await expect(screen.getByText("Page 2")).toBeVisible()

  /* Put the keyboard away, then pull the list down to refresh it. */
  await device.dismissKeyboard()
  await device.swipe({ direction: "down", durationMs: 900 })
})
OptionWhat it doesDefault
directionthe way the finger moves: up, down, left or right
withinthe box to swipe across, in screenshot pixelsthe whole screen
from, tothe two points to swipe between, in place of direction
durationMshow long the finger takes300

dismissKeyboard() does nothing when no keyboard shows. The agent has the same gestures, and a long press too.

The agent on a device

Plain-English steps work too. The agent reads the screen as a list of elements and acts with taps, typing, scrolls, swipes and long presses. It sees which elements are disabled, checked or selected, such as the active tab:

tests/agent-buy.danwo.ts
import { test, z } from "@danwo/sdk"

test("buys 500 credits when asked in plain words", async ({ agent, screen, expect }) => {
  const { credits } = await agent.extract("the credits balance", z.object({ credits: z.number() }))

  await agent.act("Start buying 500 credits")
  await agent.act("Pay for the credits with the email {email}", {
    params: { email: "buyer@example.com" },
  })
  await expect(screen.getByTestId("credits")).toHaveText(`Credits: ${credits + 500}`)
})

The first run records each step. Later runs, and every matrix trial, replay them with no model call. In the example shop, the recorded run made 6 model calls in about 17 seconds; the replay made none, in about 6.

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI