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
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:
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:
- boots the AVD headless, or uses one that already runs;
- runs
adb rootand puts the run's certificate in the trust store; - turns off animations, Chrome's first-run screens and the captive-portal probe;
- routes the device's traffic through the proxy;
- installs the APK.
| Option | What it does |
|---|---|
avd | the AVD to boot |
worker | gives each worker its own emulator (console port 5554 + 2 × worker) |
snapshot | boots from a saved AVD snapshot: seconds instead of a minute |
serial | uses a device that already runs, as it is |
permissions | runtime permissions to grant before each launch |
app.scheme | the 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
| Fixture | On Android |
|---|---|
screen | getByTestId, getByRole, getByLabel, getByText; tap, fill, check, press, selectOption |
expect | the same checks as the web, including toHaveScreenshot |
app.open | opens a deep link; with no path, restarts the app |
device | foreground(), back(), openLink(url), logs(), swipe(), dismissKeyboard() |
agent | act, 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:
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 })
})| Option | What it does | Default |
|---|---|---|
direction | the way the finger moves: up, down, left or right | |
within | the box to swipe across, in screenshot pixels | the whole screen |
from, to | the two points to swipe between, in place of direction | |
durationMs | how long the finger takes | 300 |
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:
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.