device-devtools-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env bash
2
+ # app.sh — the app under test's lifecycle.
3
+ #
4
+ # app.sh installed <id> is some other app present?
5
+ # app.sh state report whether it is running, and how
6
+ # app.sh launch bring it up (no-op if already foreground)
7
+ # app.sh kill terminate it
8
+ # app.sh relaunch kill then launch, keeping stored data
9
+ # app.sh reset uninstall and reinstall, discarding all data
10
+ # app.sh install [path] [--launch]
11
+ # install a built .app bundle onto the configured device
12
+ # app.sh uninstall [id] remove it
13
+ # app.sh build build it from source
14
+ #
15
+ # INSTALL GOES TO THE DEVICE THE CONFIG SELECTS, and takes no UDID of its own.
16
+ # Two phones on one desk is the normal case; installing onto one and reading the
17
+ # other produces a session spent debugging a build that is not there, with
18
+ # nothing anywhere saying so.
19
+ #
20
+ # Lifecycle goes through WebDriverAgent rather than the command line. It is
21
+ # already connected, it is faster than shelling out, and it works the same way
22
+ # on a device where `devicectl` does not (see README). Installation goes through
23
+ # ios-deploy, because WebDriverAgent cannot install.
24
+ #
25
+ # Every state-changing verb verifies afterwards by reading the app's state back,
26
+ # rather than trusting that the request was honoured.
27
+ #
28
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
29
+ # read that would have been the next call is folded into this one. In a measured
30
+ # session — log in, open Settings — twelve calls did the work and six of them
31
+ # were snapshots taken only to find out what the previous action produced.
32
+ #
33
+ # exit 0 done, and verified
34
+ # exit 1 usage error
35
+ # exit 2 not configured for this verb, or a build/install failure
36
+ # exit 3 WebDriverAgent unreachable — run doctor.sh
37
+ # exit 4 the app did not reach the expected state
38
+
39
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
40
+
41
+ CMD="${1-}"
42
+ [ -n "$CMD" ] || die "missing verb (usage: app.sh state|launch|kill|relaunch|reset|install|uninstall|build)" 1
43
+ case "$CMD" in
44
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
45
+ esac
46
+ shift
47
+
48
+ # --snapshot IS ANSWERED ONCE, HERE, AND NOT BY EVERY VERB SEPARATELY.
49
+ #
50
+ # Each verb below counts its own arguments and refuses a stray one, which is
51
+ # right and is also why a flag that applies to all of them cannot be added to
52
+ # all of them: it would have to be subtracted from eight separate `[ $# -eq 0 ]`
53
+ # guards. Taken out of the list up front, every verb sees exactly what it saw
54
+ # before, and the screen is printed at the end of whichever one ran.
55
+ DT_ARGV=()
56
+ for a in ${@+"$@"}; do
57
+ case "$a" in
58
+ --snapshot) act_snapshot_on ;;
59
+ *) DT_ARGV+=("$a") ;;
60
+ esac
61
+ done
62
+ set -- ${DT_ARGV[@]+"${DT_ARGV[@]}"}
63
+
64
+ load_config
65
+
66
+ # Launch and terminate go through XCUITest, which waits for the app to reach an
67
+ # idle state before it answers. Terminating the app under test was measured at
68
+ # ~60s. The read timeout would abort long before that and report a driver that
69
+ # is working normally as stuck.
70
+ DT_HTTP_TIMEOUT="$(cfg '.timeouts.app_seconds' '90')"
71
+ export DT_HTTP_TIMEOUT
72
+
73
+ BUNDLE="$(cfg '.app.bundle_id' '')"
74
+ [ -n "$BUNDLE" ] \
75
+ || die "app.bundle_id is not set in $DT_CONFIG — every verb here needs it" 2
76
+
77
+ # WebDriverAgent's application states. Naming them is the difference between a
78
+ # report a caller can act on and an integer they have to look up.
79
+ # device_name_note <udid> — " (Name)" when the phone will say, else nothing.
80
+ # Cosmetic, and never allowed to fail: it exists so a person reading "installed
81
+ # onto 00008030-…" can tell which handset that is.
82
+ device_name_note() {
83
+ local n
84
+ n="$(ideviceinfo -u "$1" -k DeviceName 2>/dev/null || true)"
85
+ [ -n "$n" ] && printf ' (%s)' "$n"
86
+ return 0
87
+ }
88
+
89
+ # app_path_default — the path to reinstall from, in order of how much somebody
90
+ # meant it: what was passed, what the config names, then what this checkout last
91
+ # installed. The last one is the useful one in a build-install-drive loop, where
92
+ # `reset` a minute after `install` should not have to be told the same path
93
+ # twice.
94
+ app_path_default() {
95
+ local given="${1-}" p
96
+ if [ -n "$given" ]; then printf '%s' "$given"; return 0; fi
97
+ p="$(cfg '.app.app_path' '')"
98
+ if [ -n "$p" ]; then printf '%s' "$p"; return 0; fi
99
+ p="$(cut -f6 "$(install_record)" 2>/dev/null || true)"
100
+ [ -n "$p" ] && [ -d "$p" ] && printf '%s' "$p"
101
+ return 0
102
+ }
103
+
104
+ # reset_launch — bring it up when --launch asked. A reset almost always wants
105
+ # the app in front afterwards; the flag is still explicit, because "start from
106
+ # nothing" and "start" are the two halves this verb deliberately keeps apart.
107
+ reset_launch() {
108
+ [ "${LAUNCH_AFTER:-0}" -eq 1 ] || return 0
109
+ local resp err s
110
+ wda_drop_session
111
+ resp="$(session_post '/wda/apps/launch' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
112
+ err="$(session_error "$resp")"
113
+ [ -z "$err" ] || die "reset, but launch failed — $err" 3
114
+ s="$(wait_for_state foreground "reset and launched but never reached the foreground")"
115
+ printf 'OK launched %s — %s\n' "$BUNDLE" "$(state_name "$s")"
116
+ return 0
117
+ }
118
+
119
+ state_name() {
120
+ case "$1" in
121
+ 1) printf 'not running' ;;
122
+ 2) printf 'suspended' ;;
123
+ 3) printf 'running in background' ;;
124
+ 4) printf 'running in foreground' ;;
125
+ *) printf 'unknown (%s)' "$1" ;;
126
+ esac
127
+ }
128
+
129
+ app_state() {
130
+ local resp
131
+ resp="$(session_post '/wda/apps/state' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
132
+ printf '%s' "$resp" | jq -r 'if (.value | type) == "number" then .value else (.value.value // 0) end' 2>/dev/null || printf '0'
133
+ }
134
+
135
+ # wait_state <predicate-description> <test-expression-on-$s>
136
+ # Polls rather than sleeping, for the reason wait.sh exists.
137
+ wait_for_state() {
138
+ local want="$1" desc="$2" s deadline=0
139
+ while [ "$deadline" -lt 20 ]; do
140
+ s="$(app_state)"
141
+ case "$want" in
142
+ foreground) [ "$s" = 4 ] && { printf '%s' "$s"; return 0; } ;;
143
+ stopped) [ "$s" = 1 ] && { printf '%s' "$s"; return 0; } ;;
144
+ esac
145
+ deadline=$((deadline + 1))
146
+ sleep 0.3
147
+ done
148
+ printf '%s' "$s"
149
+ die "$desc — app is $(state_name "$s")" 4
150
+ }
151
+
152
+ case "$CMD" in
153
+ # installed <bundle-id> — is some OTHER app on this device?
154
+ #
155
+ # Every other verb here acts on the app under test. This one answers about an
156
+ # app a test merely depends on — a browser for a deep link, Maps for a
157
+ # hand-off — so a test can declare what it needs and be skipped where that is
158
+ # absent, rather than failing as though the product were broken.
159
+ #
160
+ # exit 4 for absent rather than exit 1, because "not there" is an answer.
161
+ installed)
162
+ [ $# -eq 1 ] || die "app.sh installed needs exactly one bundle id" 1
163
+ if [ "$(ios_kind)" = simulator ]; then
164
+ if xcrun simctl get_app_container "$(cfg '.device.udid')" "$1" app >/dev/null 2>&1; then
165
+ printf 'OK %s — installed\n' "$1"
166
+ else
167
+ die "$1 is not installed on the simulator" 4
168
+ fi
169
+ elif ! command -v ios-deploy >/dev/null 2>&1; then
170
+ die "cannot check for $1 — ios-deploy not found" 2
171
+ elif ios-deploy --id "$(cfg '.device.udid')" --no-wifi --exists --bundle_id "$1" >/dev/null 2>&1; then
172
+ printf 'OK %s — installed\n' "$1"
173
+ else
174
+ die "$1 is not installed on $(cfg '.device.udid')" 4
175
+ fi ;;
176
+
177
+ state)
178
+ [ $# -eq 0 ] || die "app.sh state takes no arguments" 1
179
+ s="$(app_state)"
180
+ printf 'OK %s — %s\n' "$BUNDLE" "$(state_name "$s")"
181
+ ;;
182
+
183
+ launch)
184
+ # An explicit bundle id, for an app that is not the one under test — and for
185
+ # any app at all that registers no URL scheme, where `open` has nothing to
186
+ # work with. WebDriverAgent asks the OS by identifier, so no icon is
187
+ # involved and the home screen is not in the way.
188
+ [ $# -le 1 ] || die "app.sh launch takes at most one bundle id" 1
189
+ [ $# -eq 1 ] && BUNDLE="$1"
190
+ # A cached session may reference a process that no longer exists.
191
+ wda_drop_session
192
+ resp="$(session_post '/wda/apps/launch' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
193
+ err="$(session_error "$resp")"
194
+ [ -z "$err" ] || die "launch failed — $err" 3
195
+ s="$(wait_for_state foreground "launched but never reached the foreground")"
196
+ printf 'OK launched %s — %s\n' "$BUNDLE" "$(state_name "$s")"
197
+ ;;
198
+
199
+ kill)
200
+ [ $# -eq 0 ] || die "app.sh kill takes no arguments" 1
201
+ resp="$(session_post '/wda/apps/terminate' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
202
+ err="$(session_error "$resp")"
203
+ [ -z "$err" ] || die "terminate failed — $err" 3
204
+ s="$(wait_for_state stopped "terminate was accepted but the app is still alive")"
205
+ wda_drop_session
206
+ printf 'OK killed %s — %s\n' "$BUNDLE" "$(state_name "$s")"
207
+ ;;
208
+
209
+ relaunch)
210
+ [ $# -eq 0 ] || die "app.sh relaunch takes no arguments" 1
211
+ # Terminating an app that is not running is not an error worth stopping for:
212
+ # the point of relaunch is the end state, not the path to it.
213
+ session_post '/wda/apps/terminate' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')" >/dev/null
214
+ wait_for_state stopped "could not terminate before relaunching" >/dev/null
215
+ wda_drop_session
216
+ resp="$(session_post '/wda/apps/launch' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
217
+ err="$(session_error "$resp")"
218
+ [ -z "$err" ] || die "relaunch failed at the launch step — $err" 3
219
+ s="$(wait_for_state foreground "relaunched but never reached the foreground")"
220
+ printf 'OK relaunched %s — %s\n' "$BUNDLE" "$(state_name "$s")"
221
+ ;;
222
+
223
+ reset)
224
+ # relaunch keeps the app's container; reset does not. They are separate verbs
225
+ # rather than a flag because "start the app" and "start from nothing" are
226
+ # different intentions, and a test that needed the second while getting the
227
+ # first fails somewhere far away from the cause.
228
+ #
229
+ # On iOS there is no supported way to clear another app's data in place on a
230
+ # device that is not jailbroken, so this is uninstall followed by install,
231
+ # which is why it needs the built bundle.
232
+ LAUNCH_AFTER=0
233
+ ARGS=""
234
+ while [ $# -gt 0 ]; do
235
+ case "$1" in
236
+ --launch) LAUNCH_AFTER=1; shift ;;
237
+ -*) die "unknown argument: $1 (usage: app.sh reset [path] [--launch])" 1 ;;
238
+ *) [ -z "$ARGS" ] || die "app.sh reset takes one path" 1
239
+ ARGS="$1"; shift ;;
240
+ esac
241
+ done
242
+ set -- ${ARGS:+"$ARGS"}
243
+ udid="$(device_udid)"
244
+ wda_drop_session
245
+
246
+ # A simulator can erase an app's data in place, so reset does not need the
247
+ # built bundle there. On a device there is no supported way to do that
248
+ # without a jailbreak, which is why the device path is uninstall-reinstall
249
+ # and asks for the .app.
250
+ if [ "$(ios_kind)" = simulator ]; then
251
+ xcrun simctl uninstall "$udid" "$BUNDLE" >/dev/null 2>&1 \
252
+ || die "simctl could not uninstall $BUNDLE" 2
253
+ APP_PATH="$(app_path_default "${1-}")"
254
+ if [ -n "$APP_PATH" ]; then
255
+ APP_PATH="$(expand_path "$APP_PATH")"
256
+ [ -d "$APP_PATH" ] || die "not a bundle directory: $APP_PATH" 2
257
+ xcrun simctl install "$udid" "$APP_PATH" >/dev/null 2>&1 \
258
+ || die "uninstalled $BUNDLE but could not reinstall from $APP_PATH" 2
259
+ printf 'OK reset %s — uninstalled and reinstalled from %s\n' "$BUNDLE" "$APP_PATH"
260
+ reset_launch
261
+ else
262
+ printf 'OK reset %s — uninstalled; no app_path configured, so it was not reinstalled\n' "$BUNDLE"
263
+ fi
264
+ # The only branch that leaves before the end of the file, so it prints the
265
+ # screen for itself.
266
+ act_snapshot_now
267
+ exit 0
268
+ fi
269
+
270
+ need_cmd ios-deploy
271
+ APP_PATH="$(app_path_default "${1-}")"
272
+ [ -n "$APP_PATH" ] \
273
+ || die "reset needs the built .app — pass a path, set app.app_path in $DT_CONFIG, or run $(as_cmd app install) once and it will remember" 2
274
+ APP_PATH="$(expand_path "$APP_PATH")"
275
+ [ -d "$APP_PATH" ] || die "not a bundle directory: $APP_PATH" 2
276
+ ios-deploy --id "$udid" --no-wifi --uninstall_only --bundle_id "$BUNDLE" >/dev/null 2>&1 || true
277
+ ios-deploy --id "$udid" --no-wifi --exists --bundle_id "$BUNDLE" >/dev/null 2>&1 \
278
+ && die "uninstall did not remove $BUNDLE — is the device unlocked?" 4
279
+ ios-deploy --id "$udid" --no-wifi --bundle "$APP_PATH" >/dev/null 2>&1 \
280
+ || die "uninstalled $BUNDLE but could not reinstall from $APP_PATH — the device now has no copy of the app" 2
281
+ ios-deploy --id "$udid" --no-wifi --exists --bundle_id "$BUNDLE" >/dev/null 2>&1 \
282
+ || die "reinstall reported success but $BUNDLE is not present" 4
283
+ printf 'OK reset %s — reinstalled from %s\n' "$BUNDLE" "$APP_PATH"
284
+ reset_launch
285
+ ;;
286
+
287
+ install)
288
+ LAUNCH_AFTER=0
289
+ ARGS=""
290
+ while [ $# -gt 0 ]; do
291
+ case "$1" in
292
+ --launch) LAUNCH_AFTER=1; shift ;;
293
+ -*) die "unknown argument: $1 (usage: app.sh install [path] [--launch])" 1 ;;
294
+ *) [ -z "$ARGS" ] || die "app.sh install takes one path" 1
295
+ ARGS="$1"; shift ;;
296
+ esac
297
+ done
298
+ APP_PATH="$(app_path_default "$ARGS")"
299
+ [ -n "$APP_PATH" ] \
300
+ || die "no .app to install — pass a path or set app.app_path in $DT_CONFIG" 2
301
+ APP_PATH="$(expand_path "$APP_PATH")"
302
+ [ -d "$APP_PATH" ] || die "not a bundle directory: $APP_PATH" 2
303
+
304
+ facts="$(bundle_facts "$APP_PATH")" \
305
+ || die "no Info.plist in $APP_PATH — that is not an app bundle" 2
306
+ IFS='|' read -r B_ID B_VER B_BLD B_TEAM B_SIG <<< "$facts"
307
+ udid="$(device_udid)"
308
+ kind="$(ios_kind)"
309
+
310
+ # THE ONE ERROR WORTH CATCHING BEFORE THE OS GIVES ITS VERSION OF IT.
311
+ #
312
+ # A bundle built with CODE_SIGNING_ALLOWED=NO installs onto a simulator and
313
+ # is refused by a device, and the refusal reads `ApplicationVerificationFailed`
314
+ # — which sends people to look at profiles, entitlements and certificates,
315
+ # none of which are the problem. Makefiles set that flag; this is not rare.
316
+ if [ "$kind" != simulator ] && [ "$B_SIG" = unsigned ]; then
317
+ die "bundle is unsigned — build likely used CODE_SIGNING_ALLOWED=NO. A device will refuse it; a simulator would not. Rebuild with signing on, or install to a simulator" 2
318
+ fi
319
+
320
+ if [ -n "$B_ID" ] && [ "$B_ID" != "$BUNDLE" ]; then
321
+ printf 'NOTE the bundle is %s, and app.bundle_id says %s — every other verb will still drive %s\n' \
322
+ "$B_ID" "$BUNDLE" "$BUNDLE"
323
+ fi
324
+
325
+ if [ "$kind" = simulator ]; then
326
+ xcrun simctl install "$udid" "$APP_PATH" >/dev/null 2>&1 \
327
+ || die "simctl could not install $APP_PATH — is the simulator booted?" 2
328
+ xcrun simctl get_app_container "$udid" "${B_ID:-$BUNDLE}" app >/dev/null 2>&1 \
329
+ || die "install reported success but ${B_ID:-$BUNDLE} is not present — check the bundle id in $DT_CONFIG" 4
330
+ else
331
+ need_cmd ios-deploy
332
+ # The whole output, on failure only. installd's reason is in there and
333
+ # abbreviating it would throw away the one line that explains anything.
334
+ out="$(ios-deploy --id "$udid" --no-wifi --bundle "$APP_PATH" 2>&1)" || {
335
+ printf '%s\n' "$out" >&2
336
+ die "ios-deploy could not install $APP_PATH onto $udid — the output above is installd's reason; the device must be unlocked and trusting this Mac" 2
337
+ }
338
+ ios-deploy --id "$udid" --no-wifi --exists --bundle_id "${B_ID:-$BUNDLE}" >/dev/null 2>&1 \
339
+ || die "install reported success but ${B_ID:-$BUNDLE} is not present — check the bundle id in $DT_CONFIG" 4
340
+ fi
341
+
342
+ # What went on, and where. Written down as well as printed, because the
343
+ # device will not tell anyone afterwards.
344
+ when="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
345
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
346
+ "${B_ID:-$BUNDLE}" "$B_VER" "$B_BLD" "$B_TEAM" "$when" "$APP_PATH" > "$(install_record)"
347
+
348
+ printf 'OK installed %s\n' "${B_ID:-$BUNDLE}"
349
+ printf ' version %s (%s)\n' "${B_VER:-?}" "${B_BLD:-?}"
350
+ printf ' signed %s%s\n' "$B_SIG" "${B_TEAM:+, team $B_TEAM}"
351
+ printf ' onto %s%s\n' "$udid" "$(device_name_note "$udid")"
352
+ printf ' from %s\n' "$APP_PATH"
353
+
354
+ if [ "$LAUNCH_AFTER" -eq 1 ]; then
355
+ # What was installed is what gets launched and watched. wait_for_state
356
+ # reads $BUNDLE, so pointing it at the bundle just installed is what keeps
357
+ # the verification about the same app as the action.
358
+ BUNDLE="${B_ID:-$BUNDLE}"
359
+ wda_drop_session
360
+ resp="$(session_post '/wda/apps/launch' "$(jq -nc --arg b "$BUNDLE" '{bundleId: $b}')")"
361
+ err="$(session_error "$resp")"
362
+ [ -z "$err" ] || die "installed, but launch failed — $err" 3
363
+ s="$(wait_for_state foreground "installed and launched but never reached the foreground")"
364
+ printf 'OK launched %s — %s\n' "$BUNDLE" "$(state_name "$s")"
365
+ fi
366
+ ;;
367
+
368
+ uninstall)
369
+ [ $# -le 1 ] || die "app.sh uninstall takes at most one bundle id" 1
370
+ TARGET="${1:-$BUNDLE}"
371
+ udid="$(device_udid)"
372
+ if [ "$(ios_kind)" = simulator ]; then
373
+ xcrun simctl uninstall "$udid" "$TARGET" >/dev/null 2>&1 \
374
+ || die "simctl could not uninstall $TARGET" 2
375
+ else
376
+ need_cmd ios-deploy
377
+ ios-deploy --id "$udid" --no-wifi --uninstall_only --bundle_id "$TARGET" >/dev/null 2>&1 \
378
+ || die "ios-deploy could not uninstall $TARGET — is it installed?" 2
379
+ ios-deploy --id "$udid" --no-wifi --exists --bundle_id "$TARGET" >/dev/null 2>&1 \
380
+ && die "uninstall reported success but $TARGET is still present" 4
381
+ fi
382
+ [ "$TARGET" = "$(cut -f1 "$(install_record)" 2>/dev/null || true)" ] \
383
+ && rm -f "$(install_record)"
384
+ printf 'OK uninstalled %s from %s\n' "$TARGET" "$udid"
385
+ ;;
386
+
387
+ build)
388
+ [ $# -eq 0 ] || die "app.sh build takes no arguments" 1
389
+ BUILD_CMD="$(cfg '.app.build_command' '')"
390
+ [ -n "$BUILD_CMD" ] \
391
+ || die "app.build_command is not set in $DT_CONFIG — DeviceTools does not guess how to build your app" 2
392
+ # Build output is the one place a wall of text is correct: a compiler error
393
+ # is what the caller needs, and abbreviating it helps nobody.
394
+ ( eval "$BUILD_CMD" ) || die "build failed — see the output above" 2
395
+ printf 'OK built %s\n' "$BUNDLE"
396
+ ;;
397
+
398
+ *)
399
+ die "unknown verb '$CMD' (usage: app.sh state|launch|kill|relaunch|reset|install|uninstall|build)" 1
400
+ ;;
401
+ esac
402
+
403
+ # The screen the verb left behind, when it was asked for.
404
+ act_snapshot_now
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # back.sh — go back one screen.
3
+ #
4
+ # back.sh [--method nav|swipe]
5
+ #
6
+ # Android has a system back button. iOS does not, and pretending otherwise is
7
+ # how a cross-platform abstraction starts lying. On iOS "back" is either the
8
+ # navigation bar's leading button or an edge swipe, and which one works depends
9
+ # on the app.
10
+ #
11
+ # So this tries the navigation bar first and falls back to the edge swipe, and
12
+ # it says which one it used. An honest heuristic, named as a heuristic, beats a
13
+ # silent one — when a test fails you can see whether the gesture was even the
14
+ # right idea.
15
+ #
16
+ # It then verifies that the screen actually changed. A back that does nothing is
17
+ # worse than a back that fails: the test carries on believing it moved.
18
+ #
19
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
20
+ # read that would have been the next call is folded into this one. In a measured
21
+ # session — log in, open Settings — twelve calls did the work and six of them
22
+ # were snapshots taken only to find out what the previous action produced.
23
+ #
24
+ # exit 0 went back, and the screen changed
25
+ # exit 1 usage error
26
+ # exit 3 WebDriverAgent unreachable — run doctor.sh
27
+ # exit 4 nothing to go back to, or the screen did not change
28
+
29
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
30
+
31
+ METHOD=auto
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --method)
35
+ [ $# -ge 2 ] || die "--method needs nav or swipe" 1
36
+ case "$2" in nav|swipe) METHOD="$2" ;; *) die "--method must be nav or swipe, got: $2" 1 ;; esac
37
+ shift 2 ;;
38
+ --snapshot) act_snapshot_on; shift ;;
39
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
40
+ *) die "unknown argument: $1 (usage: back.sh [--method nav|swipe])" 1 ;;
41
+ esac
42
+ done
43
+
44
+ load_config
45
+
46
+ src="$(fetch_source)"
47
+ before="$(drv_fingerprint "$src")"
48
+ act_before
49
+
50
+ read -r WIN_W WIN_H <<< "$(window_size)"
51
+
52
+ # The leading button of a navigation bar: a Button inside a NavigationBar,
53
+ # within the left third of the screen. Matching by position rather than by label
54
+ # is deliberate — the back button's label is the previous screen's title, which
55
+ # changes with every screen and with the app's language.
56
+ nav_button="$(printf '%s' "$src" | jq -r --argjson third "$((WIN_W / 3))" "$JQ_CLEAN"'
57
+ def bar_children($inbar):
58
+ . as $n
59
+ | (if ($n.type // "") == "NavigationBar" then true else $inbar end) as $under
60
+ | (if $under and (($n.type // "") == "Button")
61
+ and ((($n.isVisible // "") == "1"))
62
+ and ((.rect.x // 0) + ((.rect.width // 0) / 2) < $third)
63
+ and ((.rect.width // 0) > 0)
64
+ then [{x: (.rect.x // 0), y: (.rect.y // 0), w: (.rect.width // 0), h: (.rect.height // 0),
65
+ lb: (.label | clean)}] else [] end)
66
+ + ([ (.children // [])[] | bar_children($under) ] | add // []);
67
+ (.value | bar_children(false)) | .[0] // empty
68
+ | "\(.x + (.w / 2) | floor)|\(.y + (.h / 2) | floor)|\(.lb)"
69
+ ' 2>/dev/null || true)"
70
+
71
+ used=""
72
+ if [ "$METHOD" != swipe ] && [ -n "$nav_button" ]; then
73
+ IFS='|' read -r BX BY BLB <<< "$nav_button"
74
+ err="$(drv_tap "$BX" "$BY" || true)"
75
+ [ -z "$err" ] || die "tapping the navigation back button at $BX,$BY failed — $err" 3
76
+ used="navigation bar button at $BX,$BY${BLB:+ \"$BLB\"}"
77
+ elif [ "$METHOD" = nav ]; then
78
+ die "no navigation bar back button on this screen — try --method swipe" 4
79
+ else
80
+ FY=$(( WIN_H / 2 ))
81
+ TX=$(( WIN_W * 60 / 100 ))
82
+ err="$(drv_drag 2 "$FY" "$TX" "$FY" 100 || true)"
83
+ [ -z "$err" ] || die "edge swipe failed — $err" 3
84
+ used="edge swipe from 2,$FY"
85
+ fi
86
+
87
+ after="$(drv_fingerprint "$(fetch_source)")"
88
+ [ "$before" != "$after" ] \
89
+ || die "went back using the $used but the screen did not change — there may be nothing to go back to" 4
90
+
91
+ # back already refuses when the screen did not move, so the SCREEN line here
92
+ # is not the verdict — it is which screen you have arrived at, and what the app
93
+ # said on the way.
94
+ act_report BACK "$used"
95
+ journal_append back "" "" "" "" "" 0 0 0 0 "$DT_ACT_HASH"
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # controls.sh — the things on this screen you can actually act on.
3
+ #
4
+ # controls.sh [--all]
5
+ #
6
+ # One line per interactive element, in find.sh's columns:
7
+ #
8
+ # type | id | label | text | x,y,w,h
9
+ #
10
+ # This is usually a better first question than `tree`. A screen with 150
11
+ # elements has perhaps a dozen you can tap, and those dozen are what a test is
12
+ # written against; the rest is layout.
13
+ #
14
+ # HOW THIS DIFFERS FROM THE ANDROID ADAPTER, AND WHY IT IS WEAKER
15
+ #
16
+ # Android publishes `clickable` on the element, so its answer is exact. iOS does
17
+ # not publish anything equivalent, so this infers from the element's type — and
18
+ # the type is already known to be unreliable here: on the app this was built
19
+ # against, the same password field appeared as a plain TextField on one launch
20
+ # and a SecureTextField on the next.
21
+ #
22
+ # So this list is a good starting point and not a guarantee. A control missing
23
+ # from it may still be tappable, and `tap` on a coordinate always works. Said
24
+ # plainly rather than left for someone to discover.
25
+ #
26
+ # --all adds container types that are often tappable rows in practice.
27
+ #
28
+ # exit 0 at least one control
29
+ # exit 1 usage error
30
+ # exit 3 WebDriverAgent unreachable — run doctor.sh
31
+ # exit 4 nothing interactive on screen
32
+
33
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
34
+
35
+ ALL=0
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --all) ALL=1; shift ;;
39
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
40
+ *) die "unknown argument: $1 (usage: controls.sh [--all])" 1 ;;
41
+ esac
42
+ done
43
+
44
+ load_config
45
+ src="$(fetch_source)"
46
+
47
+ # The types a user can act on live in lib.sh, because tree.sh needs the same
48
+ # list to avoid hiding an unlabelled control.
49
+ CORE="$DT_TAPPABLE_TYPES"
50
+ EXTRA='Cell|CollectionView|Tab|TabBar|NavigationBar|Icon|Image'
51
+
52
+ if [ "$ALL" -eq 1 ]; then
53
+ WANT="$CORE|$EXTRA"
54
+ else
55
+ WANT="$CORE"
56
+ fi
57
+
58
+ rows="$(printf '%s' "$src" | jq -r --arg want "$WANT" "$JQ_CLEAN"'
59
+ (.value | walk(0))
60
+ | map(select(.vis and .w > 0 and .h > 0))
61
+ | map(select(.t | test("^(" + $want + ")$")))
62
+ | .[]
63
+ | "\(.t) | \(.id) | \(.lb) | \(.v) | \(.x),\(.y),\(.w),\(.h)"
64
+ ')"
65
+
66
+ [ -n "$rows" ] || die "nothing interactive on screen — is the app in the foreground?" 4
67
+
68
+ printf '%s\n' "$rows"
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # devices.sh — what is attached, one per line.
3
+ #
4
+ # devices.sh [--ready]
5
+ #
6
+ # Output, pipe-separated so it parses without a JSON reader:
7
+ #
8
+ # <udid> | <kind> | <name> | <state>
9
+ #
10
+ # kind is device or simulator. state is ready, not-paired or locked. A device
11
+ # that is attached but not usable is still listed, with the reason, because
12
+ # "nothing attached" and "attached but you never tapped Trust" call for
13
+ # completely different actions.
14
+ #
15
+ # Simulators are listed only when booted. A shut-down simulator is not a device
16
+ # anything can drive, and listing it would put an unusable entry into a fleet.
17
+ #
18
+ # --ready lists only the ones that can be driven.
19
+ #
20
+ # exit 0 at least one device listed
21
+ # exit 1 usage error
22
+ # exit 2 neither libimobiledevice nor xcrun is available
23
+ # exit 4 nothing attached (or nothing ready, with --ready)
24
+
25
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
26
+
27
+ READY_ONLY=0
28
+ while [ $# -gt 0 ]; do
29
+ case "$1" in
30
+ --ready) READY_ONLY=1; shift ;;
31
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
32
+ *) die "unknown argument: $1 (usage: devices.sh [--ready])" 1 ;;
33
+ esac
34
+ done
35
+
36
+ load_config
37
+
38
+ have_imd=0; command -v idevice_id >/dev/null 2>&1 && have_imd=1
39
+ have_xcrun=0; command -v xcrun >/dev/null 2>&1 && have_xcrun=1
40
+ [ "$have_imd" -eq 1 ] || [ "$have_xcrun" -eq 1 ] \
41
+ || die "neither idevice_id nor xcrun is available — brew install libimobiledevice, or install Xcode" 2
42
+
43
+ emit() { # emit <udid> <kind> <name> <state>
44
+ [ "$READY_ONLY" -eq 0 ] || [ "$4" = ready ] || return 0
45
+ printf '%s | %s | %s | %s\n' "$1" "$2" "$3" "$4"
46
+ N=$(( N + 1 ))
47
+ }
48
+ N=0
49
+
50
+ if [ "$have_imd" -eq 1 ]; then
51
+ while IFS= read -r udid; do
52
+ [ -n "$udid" ] || continue
53
+ # ideviceinfo answers only for a paired device that is unlocked enough to be
54
+ # queried, so its failure is the signal, not an error to be swallowed.
55
+ name="$(ideviceinfo -u "$udid" -k DeviceName 2>/dev/null | tr -d '\r')"
56
+ if [ -n "$name" ]; then
57
+ ver="$(ideviceinfo -u "$udid" -k ProductVersion 2>/dev/null | tr -d '\r')"
58
+ emit "$udid" device "${name}${ver:+ (iOS $ver)}" ready
59
+ else
60
+ emit "$udid" device "$udid" not-paired
61
+ fi
62
+ done <<< "$(idevice_id -l 2>/dev/null | tr -d '\r')"
63
+ fi
64
+
65
+ if [ "$have_xcrun" -eq 1 ]; then
66
+ while IFS='|' read -r udid name; do
67
+ [ -n "$udid" ] || continue
68
+ emit "$udid" simulator "$name" ready
69
+ done <<< "$(xcrun simctl list devices -j 2>/dev/null \
70
+ | jq -r '.devices | to_entries[] | .value[]
71
+ | select(.state == "Booted")
72
+ | "\(.udid)|\(.name)"' 2>/dev/null)"
73
+ fi
74
+
75
+ if [ "$N" -eq 0 ]; then
76
+ if [ "$READY_ONLY" -eq 1 ]; then
77
+ die "no device is ready — run $(as_cmd devices) without --ready to see what is attached" 4
78
+ fi
79
+ die "nothing attached — connect a device over USB or boot a simulator" 4
80
+ fi