The tap server¶
Every client, whether a test in Kotlin or Python, tap-agent, Tap Studio or tap-watcher, talks to one
server per machine: tap. It owns ADB, installs Tap's driver on each device, and keeps a device
held by one client at a time. You start it; clients never do.
The tap command¶
tap start [--port N] [--state-dir DIR] [--adb PATH] [--scrcpy PATH] [--scrcpy-server JAR]
tap serve [--port N] [--state-dir DIR] [--adb PATH] [--scrcpy PATH] [--scrcpy-server JAR] [--driver-apk APK --driver-test-apk APK]
tap status [--state-dir DIR]
tap stop [--state-dir DIR]
tap version
start |
starts the server in the background and returns once it answers: prints started 127.0.0.1:PORT pid=PID, or running … when one is already up in that state dir (nothing is started twice). Its output goes to <state-dir>/daemon.log; exit 1 if it died or never became ready |
serve |
the same server in the foreground (--port 0 = ephemeral, the default); what start runs for you |
| both | bind loopback only, write <state-dir>/daemon.json (port, pid, token, daemonVersion, adb; readable only by you), and keep running until tap stop. One server per state dir: a second serve exits 3 |
--state-dir |
where daemon.json, sessions/ (leases and journals) and the extracted driver live; default $TAP_STATE_DIR or ~/.tap |
--adb |
the ADB executable; default $TAP_ADB or adb on PATH |
--scrcpy |
the scrcpy executable, used only by recordings; default $TAP_SCRCPY or scrcpy on PATH. Tap runs it with its own ADB |
--scrcpy-server |
a scrcpy 4.1 server JAR for the shared screen video that tap-watcher shows; default $TAP_SCRCPY_SERVER, otherwise no shared video. Recordings do not need it |
--driver-apk, --driver-test-apk |
a driver build of your own instead of the one bundled in the executable. Clients cannot pick a driver per attach |
status |
prints running 127.0.0.1:PORT pid=… version=… adb=… (never the token); exit 1 when no server is running |
stop |
terminates the server recorded in daemon.json after it answered an authenticated call; a stale file is removed and nothing is signalled |
One server per machine is the intended setup; every test process on that machine connects to
it. Two servers with different --state-dirs are isolated from each other (separate journals
and device locks), which is how Tap's own CI keeps a scratch server apart from ~/.tap.
Extracted driver cache¶
The bundled driver APKs live in <state-dir>/driver/<version>-<digest>/. Different builds keep
separate directories; old builds are not deleted automatically because another server may still
be using them. To reclaim space, stop every Tap server using that state directory, then delete
its driver/ directory (including any older version-only subdirectories). The next start
extracts its bundled APKs again. Leave sessions/ intact: it holds device locks and recovery
journals, not cached files.
Who starts it¶
Starting is explicit, and there are three ways to do it:
- By hand or in a CI step:
tap startbefore the tests,tap stopafter. The server is shared by every test process on the machine, in any language. - From the test runner:
TAP_MANAGE_DAEMON=true(pytest initap_manage_daemon, JUnittap.manageDaemon). The runner runstap startbefore its first session andtap stopwhen the run ends, but only if that start created the server: one that was already running is left running.TAP_BINsays which executable to use (defaulttaponPATH). - From code: Python
tap_e2e.start_daemon()/stop_daemon(), KotlinTapDaemonProcess.start()/stop(). Both report whether the call started the server.
How clients find it¶
In order:
- An explicit address:
TAP_SERVER(host:port; pytest initap_server, JUnittap.server). - A live
daemon.jsonin the state dir (TAP_STATE_DIR, default~/.tap): whattap startwrote. - Otherwise the client fails with no running tap daemon; run
tap start.
Every call carries the server's bearer token. With a discovered server the client reads it from
daemon.json (only the user running the server can read that file); with an explicit address it
comes from TAP_TOKEN (JUnit: tap.token too). A wrong or missing token fails every call with
UNAUTHENTICATED. Set TAP_STATE_DIR for both the server and the clients if you want it
anywhere but ~/.tap.
The devices¶
- API 26 and up, emulator or physical, visible to
adb devicesasdevice. - Each attachment checks both installed driver APKs against the server's files and reinstalls them if they differ or cannot be verified, including an older build restored from an emulator snapshot. An unchanged build is not reinstalled.
- On emulators used in CI,
adb shell settings put secure hide_error_dialogs 1keeps an unrelated system ANR from stealing the focused window from your app.
The device list is one call away:
from tap_e2e import TapClient
for d in TapClient.create().devices():
print(d.serial, d.state.name, d.client_connection_id, d.quarantine_reason)
Each device is FREE, LEASED (with the connection holding it, when it is one of this
server's), OFFLINE, UNAUTHORIZED (ADB lists it but the device has not accepted this
machine's key), or QUARANTINED with a reason. Only FREE and LEASED devices can be
attached; a LEASED one waits until its holder lets go.
A device is quarantined when a session could not leave it provably clean: the driver could not
be stopped, the journal is corrupt, the boot identity changed under an active session, or a
mutation may still be in flight. It stays out of circulation until you have checked the device
and removed its journal record from ~/.tap/sessions/ (one <base64url(serial)>.json per
device). Nothing is retried or reset behind your back.
What a session puts back¶
Whatever a session changes on a device through Tap (rotation, device conditions, app languages, the mock location, files it pushed) is captured before the first change, journaled, and restored when the device is detached. When the server died first, the next attach restores it. A restore that does not read back quarantines the device rather than leaving it changed silently.