Skip to main content

Gray box: waiting for the app to say it is idle

Auto-wait judges a screen by what it shows: a target is there, has stopped moving, is enabled and is not covered. That cannot see work the screen does not show — a request in flight behind rows that look finished, which a tap then lands on just before they are replaced.

Gray box asks the app instead. An app built with Mobium's gray-box library, and launched with --gray-box, says in the device log when it starts work and when that work is finished and on screen. Every action then waits for the app to be idle before it finds its target. The app says when; Mobium does not guess.

iOS and Android, on simulators, emulators and phones. Everything here was measured on an iPhone 15 Plus on iOS 26.6.2, an iPhone 17 Pro simulator on iOS 26.5, a Pixel 8 Pro on Android 17 and a Pixel 7 emulator on Android 15, on MobiumApp's Busy Demo, which links the library.

Turning it on​

$ mobium launch --gray-box dev.mobium.mobiumapp
launched dev.mobium.mobiumapp, with the gray box: every action waits for the app to say it is idle

On iOS, --gray-box launches the app with the argument -MobiumGrayBox YES, which iOS keeps for that launch only. On Android it starts the app afresh — stopped first, so its activity reads what it was started with — with the intent extra MobiumGrayBox=true, which a launch from the home screen never carries. Either way the next ordinary launch is ordinary, a person running the same build never turns it on, and the library writes nothing without it.

An app that does not link the library launches normally, and says so (an emulator; a simulator says the same of com.apple.Preferences):

$ mobium launch --gray-box com.android.settings
launched com.android.settings, but the app has not answered the gray box in 5s, so actions are not waited for: it needs Mobium's gray-box library, in a build that reads the MobiumGrayBox launch argument or intent extra

In a test file it is "grayBox": true beside "app", and each test's launch turns it on — the tutorial takes a test from five failures in five to five passes that way. From MCP and the clients it is app_launch with gray_box: true — launch(app, gray_box=True) in Python, launch(app, { grayBox: true }) in JavaScript, LaunchWithGrayBox in Go and .NET, launchWithGrayBox in Java.

What an action does​

Before an action finds its target, it waits until the app has nothing in flight. The result says what it waited for (the iPhone):

$ mobium tap testid=busyQuiet
tapped testid=busyQuiet at (645, 1011)
gray box: waited 35 ms for the app to go idle

$ mobium tap testid=busyRowB
tapped testid=busyRowB at (645, 1218)
gray box: waited 1018 ms for the app to go idle (busy: quiet)

$ mobium text testid=busyOutcome
row B, generation 2: current

The wait is the check idle, after the five auto-wait makes. Work a tap starts is announced as the tap is handled, so the wait first lets the last call's touch arrive, then 150 ms after the finger lifted — measured: the app's busy line came 0 to 15 ms after the lift, in 200 trials — and only then trusts a count of zero. On an idle app it costs about 35 ms, or nothing when the last call ended long enough ago — gray box: the app was idle, as the emulator printed for the tap before this one:

$ mobium tap testid=busyQuiet
tapped testid=busyQuiet at (540, 901)
gray box: the app was idle

$ mobium tap testid=busyRowB
tapped testid=busyRowB at (540, 1095)
gray box: waited 1184 ms for the app to go idle (busy: quiet)

With --json the waits are in app_idle (this one on the simulator):

$ mobium tap testid=busyRowB --json
{
"action": "tap",
"app_idle": {
"waited_ms": 388,
"waits": [
{
"busy": [
"quiet"
],
"waited_ms": 388
}
]
},
"target": "testid=busyRowB",
"x": 603,
"y": 1275
}

Coordinate taps, long presses and swipes wait the same way as a tap on an element, and so does a tap in a WebView. A tap on a button in an alert lifts in the alert's own window, which the library does not watch; with no lift heard during an action, the grace counts from the action's end instead.

An app that is never idle — one polling in the background — is not waited for forever. After 10 seconds the action is refused as a timeout that failed check idle, naming what the app said kept it busy, with the remedy that works: launch the app again without --gray-box to act on the screen as it is.

$ mobium tap testid=busyRowA
error: testid=busyRowA failed check idle: the app says it is still busy after 10s, with poll — something in the app never finishes; to act on the screen as it is, launch the app again without gray_box

When the app is not waited for​

Three things end a wait early, and the result says which, so a check that did not run never reads as one that passed:

  • The app went to the background. The library says away and back; while the app is away, an action elsewhere is not held up by it.
  • The app stopped saying it is busy. Busy is a lease: while work is in flight the library restates the count every half second, from a native timer that a busy JavaScript thread does not stop. A count nothing has restated for 1.5 seconds belongs to an app that crashed holding it, or was suspended, and is not waited on.
  • Mobium is not hearing the app. The log stream stopped. On Android it starts again by itself within a second, from the device time of the last line it read, so nothing written in the gap is lost; on a simulator the next launch --gray-box starts it again; on an iPhone the session's log capture reconnects at the next action. What the app said during the gap is lost there — work begun in it, among other things — so after a reconnect the count starts again from zero and the wait gives the app 700 ms to restate the work it has in flight before trusting it, and the result says so: gray box: waited 758 ms for the app to go idle, after its log stream dropped and was reconnected.

The Busy Demo's crash button dies holding work; the next tap, on the emulator's home screen, was not held up by it:

$ mobium tap 540 1200
tapped (540, 1200)
gray box: not waited — the app stopped saying it is busy 2.1s ago (it was busy with doomed)

On Android the app often reports itself in the background on its way down, and then that is the reason given — the same answer, reached sooner.

Hooks: calling into the app​

Waiting is the way out of the app. Hooks are the way in: functions the app registers by name, which a test calls to set up state without walking the UI — sign in, seed data, raise a toast. MobiumApp registers three; in outline:

GrayBox.register('raiseToast', message => { showToast(message); return 'shown'; });
GrayBox.register('screen', () => screenRef.current);
GrayBox.register('signIn', username => { setLoggedInAs(username); setScreen('secret'); return `signed in as ${username}`; });

A test calls one by name, with string arguments, and gets back what it returned (the emulator):

$ mobium hook screen
hook screen answered: "home"
gray box: waited 114 ms for the app to go idle

$ mobium hook raiseToast "Toast raised by test script"
hook raiseToast answered: "shown"
gray box: waited 104 ms for the app to go idle

$ mobium text testid=hookToast
Toast raised by test script

$ mobium hook signIn mobium
hook signIn answered: "signed in as mobium"
gray box: waited 102 ms for the app to go idle

$ mobium text testid=welcomeText
Welcome, mobium!

From MCP it is app_hook with hook, args and an optional timeout_ms; from the clients, hook(name, *args) in Python, hook(name, ...args) in JavaScript, Hook(ctx, name, args...) in Go, hook(name, args...) in Java and Hook(name, params args) in .NET, each returning what the hook returned, decoded from JSON.

How a call travels: Mobium waits for the app to be idle, then writes {"i":<id>,"h":"<name>","a":[...]} into a field the library adds only in a gray-box launch — set whole through UiAutomator2 on Android, typed through WebDriverAgent on iOS, with everything outside ASCII escaped, since a real iPhone's keyboard has dropped letters outside its layout. The library hands the call to the registered function and writes its answer to the device log, where the gray box is already listening: MOBIUM-GRAYBOX hook id=<id> ok <json>, or error <text>. A hook's own work counts as busy, until two frames after it answers, so the next action waits for what the hook changed to be on screen. map never lists the field. A call takes about 0.6 s on an Android emulator and 1.4 to 1.9 s on an iOS simulator, where it is typed.

What goes wrong is said:

$ mobium hook nosuch
error: the app has no hook named nosuch; registered: raiseToast, screen, signIn

A name the app never registered is invalid_argument, naming the ones it did; a hook that throws is device_server, with its message; an app launched without --gray-box is device_not_ready, naming the launch; no answer within 15 seconds (--timeout-ms) is timeout.

Register hooks only in builds made for testing. They answer only in a gray-box launch, but a hook in a shipped app would still be a door into it.

What the app writes​

The library writes one line to the device log for each change — on iOS under the os_log subsystem dev.mobium.graybox, at the default (notice) level, with its values public; on Android to logcat under the tag MobiumGrayBox, at info:

MOBIUM-GRAYBOX on the library is listening
MOBIUM-GRAYBOX busy=1 tag=fetch work started; 1 thing in flight
MOBIUM-GRAYBOX busy=0 tag=fetch that work finished, and is on screen
MOBIUM-GRAYBOX lift a finger came up
MOBIUM-GRAYBOX still busy=1 every half second while work is in flight
MOBIUM-GRAYBOX away / back the app left the foreground / returned
MOBIUM-GRAYBOX hook id=7 ok <json> a hook answered (error <text> if it threw)

busy= is the count of work in flight after the change; tag= names it, for a refusal to name. Anything after the fields — MobiumApp's library adds the phone's clock as t= — is ignored. On an iPhone the lines arrive through the log the session already captures, 1 to 4 ms after the app writes them; on a simulator through a log stream narrowed to that subsystem; on Android through logcat narrowed to that tag, from the device's own clock at the moment of the launch, so a line from an earlier launch is never read.

The app decides what counts as work, as it decides when it is finished. In MobiumApp the Busy Demo calls the library's busy("quiet") when it starts and idle("quiet") in an effect after the new rows are rendered, so idle means "done and on screen", not "the response arrived". The library is a local Expo module, modules/graybox in MobiumApp, about ninety lines of Swift and seventy of Kotlin: copying it into another React Native app, or writing the same lines from a native one, is all it takes.

The Busy Demo, measured​

The Busy Demo has two buttons that start the same 0.4 to 1.6 seconds of work, then bring a new generation of rows. Refresh replaces the rows with a spinner meanwhile; Refresh quietly leaves the old rows up. A row says whether the one tapped was current. A trial is: tap the refresh, tap Row B, read what the row said. docs/checks/graybox-edges.sh holds the edges above to the same demo, with buttons that start the refresh from an alert, run two at once, keep polling, and crash the app mid-refresh. docs/checks/graybox.sh runs ten after a quiet refresh launched normally, three after a refresh with a spinner, and ten after a quiet refresh launched with --gray-box:

DeviceLaunched normally: staleWith --gray-box: stale
iPhone 15 Plus, iOS 26.6.26 of 10, and 7 of 100 of 10, twice
iPhone 17 Pro simulator, iOS 26.59 of 100 of 10
Pixel 8 Pro, Android 179 of 100 of 10
Pixel 7 emulator, Android 1510 of 100 of 10

After a refresh with a spinner, a normal launch tapped a current row every time, on every device: that is the control — what the screen shows, auto-wait already waits for. The quiet refresh is what only the app can say. Over fifty more trials on the iPhone, launched normally, 35 of 50 rows tapped after a quiet refresh were stale, and none of 50 after a refresh with a spinner.

The check fails if the normal launch never tapped a stale row: a run in which gray box had nothing to prevent shows nothing.

What it does not do​

  • Detect work. The app says when it is busy. Work it does not declare is not waited for, and an app that says it is idle too early is believed.
  • An app without the library — anything from an app store — which stays driven the ordinary way.
  • --hit-test together with it, for now: launch with one of them.
  • Switching apps. The gray box belongs to the app last launched with it. An ordinary launch of any app ends it.