Air QA
air-qa
Get started
Black-box UI testing

Test real apps
the way real users
touch them.

No Appium. No Java server. No code changes. air-qa drives Android, iOS, and web UI straight through the OS accessibility layer, orchestrated by plain YAML flows.

3Platforms, one runner
0Appium / Java servers
5Auto-retries per step
air-qa — checkout-flow.yaml
Why black box

It reads your app.
It doesn’t live inside it.

Appium and WebDriver-style tools sit inside your app — agents, bridges, a Java server, wired up before a single tap fires. air-qa reads the accessibility tree every Android and iOS device already broadcasts, and drives real taps, input, and scrolls straight against it.

No SDK to add. No test target to maintain. No instrumentation that drifts out of sync the next time your app ships.

✗ Appium server
✗ WebDriver protocol handshake
✗ App-side instrumentation / SDK
✓ OS accessibility bridge (adb/uiautomator, idb, Playwright) → your app, unmodified
Capabilities

Everything you need
to ship with confidence.

Zero code changes

Taps and inputs run against real native accessibility bounds. React Native testID maps through on both iOS and Android.

Native speed

No Java server to boot, no WebDriver handshake — the runner talks to the device directly.

YAML flows

Declarative, Maestro-style syntax — readable steps, not a page-object framework to maintain.

AI video → test

Hand it a screen recording. Gemini watches the video and writes back an executable YAML script.

AI severity grading

Every failure is graded CRITICAL → LOW with a one-line blast-radius reason.

Mobile + web, one flow

Complete a transaction on the app, switch into a real browser, confirm it landed on the portal — one run, one report.

Device matrix

Run the same suite across multiple devices and emulators in parallel, with auto-detection.

Live dashboard

A polling browser dashboard shows pending / running / passed / failed for every file, live.

Graceful stop

Ctrl+C mid-suite finishes the current step, closes the app and browser cleanly, and still reports what ran.

Pick your surface

One YAML shape.
Two ways to run it.

appId: com.customerportal.dev
webBaseUrl: https://portal.customerbank.dev
webHeadless: true
---
# mobile side
- launchApp
- tapOn: "Send Money"
- tapOn: { testID: "amount-input" }
- inputText: "500"
- tapOn: "Confirm Transfer"
- assertVisible: "Transaction Successful"
- saveText:
    from: { testID: "txn-reference" }
    as: txnRef

# same run, real browser
- web:
    - launchApp: "/login"
    - tapOn: { testID: "portal-username" }
    - inputText: "admin@customerbank.dev"
    - tapOn: "Sign In"
    - tapOn: "Transactions"
    - assertVisible: "{{txnRef}}"
1

Runs the transfer on the real app.

Native accessibility taps, no mock.
2

Captures the reference number into {{txnRef}}.

Runtime variable — doesn’t exist until this step runs.
3

Switches into a real Chromium session.

Same run, same report — browser opens lazily.
4

Confirms the same transaction on the portal.

One assertion closes the loop.
platform: web
webBaseUrl: https://portal.customerbank.dev
webHeadless: true
---
- launchApp: "/authentication/login"
- tapOn: { selector: 'input[placeholder="User Number"]' }
- inputText: "${PORTAL_USER}"
- tapOn: { selector: 'input[placeholder="Password"]' }
- inputText: "${PORTAL_PASSWORD}"
- tapOn: { selector: "a.btn.btn-primary" }
- assertVisible: "Dashboard"
1

No mobile app at all.

platform: web skips straight to the browser — no web: wrapper needed.
2

Navigates directly.

A relative path resolves against webBaseUrl.
3

Raw CSS when there’s no testID.

{ selector: "..." } is the escape hatch for markup keyed on placeholders or classes.
4

Same runner, same report.

Retries, screenshots, and the HTML report work exactly as they do on mobile.
AI, where it earns its keep

Two jobs. Not a chatbot.

Screen recording → runnable test

Record yourself using the app once. air-qa hands the video to Gemini, which watches it and writes back a complete YAML script — taps, text input, PIN pads, assertions — ready to run.

.mov / .mp4 → Gemini File API → checkout-generated.yaml

Every failure, triaged automatically

When a step fails, the full flow and the failing step are graded for severity — with a one-line reason — so a delivery team sees what needs attention first.

CRITICALHIGHMEDIUMLOW
After every run

A report that tells you why.

Total
5
Passed
3
Failed
2
Duration
1m 41s
FAILcheckout-flow.yamlemulator-5554 · 12.4s
Element not found matching {"testID":"confirm-btn"} after 5 retries
PASSlogin-then-staff-portal-verify.yamlweb · 8.9s
Instrumentation, per platform

One YAML. Three surfaces.

Android

uiautomator UI-tree dumps over adb. Animations disabled automatically so taps land on the frame you expect.

adb · uiautomator

iOS

Drives the simulator through idb — the same accessibility bridge Xcode’s own tooling relies on.

idb-companion

Web

For the portal riding alongside your app, or a standalone platform: web file — headless or headed Chromium via Playwright.

Playwright · Chromium
Header fields

Set once, at the top of the file.

appId

The Android package / iOS bundle id to launch and close. Omit it entirely for a pure platform: web file.

platform

android · ios · web. Defaults to the --platform flag (itself defaulting to android) — a file’s own value always wins.

webBaseUrl / webHeadless

Base URL and headless flag for a web: block, or a pure platform: web file.

closeApp: false

Leaves the app open after this file finishes, instead of the default auto-close between files.

expectFail: true

Marks a negative test case — a failing run reports PASS (XFAIL); an unexpected pass reports FAIL (XPASS).

continueOnFailure: true

Don’t halt the suite if this file’s steps fail — the runner moves on to the next one.

Every step type

The full command reference.

Nineteen step types cover everything from a single tap to a full mobile-to-browser handoff.

launchAppLaunches the app — or, inside web:, navigates (a relative path resolves against webBaseUrl).
tapOn: "text"Matches visible text, testID, id, or content-desc.
tapOn: { text, index }Disambiguates when more than one element matches.
tapOnPoint: "50%, 50%"Or absolute coordinates, e.g. "500, 1000".
inputText: "text"Types into the focused native field — falls back to tapping an on-screen keyboard key by key if none is focused.
inputText: { text, onScreen }Forces on-screen key-by-key typing, e.g. for a custom PIN pad.
eraseText: nOr clearText: true to clear the whole field.
hideKeyboardtapOn/assertVisible already do this automatically — use it to dismiss at another point, e.g. before a scroll.
assertVisible: "text"Fails the step if the element isn’t on screen.
extendedWaitUntil: { visible, timeout }Waits up to timeoutms for something to eventually appear.
wait: msA fixed pause.
scroll: { direction, times, toEnd }Scrolls a fixed amount, or all the way to the end.
scrollUntilVisible: { element, direction }Scrolls until a target element appears (Android only).
runFlow: "path.yaml"Imports a sub-flow inline — also takes { file, continueOnFailure }.
waitForUser: "message"Or pause. Pauses and types what you enter into the active field.
if: { visible|notVisible } then: [...] else: [...]Branches on current state — a one-shot check, not a wait.
randomNumber: { min, max, as }Saves a random integer as a runtime {{var}}.
saveText: { from, as }Captures an element’s visible text as a runtime {{var}}, reusable in any later step.
web: [...]Runs a nested step list against a browser instead of the device — see “Pick your surface” above.
Quick start

Two steps. You’re testing.

1

Install

git clone git@github.com:ayush0710/air-qa.git
cd air-qa && npm install && npm link
cp .env.example .env

Private repo — invite-only, you’ll need collaborator access. Testing a web portal too? Also run npx playwright install chromium once.

2

Run

air-qa tests/checkout-flow.yaml --platform android

Add --devices a,b for parallel devices, or swap in --platform web --dashboard for a live browser view.