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.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- 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
|