Use
Examples
Look, then act
looktap ls
@1 Button 16, 285 358x54 General
@2 Button 16, 339 358x54 Accessibility
@3 TextField 33, 773 324x38 Search = Search
Each row shows a ref (@1), the element type, its x,y position and width×height (iOS points /
Android dp, the units tapxy and swipe take), its label, and = value for fields that hold text and for switches and checkboxes (1 on, 0 off). Unlabelled
buttons are listed too, as (no label), so you can tap them by ref. The on-screen keyboard is a single row.
Tap by label, not by coordinates
looktap tap "Open menu" # exact label (or identifier on iOS)
# tapped "Open menu" at 30,62
looktap tap "Messages" # no exact match → the one label starting with it
# tapped "Messages, 2 unread" at 342,62
looktap tap "Wi" # matches two different labels → refuses
# looktap: "Wi" is ambiguous — matches 2 labels: "Wi-Fi", "Wi-Fi hotspot"
looktap tap --exact "Messages" # exact only
looktap tap @7 # row @7 of the last ls
looktap tapxy 205 874 # when ls doesn't list it at all
- A ref works only while its element is still where
lssaw it. If the screen changed,tap @7fails ("no longer on screen — run: looktap ls") rather than tapping something else. - If another element covers the tap point (a toast, a dev-warning banner, a floating bar),
tapprints a warning, because the tap probably landed on the cover instead.
Long press and pinch-zoom
looktap longpress "Photo" # context menus, drag handles, "edit" modes (default 800 ms)
looktap longpress @4 1500 # by ref, held 1.5 s
looktap longpress 120 470 # a point, e.g. to drop a pin on a map
looktap zoom in # two fingers spreading at the screen centre (maps, photos, PDFs)
looktap zoom out 200 400 --scale 4 # pinch around a point; --scale is how far the fingers travel
zoom is a real two-finger gesture on both platforms. On Android it needs looktap's helper, because
adb input has no multi-touch.
Fill a form and capture its validation errors
looktap fill "Email" not-an-email
looktap fill "Name" "" # empty: just clears the field
looktap hidekb
looktap shot profile__validation
fill finds the field by its label (on iOS also its identifier), or else the first field at or below a matching
label. It clears the field and types. On Android it also presses Enter, because React Native fields only show
validation on blur.
hidekb tries, in order:
- the keyboard's Done/Return key;
- on iOS, the ✓ on a web form's Previous/Next bar;
- a tap on plain text above the keyboard.
It checks after each. If the keyboard is still up, it exits 2 rather than reporting success.
Wait instead of sleeping, and scroll
looktap wait "Welcome" # until it's on screen (default up to 10 s; --wait 30)
looktap wait "Loading" --gone # until it's gone
looktap scrollto "Privacy & Security" # swipe until fully on screen; fails at the end of the list
looktap scrollto "General" --up
looktap swipe 200 700 200 250 # scroll down (finger moves up); optional 5th arg: ms
looktap back # Android: system Back · iOS: the navigation bar's back button
looktap home # the Home button: home screen, the app keeps running
Debug: logs, crashes, "where did my app go?"
looktap tap "Pay" # something goes wrong…
looktap logs --errors # the app's errors since the last `logs`
# 15:41:06.700 E [com.example:api] POST /payments failed: 422 amount_too_small
looktap logs --grep payment # everything it logged that matches
looktap app --app com.example # is it still running?
# front: com.android.launcher3 pid 1205
# com.example: not running — if it was, check: looktap crashes
looktap crashes
# com.example (pid 14001) exited at 14:56:06.724 while in the background: LOW_MEMORY — killed by the system to free memory, not a crash
When the app dies, looktap says so. After an action, and at the next ls, looktap checks whether the
app that was in front still has its process. If not, it prints:
looktap: warning: com.example is no longer running — it crashed or was killed. See: looktap crashes com.example
- It also catches an Android app that crashed and was restarted at once, because the check is by process id.
- Switching to another app, going home, or uninstalling doesn't trigger it.
- On iOS the crash report is written up to a minute after the crash. Until then,
crashesalready shows the signal (e.g.SIGTRAP) and says the full report is coming.
Each logs call starts where the previous one ended, so a loop of act → logs only ever shows what's new.
--since 5m sets a fixed window instead.
What's in the log:
- iOS: what the app's own code logged, plus errors and faults from anything in its process. The simulator's
own noise (low-level networking, the keyboard) is hidden.
--allshows everything, Apple frameworks included. A read takes about 1–2 s. - Android: everything logcat has for the app's uid, so restarts are covered. A read takes about 0.1 s.
What crashes reports:
- iOS: each crash report as exception, reason (such as an uncaught
NSException), the crashed thread's top frames, and the path to the full.ipsreport. - Android: Java crashes, native tombstones, ANRs, and exits that leave no crash report, low-memory kills above all.
React Native: in development builds, JS console.* output and uncaught JS errors are in logs on both
platforms:
- iOS: as
[com.facebook.react.log:javascript]lines. - Android: as
ReactNativeJSlines.
A red box is also on screen, so ls reads its message and call stack.
Swift: print() writes to stdout, not to the log. Use os.Logger or NSLog for messages logs should see.
Performance: CPU, memory, frames, launch time
looktap launch com.example --time # a cold start, timed
# launched com.example: in front after 0.44 s, first screen settled by 0.85 s · Android TotalTime 300 ms
looktap perf # the app in front: one reading
# com.example pid 4127
# cpu 14% (over 0.5 s; 100% is one core)
# memory pss 78.4 MB · rss 171.2 MB · java heap 12.1 MB · native heap 18.6 MB
# threads 21
looktap perf --for 20s # a reading each second while you (or a flow) use the app, then avg/max
looktap perf --frames # frames since the last --frames, janky %, p50–p99 frame time (iOS simulator:
# the first call starts counting)
looktap swipe 200 700 200 200; looktap perf --frames # how smooth was that scroll?
- Memory: on the iOS simulator, the footprint Xcode's memory gauge shows, and its peak. On Android, PSS (the app's share of RAM) plus RSS and the Java and native heaps.
- CPU is a share of one core over the reading, like
top: a busy app on two cores reads 200%. - Simulator numbers come from the Mac. An iOS simulator app runs on the Mac's CPU and memory, so compare builds and screens against each other, not against a phone. An emulator is closer, but still not a phone: for release numbers, use a real Android device.
launch --timestarts the clock at the start command (stopping the old copy comes first, outside it). Right after a launch an iOS screen read can take a second, so it says how much sooner the screen may have settled.- Real iPhone:
perfreads it through Instruments' Activity Monitor (xcrun xctrace, part of Xcode, so nothing to install). Starting Instruments takes a few seconds, so a reading takes 10–25 s and--forgives one about every 8 s. Its CPU matches Instruments' own figure to within about 10%. - Frames on the iOS simulator come from the screen itself. Instruments' Animation Hitches doesn't support the
simulator, so looktap counts how often the simulator's screen actually changes (its framebuffer), snapped to the
60 Hz display. A frame that skips a refresh (33 ms or more) is janky, and fps is measured while something moves.
It's the whole screen as you'd see it, not the app's own render time, which only Instruments has. A gap over
0.1 s counts as "nothing moving", so a pause between two actions doesn't count as jank, and is reported as a
pause. The first
perf --framesstarts counting; each one after reports what happened since the last. - Frames on a real iPhone come from its USB screen feed, the capture device QuickTime uses, compared frame by frame in the same way. It needs a USB cable (not Wi-Fi), an unlocked phone, and QuickTime not showing the phone. The feed runs at 60 Hz at most, so a 120 Hz screen is measured at half its rate, and during fast motion the phone sometimes skips a frame of the feed: those refreshes are reported as unknown, never as jank. (Instruments' Animation Hitches would measure the app's own frames, but its recordings hang while looktap's device runner is up.)
Catch a spinner, toast, or transition (burst)
Start the burst in the background, then trigger the action:
looktap burst ./frames 5 & # 5 s: a frame each time the screen changes
looktap swipe 200 250 200 700 # pull to refresh
wait
# 61 frames, 23 distinct, in ./frames
For a video of a whole flow, record it. The recording runs in the background between the two commands:
looktap record start checkout # → ./shots/checkout.mp4
looktap tap "Add to cart" --ls
looktap tap "Checkout" --ls
looktap record stop # prints the path and the length
- Android records with the device's
screenrecord: at most 3 minutes, andFLAG_SECUREscreens come out black. - iOS records with
simctl io recordVideo. A recording started in landscape is marked to play turned.
Start from a clean state: install, reset, permissions
looktap install build/MyApp.app # iOS: a simulator build · Android: an .apk (keeps the app's data)
# installed com.example.myapp
looktap reset com.example.myapp # as if freshly installed: data and permissions cleared
looktap permission grant location com.example.myapp # no prompt
looktap permission revoke photos com.example.myapp # test the "denied" path
looktap permission reset camera com.example.myapp # it asks again on next use
looktap stop com.example.myapp # end it; prints "stopped" once its process is gone
looktap uninstall com.example.myapp
reset:- iOS: looktap copies the app's own bundle aside, uninstalls it and installs the copy, so documents, caches, app groups and permissions all go. The keychain survives, just as it does on a real iPhone, so an app that keeps its sign-in token there may still be signed in.
- Android:
pm clear.
- Permission names are the same on both platforms:
camera,microphone,location,location-always,photos,contacts,calendar,notifications,motion,all.- Android also takes any
android.permission.*, and acts only on permissions the app declares. Aresetalso clears "don't ask again". - The iOS simulator can't set
cameraornotifications; looktap says so.
- Android also takes any
- These commands never guess the app from the screen.
stop,uninstall,resetandpermissiontake the app id as an argument, or from--app/looktap.config.json.
Simulated conditions: location, dark mode, text size, orientation, network, push
looktap location 51.5072 -0.1276 # GPS (Android: emulators) · `location clear` on iOS
looktap appearance dark # or light; no argument prints the current one
looktap textsize ax2 # Dynamic Type / font scale: xs s m l xl xxl xxxl ax1…ax5
looktap statusbar clean # 9:41, full battery and Wi-Fi, for screenshots · `statusbar live`
looktap rotate landscape --ls # or left, right, upsidedown, portrait; no argument prints it
looktap network offline # Android: airplane mode · `network online`
looktap push com.example.myapp "Your order has shipped" # iOS: an alert
looktap push com.example.myapp '{"aps":{"alert":"Hi"},"orderId":7}' # or any payload / a .json file
textsizeuses the iOS Dynamic Type sizes. On Android each size maps to the nearest font scale, from 0.8 to 2.0, the default beingl/largeon both.statusbar cleanuses iOS status-bar overrides, and Android's System UI demo mode.rotateturns the device the way Simulator's Rotate Left/Right does, or locks Android's rotation.landscapeisleft: the device turned counter-clockwise.lscoordinates, taps and screenshots follow the turned screen, so read the screen again afterwards. An app that doesn't support the orientation stays as it is, androtatesays so. On Android it leaves auto-rotate off.network offlineturns on Android's airplane mode and waits until the device has no route out. The iOS simulator shares the Mac's network, so it can't go offline or slow by itself.pushdelivers a remote notification the way APNs would. The app needs notification permission, as on a device.- A payload without
"aps"is sent as a silent (background) push carrying that data. - Android pushes can't be simulated locally. They come through Firebase (FCM), so send them from your backend or the Firebase console.
- A payload without
Biometrics
looktap bio match # iOS: Face ID success · Android: enrolled finger 1
looktap bio nomatch # iOS: Face ID failure · Android: unknown finger
On Android, enrol a fingerprint once first: Settings → Security → Fingerprint, using a screen-lock PIN, and touch
with looktap bio match when it asks.
Same screen on several devices
looktap boot "iPhone 14" --headless && looktap boot "iPhone 16 Pro" --headless
looktap devices
# ios 42E41FA7-… iPhone 14 · simulator · iOS 26.0 · headless
# ios F9213EDC-… iPhone 16 Pro · simulator · iOS 26.0 · headless
# android emulator-5554 Pixel_9a · emulator · Android 16 · window
for d in 42E41FA7-EA40-45C1-8C6E-13A70BAF1FD6 F9213EDC-180D-412B-B24C-2FD69A37D3AC; do
looktap -d $d open myapp://home
looktap -d $d wait "Home"
looktap -d $d shot -o shots/$d home
done
Passing both -p and -d (or exporting LOOKTAP_PLATFORM and LOOKTAP_DEVICE) makes every command about
0.2 s faster, because looktap then skips listing devices.
A repeatable screenshot sweep
#!/bin/sh
set -e
looktap launch
for route in home search messages profile settings; do
looktap open "myapp://$route" --ls > /dev/null
looktap shot "$route"
looktap swipe 200 700 200 250
looktap shot "${route}__scrolled"
done
Record a flow (codegen)
On Android you can write a flow by using the app. codegen restarts the app, then turns each touch into the step
that replays it:
$ looktap codegen flows/about.txt com.android.settings
+ stop com.android.settings
+ launch com.android.settings
+ wait Settings
recording com.android.settings to flows/about.txt — use the phone. …
+ scrollto "About phone"
+ tap "About phone"
+ back
+ tap "Search settings"
+ fill Search wifi
expect "Wi-Fi"
ok: "Wi-Fi" at 107,148
+ expect Wi-Fi
saved 9 steps to flows/about.txt — run it: looktap test flows/about.txt
- Taps are saved by label: a tap anywhere on a row is a tap on the row's label, when
tapwith that label finds the same row again. Where no label does (an icon with no accessibility label), it's saved astapxy, with a note. - Typing is one
fill, with the field's name and its final text. Text typed into a password field isn't saved: the step reads it from an environment variable instead (fill Password "${PASSWORD}"). - Scrolling that brought a row into view becomes
scrollto, which replays on any screen size. Other swipes (a carousel, a map) stay swipes. The navigation bar's Back and Home, and gesture navigation, becomebackandhome. - Checks: type a command into the terminal while it records, e.g.
expect "Welcome". It's run, and saved if it passes.undodrops the last step.
It reads the touchscreen through adb shell getevent, so it records real fingers (not taps sent by looktap itself).
It isn't available on iOS yet: write the flow by hand there, or record it on Android if the app is the same.
Test flows in CI
A flow file is a run script: one looktap command per line, # for comments. Use wait where the app takes
time and expect where the flow should check something:
# flows/sign-in.txt
launch com.example.myapp
wait "Sign in"
fill Email [email protected]
tap "Continue"
wait Welcome
expect "Sign in" --gone
$ looktap test flows/ -o results --junit results/junit.xml
✓ flows/browse.txt 9 steps · 6.1 s
✗ flows/checkout.txt 3.4 s
step 5 (expect Total = €4.20): expect failed: "Total" is "€0.00", expected "€4.20" (waited 3 s)
│ $ tap "Add to bag"
│ tapped "Add to bag" at 195,612
│ …
evidence: results/failures/flows-checkout
✓ flows/sign-in.txt 6 steps · 4.8 s
looktap: 3 flows: 2 passed, 1 failed · 14.3 s · results/junit.xml
Flows run in order on one device, each from wherever the last one left the app, so start each with launch (or
reset <app> and launch for a fresh install). Steps wait for their targets, but
a flow that depends on a backend can still be slow: raise --wait rather than adding sleeps.
Flow files live in your repo, so never put real credentials in one. Sign in with a test-only account, and keep its
password out of the file: test replaces ${NAME} with that environment variable (a CI secret), e.g.
fill Password "${PASSWORD}".
In GitHub Actions, iOS runs on a macOS runner and Android on a Linux runner with KVM:
jobs:
ios:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g @looktap/cli
- run: xcodebuild -scheme MyApp -sdk iphonesimulator -derivedDataPath build build
- run: looktap boot "iPhone 16" --headless
- run: looktap install build/Build/Products/Debug-iphonesimulator/MyApp.app
- run: looktap test flows/ -o results --junit results/junit.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: ios-results, path: results }
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g @looktap/cli
- run: ./gradlew assembleDebug
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
script: |
looktap -p android install app/build/outputs/apk/debug/app-debug.apk --grant
looktap -p android test flows/ -o results --junit results/junit.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: android-results, path: results }
Upload results/ even when the job fails: that's where the failed flows' screenshots and logs are.
Android screenshots of secure screens
Apps that set FLAG_SECURE (common in banking and payment apps) make normal Android screenshots come out black. On
emulators, looktap shot takes the frame from the emulator itself, which gets past that. --plain takes a normal
on-device screenshot, for example to check that a screen is secure.
Generated from README.md. Edit it there.