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,1709 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# DeviceTools — self-test for the platform-neutral layer.
|
|
3
|
+
#
|
|
4
|
+
# scripts/selftest.sh
|
|
5
|
+
#
|
|
6
|
+
# No device, no driver, no config beyond the one every script loads. It exercises
|
|
7
|
+
# the parts of this tool that are pure decisions — path resolution, colour, the
|
|
8
|
+
# tree filter, the geometry arithmetic — because those are the parts that fail
|
|
9
|
+
# silently. A wrong rectangle produces a plausible number on every screen and
|
|
10
|
+
# nobody notices for a month.
|
|
11
|
+
#
|
|
12
|
+
# WHY IT MATTERS THAT THIS NEEDS NO PHONE
|
|
13
|
+
#
|
|
14
|
+
# Everything else here is proven by driving a real device, which means it is
|
|
15
|
+
# proven when somebody is sitting in front of one. This file runs anywhere, so
|
|
16
|
+
# it is the only check that can catch a macOS-ism, a byte-vs-character bug in
|
|
17
|
+
# awk, or a sign error in an overlap, on the day it is introduced.
|
|
18
|
+
#
|
|
19
|
+
# exit 0 every check passed
|
|
20
|
+
# exit 5 at least one failed
|
|
21
|
+
#
|
|
22
|
+
# That 5 is this file's own, and is the one exception to the exit-code contract
|
|
23
|
+
# every verb follows: a test runner reports how many checks failed, and a verb
|
|
24
|
+
# reports what the screen did.
|
|
25
|
+
|
|
26
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
27
|
+
|
|
28
|
+
FAILED=0
|
|
29
|
+
|
|
30
|
+
# A SUITE THAT DIES HALFWAY MUST NOT LOOK LIKE A SUITE THAT PASSED.
|
|
31
|
+
#
|
|
32
|
+
# Everything here runs under `set -e`. A check that calls something undefined —
|
|
33
|
+
# an adapter function, a renamed helper — aborts the script before the verdict
|
|
34
|
+
# block, so nothing prints RESULT and the exit status is whatever the last
|
|
35
|
+
# command left behind. Measured: a call to candidate_elements, which lives in an
|
|
36
|
+
# adapter and not here, ended the run at check 130 of 140 with status 0.
|
|
37
|
+
#
|
|
38
|
+
# A hook rather than `trap ... EXIT`, because a second trap replaces the first
|
|
39
|
+
# and lib.sh already owns that one.
|
|
40
|
+
DT_REACHED_END=0
|
|
41
|
+
selftest_verdict_guard() {
|
|
42
|
+
[ "$DT_REACHED_END" -eq 1 ] && return 0
|
|
43
|
+
printf 'RESULT FAIL neutral-layer selftest — stopped before the verdict, at the check after the last line above\n'
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
dt_cleanup_add_hook selftest_verdict_guard
|
|
47
|
+
|
|
48
|
+
ok() { printf 'OK %s\n' "$1"; }
|
|
49
|
+
bad() { printf 'FAIL %s — %s\n' "$1" "$2"; FAILED=$((FAILED + 1)); }
|
|
50
|
+
is() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3], got [$2]"; fi; }
|
|
51
|
+
# A predicate that must hold, and one that must not. Both take a command.
|
|
52
|
+
holds() { if "${@:2}" >/dev/null 2>&1; then ok "$1"; else bad "$1" "did not hold"; fi; }
|
|
53
|
+
holds_not() { if "${@:2}" >/dev/null 2>&1; then bad "$1" "held when it should not"; else ok "$1"; fi; }
|
|
54
|
+
|
|
55
|
+
# --- paths --------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
is "an absolute path is left alone" "$(expand_path /tmp/x)" "/tmp/x"
|
|
58
|
+
is "a ~ path expands to home" "$(expand_path '~/x')" "$HOME/x"
|
|
59
|
+
is "a relative path joins the project" "$(expand_path x/y)" "$DT_ROOT/x/y"
|
|
60
|
+
holds "the tool and the project are both set" test -d "$DT_HOME/scripts"
|
|
61
|
+
|
|
62
|
+
# --- colour -------------------------------------------------------------------
|
|
63
|
+
#
|
|
64
|
+
# The decision is made once, when lib.sh is sourced, so each case is its own
|
|
65
|
+
# process. Testing it in-process would only re-read a variable already fixed.
|
|
66
|
+
paint_in() { DEVICETOOLS_COLOR="$1" bash -c 'source "$0"; paint "$1"' "$DT_HOME/scripts/lib.sh" "$2"; }
|
|
67
|
+
|
|
68
|
+
is "colour off leaves the line untouched" \
|
|
69
|
+
"$(paint_in never 'RESULT PASS tests/a.yaml 6/6')" 'RESULT PASS tests/a.yaml 6/6'
|
|
70
|
+
case "$(paint_in always 'RESULT PASS tests/a.yaml 6/6')" in
|
|
71
|
+
*$'\033['*) ok "colour on wraps the line in SGR codes" ;;
|
|
72
|
+
*) bad "colour on wraps the line in SGR codes" "no escape sequence" ;;
|
|
73
|
+
esac
|
|
74
|
+
# The quoted-word rule: a status word inside a name or a selector is the
|
|
75
|
+
# product quoting, not asserting, and must not be painted.
|
|
76
|
+
case "$(paint_in always 'STEP OK 5 tap text:OK 2.9s')" in
|
|
77
|
+
*'text:'$'\033['*) bad "a status word inside a selector is not painted" "the selector was painted" ;;
|
|
78
|
+
*) ok "a status word inside a selector is not painted" ;;
|
|
79
|
+
esac
|
|
80
|
+
|
|
81
|
+
# --- cleanup ------------------------------------------------------------------
|
|
82
|
+
#
|
|
83
|
+
# One EXIT trap with a list, because a second `trap … EXIT` replaces the first.
|
|
84
|
+
# That is how 401 return-code files came to be sitting in TMPDIR.
|
|
85
|
+
|
|
86
|
+
t1="$(mktemp)"; t2="$(mktemp)"
|
|
87
|
+
bash -c 'source "$0"; dt_cleanup_add "$1"; dt_cleanup_add "$2"; exit 0' \
|
|
88
|
+
"$DT_HOME/scripts/lib.sh" "$t1" "$t2"
|
|
89
|
+
if [ -e "$t1" ] || [ -e "$t2" ]; then
|
|
90
|
+
bad "every registered path is removed at exit" "something survived"
|
|
91
|
+
rm -f "$t1" "$t2"
|
|
92
|
+
else
|
|
93
|
+
ok "every registered path is removed at exit"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# A DIRECTORY MUST NOT STOP THE LIST. `rm -f` on one fails, and under `set -e`
|
|
97
|
+
# that used to abort the cleanup function before it reached the paths after it —
|
|
98
|
+
# so a single registered directory silently leaked everything behind it, and the
|
|
99
|
+
# script exited with the rm's status even though every check had passed.
|
|
100
|
+
d1="$(mktemp -d)"; t3="$(mktemp)"
|
|
101
|
+
bash -c 'source "$0"; dt_cleanup_add "$1"; dt_cleanup_add "$2"; exit 0' \
|
|
102
|
+
"$DT_HOME/scripts/lib.sh" "$d1" "$t3"
|
|
103
|
+
rc=$?
|
|
104
|
+
is "a registered directory does not change the exit code" "$rc" "0"
|
|
105
|
+
if [ ! -e "$d1" ] && [ ! -e "$t3" ]; then
|
|
106
|
+
ok "a directory is removed, and does not strand what follows it"
|
|
107
|
+
else
|
|
108
|
+
bad "a directory is removed, and does not strand what follows it" \
|
|
109
|
+
"left$( [ -e "$d1" ] && printf ' the directory')$( [ -e "$t3" ] && printf ' the file after it')"
|
|
110
|
+
rm -rf "$d1" "$t3"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# The return-code file lib.sh registers for itself is the one that leaked.
|
|
114
|
+
leftover="$(bash -c 'source "$0"; printf "%s" "$DT_RCFILE"' "$DT_HOME/scripts/lib.sh")"
|
|
115
|
+
if [ -n "$leftover" ] && [ ! -e "$leftover" ]; then
|
|
116
|
+
ok "lib.sh does not leave its own temp file behind"
|
|
117
|
+
else
|
|
118
|
+
bad "lib.sh does not leave its own temp file behind" "left $leftover"
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# --- the tree keeps unlabelled controls ---------------------------------------
|
|
122
|
+
#
|
|
123
|
+
# A real production app's welcome screen carries three buttons and no identifier,
|
|
124
|
+
# label or value on any of them, so the old filter printed a tree with nothing
|
|
125
|
+
# tappable in it. iOS was verified on that screen; Android could not be, because
|
|
126
|
+
# every emulator screen tried had a label on everything clickable. So the
|
|
127
|
+
# Android branch is exercised here instead, against its own tree.awk row format,
|
|
128
|
+
# rather than left as a change nobody ever saw run.
|
|
129
|
+
tree_awk_keeps() {
|
|
130
|
+
# depth|class|id|label|text|x|y|w|h|visible|enabled|password|clickable|hint
|
|
131
|
+
printf '%s\n' "$1" | awk -F'|' -v all=0 -v depth=-1 -v want="" '
|
|
132
|
+
$1 == "WINDOW" || $1 == "" { next }
|
|
133
|
+
{
|
|
134
|
+
vis = ($10 == 1)
|
|
135
|
+
if (all != 1 && !(vis && ($3 != "" || $4 != "" || $5 != "" || $13 == 1))) next
|
|
136
|
+
print $2
|
|
137
|
+
}'
|
|
138
|
+
}
|
|
139
|
+
is "an unlabelled clickable element survives the tree filter" \
|
|
140
|
+
"$(tree_awk_keeps '7|ImageButton||||10|20|30|40|1|1|0|1|')" "ImageButton"
|
|
141
|
+
is "an unlabelled unclickable element is still filtered out" \
|
|
142
|
+
"$(tree_awk_keeps '7|View||||10|20|30|40|1|1|0|0|')" ""
|
|
143
|
+
is "an invisible clickable element is still filtered out" \
|
|
144
|
+
"$(tree_awk_keeps '7|ImageButton||||10|20|30|40|0|1|0|1|')" ""
|
|
145
|
+
|
|
146
|
+
# --- the uid store and the uid selector ----------------------------------------
|
|
147
|
+
#
|
|
148
|
+
# The rule under test: a uid resolves only when the element it was assigned to
|
|
149
|
+
# is still exactly there. Not "something like it" — the fingerprint machinery
|
|
150
|
+
# was deleted on purpose and this is not it coming back under another name.
|
|
151
|
+
|
|
152
|
+
is "the uid store is called uids.tsv" "$(basename "$(uid_store)")" "uids.tsv"
|
|
153
|
+
|
|
154
|
+
UIDFIX="$(mktemp -d)"
|
|
155
|
+
dt_cleanup_add "$UIDFIX"
|
|
156
|
+
export DEVICETOOLS_UID_STORE="$UIDFIX/uids.tsv"
|
|
157
|
+
# Two empty fields in a row on purpose: the identifier and the value. Under a
|
|
158
|
+
# tab separator bash `read` would collapse them and shift every later field
|
|
159
|
+
# left, which is the bug this row shape exists to make impossible.
|
|
160
|
+
printf '1|5|Button||Đăng nhập||24|612|327|50\n' > "$DEVICETOOLS_UID_STORE"
|
|
161
|
+
|
|
162
|
+
# Stands in for the adapter. Every consumer of a uid goes through this one
|
|
163
|
+
# function, so a stub here exercises the whole path without a phone.
|
|
164
|
+
# Each case is its own process, because `die` signals the top-level shell by
|
|
165
|
+
# design — that is what stops a failure inside $( ) being swallowed, and it is
|
|
166
|
+
# documented at the top of lib.sh. A subshell around it would take this script
|
|
167
|
+
# down with it, so a refusal has to be observed from outside.
|
|
168
|
+
#
|
|
169
|
+
# The adapter is stubbed in that process. Every consumer of a uid goes through
|
|
170
|
+
# snapshot_rows, so one stub exercises the whole path without a phone.
|
|
171
|
+
uid_try() { # uid_try <selector> <rows> — prints KIND:VALUE, or exits non-zero
|
|
172
|
+
FAKE_ROWS="$2" bash -c '
|
|
173
|
+
source "$0"
|
|
174
|
+
snapshot_rows() { printf "%s\n" "$FAKE_ROWS"; }
|
|
175
|
+
parse_selector "$1"
|
|
176
|
+
printf "%s:%s" "$SEL_KIND" "$SEL_VALUE"
|
|
177
|
+
' "$DT_HOME/scripts/lib.sh" "$1" 2>/dev/null
|
|
178
|
+
}
|
|
179
|
+
refuses() { # refuses <name> <selector> <rows>
|
|
180
|
+
if uid_try "$2" "$3" >/dev/null 2>&1; then
|
|
181
|
+
bad "$1" "it resolved anyway"
|
|
182
|
+
else
|
|
183
|
+
ok "$1"
|
|
184
|
+
fi
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
HERE_ROWS='WINDOW|375|812|portrait
|
|
188
|
+
5|2|1|Button||Đăng nhập||24|612|327|50|visible,enabled,actionable'
|
|
189
|
+
|
|
190
|
+
is "a uid resolves to the centre of its element" \
|
|
191
|
+
"$(uid_try uid:1 "$HERE_ROWS")" "xy:187,637"
|
|
192
|
+
|
|
193
|
+
# The label changed. That is a real change to the screen and the uid is stale.
|
|
194
|
+
refuses "a uid whose element was renamed is refused" uid:1 'WINDOW|375|812|portrait
|
|
195
|
+
5|2|1|Button||Sign in||24|612|327|50|visible,enabled,actionable'
|
|
196
|
+
|
|
197
|
+
# The element moved. Same reasoning: a tap at the old centre would land on
|
|
198
|
+
# whatever moved into that space.
|
|
199
|
+
refuses "a uid whose element moved is refused" uid:1 'WINDOW|375|812|portrait
|
|
200
|
+
5|2|1|Button||Đăng nhập||24|700|327|50|visible,enabled,actionable'
|
|
201
|
+
|
|
202
|
+
# But it did not move by one point — that is the flooring, the same arithmetic
|
|
203
|
+
# the geometry warnings allow for. Measured on a real iPhone: a navigation bar
|
|
204
|
+
# reported 428x101 on one fetch and 428x44 on the next while the screen was
|
|
205
|
+
# still settling, and a rule with no slack at all would make every uid stale at
|
|
206
|
+
# random until an agent stopped trusting them.
|
|
207
|
+
is "a uid whose element shifted one point still resolves" \
|
|
208
|
+
"$(uid_try uid:1 'WINDOW|375|812|portrait
|
|
209
|
+
5|2|1|Button||Đăng nhập||25|613|327|50|visible,enabled,actionable')" "xy:187,637"
|
|
210
|
+
|
|
211
|
+
# Three points is not the arithmetic.
|
|
212
|
+
refuses "a uid whose element shifted past the tolerance is refused" uid:1 'WINDOW|375|812|portrait
|
|
213
|
+
5|2|1|Button||Đăng nhập||28|612|327|50|visible,enabled,actionable'
|
|
214
|
+
|
|
215
|
+
# Two identical elements where there was one. Ambiguity is refused here exactly
|
|
216
|
+
# as it is everywhere else in this tool.
|
|
217
|
+
refuses "a uid that now matches twice is refused" uid:1 'WINDOW|375|812|portrait
|
|
218
|
+
5|2|1|Button||Đăng nhập||24|612|327|50|visible,enabled,actionable
|
|
219
|
+
6|2|1|Button||Đăng nhập||24|612|327|50|visible,enabled,actionable'
|
|
220
|
+
|
|
221
|
+
refuses "an unassigned uid is refused" uid:9 "$HERE_ROWS"
|
|
222
|
+
|
|
223
|
+
# A bare integer is a uid; a bare word is a plain-string selector, which is what
|
|
224
|
+
# somebody who can see the screen writes. The error message has advertised that
|
|
225
|
+
# second rule since before it existed.
|
|
226
|
+
is "a bare integer is a uid" "$(uid_try 1 "$HERE_ROWS")" "xy:187,637"
|
|
227
|
+
is "a bare word is a plain-string match" "$(uid_try 'Đăng nhập' "$HERE_ROWS")" "any:Đăng nhập"
|
|
228
|
+
is "an explicit kind still wins" "$(uid_try label:Đóng "$HERE_ROWS")" "label:Đóng"
|
|
229
|
+
|
|
230
|
+
unset DEVICETOOLS_UID_STORE
|
|
231
|
+
|
|
232
|
+
# --- geometry warnings ---------------------------------------------------------
|
|
233
|
+
#
|
|
234
|
+
# Pure arithmetic over rectangles, and therefore the part most likely to be
|
|
235
|
+
# quietly wrong: a sign error in an overlap produces a plausible number on every
|
|
236
|
+
# screen and nobody notices for a month. Every rule gets a case with the numbers
|
|
237
|
+
# chosen so the expected answer is checkable by hand.
|
|
238
|
+
|
|
239
|
+
snap() { # snap <all> <grep>, rows on stdin
|
|
240
|
+
awk -v ALL="$1" -v GREP="$2" -v MINHIT=44 -f "$DT_HOME/scripts/snapshot.awk"
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
W='WINDOW|375|812|portrait'
|
|
244
|
+
ROOT='1|0|0|Other||||0|0|375|812|visible,enabled'
|
|
245
|
+
|
|
246
|
+
# Two visible siblings whose rects overlap by 20pt horizontally.
|
|
247
|
+
overlap_rows="$W
|
|
248
|
+
$ROOT
|
|
249
|
+
2|1|1|Button|||Alpha|10|100|100|40|visible,enabled,actionable
|
|
250
|
+
3|1|1|Button|||Beta|90|100|100|40|visible,enabled,actionable"
|
|
251
|
+
|
|
252
|
+
case "$(printf '%s\n' "$overlap_rows" | snap 0 '')" in
|
|
253
|
+
*'overlaps a sibling by 20pt — [2]'*) ok "an overlap is measured, not guessed" ;;
|
|
254
|
+
*) bad "an overlap is measured, not guessed" "$(printf '%s\n' "$overlap_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
255
|
+
esac
|
|
256
|
+
|
|
257
|
+
# A one-point overlap is the arithmetic, not the app. Both drivers report
|
|
258
|
+
# rectangles as floats and this pipeline floors them, so each edge carries up to
|
|
259
|
+
# a point of error. Reporting that as a layout bug is how a warning becomes
|
|
260
|
+
# something an agent skips: on a real, dense screen it fired twenty-three times
|
|
261
|
+
# between lines of text that are visibly not touching.
|
|
262
|
+
quantise_rows="$W
|
|
263
|
+
$ROOT
|
|
264
|
+
2|1|1|StaticText||Acme Demo||68|482|356|19|visible,enabled
|
|
265
|
+
3|1|1|StaticText||NGUYEN VAN A||68|500|336|21|visible,enabled"
|
|
266
|
+
|
|
267
|
+
case "$(printf '%s\n' "$quantise_rows" | snap 0 '')" in
|
|
268
|
+
*overlaps*) bad "a one-point overlap is not reported" "$(printf '%s\n' "$quantise_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
269
|
+
*) ok "a one-point overlap is not reported" ;;
|
|
270
|
+
esac
|
|
271
|
+
|
|
272
|
+
# The same fault on six elements is one line with six numbers on it, not six
|
|
273
|
+
# lines. A list screen otherwise produces the same warning once per row, and
|
|
274
|
+
# forty-nine warnings is a screen nobody reads.
|
|
275
|
+
group_rows="$W
|
|
276
|
+
$ROOT
|
|
277
|
+
2|1|1|Button|||A|0|100|20|20|visible,enabled,actionable
|
|
278
|
+
3|1|1|Button|||B|0|200|20|20|visible,enabled,actionable
|
|
279
|
+
4|1|1|Button|||C|0|300|20|20|visible,enabled,actionable"
|
|
280
|
+
|
|
281
|
+
is "one fault on three elements is one line" \
|
|
282
|
+
"$(printf '%s\n' "$group_rows" | snap 0 '' | grep -c 'hit area')" "1"
|
|
283
|
+
case "$(printf '%s\n' "$group_rows" | snap 0 '')" in
|
|
284
|
+
*'hit area below the 44pt minimum — [1] [2] [3]'*) ok "and it names every element it applies to" ;;
|
|
285
|
+
*) bad "and it names every element it applies to" "$(printf '%s\n' "$group_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
286
|
+
esac
|
|
287
|
+
|
|
288
|
+
# A child sticking 30pt out of the right edge of its parent.
|
|
289
|
+
outside_rows="$W
|
|
290
|
+
$ROOT
|
|
291
|
+
2|1|1|Other||||0|100|200|100|visible,enabled
|
|
292
|
+
3|2|2|Button|||Gamma|150|100|80|40|visible,enabled,actionable"
|
|
293
|
+
|
|
294
|
+
case "$(printf '%s\n' "$outside_rows" | snap 0 '')" in
|
|
295
|
+
*'outside its parent by 30pt — [1]'*) ok "a child escaping its parent is measured" ;;
|
|
296
|
+
*) bad "a child escaping its parent is measured" "$(printf '%s\n' "$outside_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
297
|
+
esac
|
|
298
|
+
|
|
299
|
+
# A control 375 wide but only 20 tall — under the 44pt minimum.
|
|
300
|
+
small_rows="$W
|
|
301
|
+
$ROOT
|
|
302
|
+
2|1|1|Button|||Tiny|0|100|375|20|visible,enabled,actionable"
|
|
303
|
+
|
|
304
|
+
case "$(printf '%s\n' "$small_rows" | snap 0 '')" in
|
|
305
|
+
*'hit area below the 44pt minimum — [1]'*) ok "a hit area below the minimum is named" ;;
|
|
306
|
+
*) bad "a hit area below the minimum is named" "$(printf '%s\n' "$small_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
307
|
+
esac
|
|
308
|
+
|
|
309
|
+
# A label the OS truncated. The ellipsis is the signal; a character count is not.
|
|
310
|
+
ellip_rows="$W
|
|
311
|
+
$ROOT
|
|
312
|
+
2|1|1|StaticText||Tên người nhận đầy đủ…||24|200|180|20|visible,enabled"
|
|
313
|
+
|
|
314
|
+
case "$(printf '%s\n' "$ellip_rows" | snap 0 '')" in
|
|
315
|
+
*'the OS truncated this text with an ellipsis — [1]'*) ok "an ellipsis is reported as truncation" ;;
|
|
316
|
+
*) bad "an ellipsis is reported as truncation" "$(printf '%s\n' "$ellip_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
317
|
+
esac
|
|
318
|
+
|
|
319
|
+
# The same label without an ellipsis must produce no warning at all. A rule that
|
|
320
|
+
# fires on everything is the same as no rule.
|
|
321
|
+
plain_rows="$W
|
|
322
|
+
$ROOT
|
|
323
|
+
2|1|1|StaticText||Tên người nhận||24|200|180|20|visible,enabled"
|
|
324
|
+
|
|
325
|
+
case "$(printf '%s\n' "$plain_rows" | snap 0 '')" in
|
|
326
|
+
*'⚠'*) bad "a text that fits produces no warning" "$(printf '%s\n' "$plain_rows" | snap 0 '' | tr '\n' ' ')" ;;
|
|
327
|
+
*) ok "a text that fits produces no warning" ;;
|
|
328
|
+
esac
|
|
329
|
+
|
|
330
|
+
# Off screen: y beyond the window height.
|
|
331
|
+
off_rows="$W
|
|
332
|
+
$ROOT
|
|
333
|
+
2|1|1|Button|||Below|0|900|100|44|visible,enabled,actionable"
|
|
334
|
+
|
|
335
|
+
case "$(printf '%s\n' "$off_rows" | snap 1 '')" in
|
|
336
|
+
*'off screen'*) ok "an element past the window edge is off screen" ;;
|
|
337
|
+
*) bad "an element past the window edge is off screen" "$(printf '%s\n' "$off_rows" | snap 1 '' | tr '\n' ' ')" ;;
|
|
338
|
+
esac
|
|
339
|
+
|
|
340
|
+
# The default filter: an invisible element with a label is noise and is dropped;
|
|
341
|
+
# an unlabelled actionable element is kept, because a screen whose only controls
|
|
342
|
+
# are unlabelled must not read as having nothing to tap.
|
|
343
|
+
filter_rows="$W
|
|
344
|
+
$ROOT
|
|
345
|
+
2|1|1|StaticText||Hidden||0|100|100|20|enabled
|
|
346
|
+
3|1|1|Button||||0|200|100|44|visible,enabled,actionable"
|
|
347
|
+
|
|
348
|
+
filter_out="$(printf '%s\n' "$filter_rows" | snap 0 '')"
|
|
349
|
+
case "$filter_out" in
|
|
350
|
+
*Hidden*) bad "an invisible labelled element is filtered out" "$filter_out" ;;
|
|
351
|
+
*) ok "an invisible labelled element is filtered out" ;;
|
|
352
|
+
esac
|
|
353
|
+
case "$filter_out" in
|
|
354
|
+
*Button*) ok "an unlabelled actionable element survives the filter" ;;
|
|
355
|
+
*) bad "an unlabelled actionable element survives the filter" "$filter_out" ;;
|
|
356
|
+
esac
|
|
357
|
+
case "$filter_out" in
|
|
358
|
+
*'[1] Button'*) ok "uids number the shown elements from 1" ;;
|
|
359
|
+
*) bad "uids number the shown elements from 1" "$filter_out" ;;
|
|
360
|
+
esac
|
|
361
|
+
|
|
362
|
+
# The screen line carries a size, an orientation and a stable hash.
|
|
363
|
+
head_line="$(printf '%s\n' "$filter_rows" | snap 0 '' | head -1)"
|
|
364
|
+
case "$head_line" in
|
|
365
|
+
SCREEN*375x812*portrait*) ok "the screen line carries size and orientation" ;;
|
|
366
|
+
*) bad "the screen line carries size and orientation" "$head_line" ;;
|
|
367
|
+
esac
|
|
368
|
+
h1="$(printf '%s\n' "$filter_rows" | snap 0 '' | head -1 | grep -o '#[0-9a-f]*')"
|
|
369
|
+
h2="$(printf '%s\n' "$filter_rows" | snap 0 '' | head -1 | grep -o '#[0-9a-f]*')"
|
|
370
|
+
is "the screen hash is stable across runs" "$h1" "$h2"
|
|
371
|
+
h3="$(printf '%s\n' "$plain_rows" | snap 0 '' | head -1 | grep -o '#[0-9a-f]*')"
|
|
372
|
+
if [ "$h1" != "$h3" ]; then
|
|
373
|
+
ok "a different screen gets a different hash"
|
|
374
|
+
else
|
|
375
|
+
bad "a different screen gets a different hash" "both were $h1"
|
|
376
|
+
fi
|
|
377
|
+
|
|
378
|
+
# Two Vietnamese labels differing only in their diacritics must not collide. A
|
|
379
|
+
# hash that folds every non-ASCII byte to the same value would report two
|
|
380
|
+
# different screens as one, and change detection would go quiet.
|
|
381
|
+
viet_a="$W
|
|
382
|
+
$ROOT
|
|
383
|
+
2|1|1|StaticText||Đăng nhập||0|100|200|20|visible,enabled"
|
|
384
|
+
viet_b="$W
|
|
385
|
+
$ROOT
|
|
386
|
+
2|1|1|StaticText||Đang nhắp||0|100|200|20|visible,enabled"
|
|
387
|
+
ha="$(printf '%s\n' "$viet_a" | snap 0 '' | head -1 | grep -o '#[0-9a-f]*')"
|
|
388
|
+
hb="$(printf '%s\n' "$viet_b" | snap 0 '' | head -1 | grep -o '#[0-9a-f]*')"
|
|
389
|
+
if [ "$ha" != "$hb" ]; then
|
|
390
|
+
ok "labels differing only in diacritics hash differently"
|
|
391
|
+
else
|
|
392
|
+
bad "labels differing only in diacritics hash differently" "both were $ha"
|
|
393
|
+
fi
|
|
394
|
+
|
|
395
|
+
# --- the session journal -------------------------------------------------------
|
|
396
|
+
#
|
|
397
|
+
# The journal exists for one reason: installing a new build puts the app back on
|
|
398
|
+
# its first screen, and re-walking a long flow by hand costs more than the
|
|
399
|
+
# code change did. It is a path, not a test — no assertions, no scoring.
|
|
400
|
+
|
|
401
|
+
JFIX="$(mktemp -d)"
|
|
402
|
+
dt_cleanup_add "$JFIX"
|
|
403
|
+
export DEVICETOOLS_JOURNAL="$JFIX/session.jsonl"
|
|
404
|
+
|
|
405
|
+
journal_append tap 'label:Đăng nhập' Button '' 'Đăng nhập' '' 24 612 327 50 a3f1
|
|
406
|
+
is "one line is appended" "$(wc -l < "$DEVICETOOLS_JOURNAL" | tr -d ' ')" "1"
|
|
407
|
+
is "the verb is recorded" "$(jq -r '.verb' "$DEVICETOOLS_JOURNAL")" "tap"
|
|
408
|
+
is "the label survives" "$(jq -r '.label' "$DEVICETOOLS_JOURNAL")" "Đăng nhập"
|
|
409
|
+
is "the rectangle is numeric, not a string" \
|
|
410
|
+
"$(jq -r '.x | type' "$DEVICETOOLS_JOURNAL")" "number"
|
|
411
|
+
is "the screen it happened on is stamped" \
|
|
412
|
+
"$(jq -r '.screen' "$DEVICETOOLS_JOURNAL")" "a3f1"
|
|
413
|
+
|
|
414
|
+
journal_append back '' '' '' '' '' 0 0 0 0 b2c3
|
|
415
|
+
is "it appends rather than replaces" "$(wc -l < "$DEVICETOOLS_JOURNAL" | tr -d ' ')" "2"
|
|
416
|
+
|
|
417
|
+
# A label with a quote, a brace and a newline in it. Application copy is written
|
|
418
|
+
# by people, and a journal that cannot survive it is a journal that loses a step
|
|
419
|
+
# exactly when the flow is interesting.
|
|
420
|
+
journal_append type 'Lê "Văn" A
|
|
421
|
+
{}' TextField '' '' '' 1 2 3 4 c3d4
|
|
422
|
+
is "quotes and newlines survive the round trip" \
|
|
423
|
+
"$(tail -1 "$DEVICETOOLS_JOURNAL" | jq -r '.args')" "$(printf 'Lê "Văn" A\n{}')"
|
|
424
|
+
is "and every line is still valid JSON" \
|
|
425
|
+
"$(jq -s 'length' "$DEVICETOOLS_JOURNAL" 2>/dev/null)" "3"
|
|
426
|
+
|
|
427
|
+
# An unwritable journal is a bad day, not a failed tap. The action already
|
|
428
|
+
# happened on the device; failing the verb afterwards would tell the agent the
|
|
429
|
+
# tap did not land, which is a lie it would act on.
|
|
430
|
+
DEVICETOOLS_JOURNAL=/nonexistent-directory-here/session.jsonl
|
|
431
|
+
if journal_append tap x Button '' y '' 1 2 3 4 z; then
|
|
432
|
+
ok "an unwritable journal does not fail the action"
|
|
433
|
+
else
|
|
434
|
+
bad "an unwritable journal does not fail the action" "it returned non-zero"
|
|
435
|
+
fi
|
|
436
|
+
unset DEVICETOOLS_JOURNAL
|
|
437
|
+
|
|
438
|
+
# --- the network filter --------------------------------------------------------
|
|
439
|
+
#
|
|
440
|
+
# There is no proxy here and there is not going to be one: the app under test
|
|
441
|
+
# pins its certificates, so a proxy would see a handshake failure and nothing
|
|
442
|
+
# else. What is left is the app's own logging, filtered — and the filter is a
|
|
443
|
+
# regex, which is worth testing precisely because a pattern that matches
|
|
444
|
+
# everything is the same as no filter at all.
|
|
445
|
+
|
|
446
|
+
netfilter() { # netfilter <line> [pattern]
|
|
447
|
+
awk -v pat="${2:-(GET|POST|PUT|PATCH|DELETE|HTTP/[0-9])}" '$0 ~ pat' <<< "$1"
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
is "a request line is kept" \
|
|
451
|
+
"$(netfilter 'POST /v1/auth/login 200 142ms')" 'POST /v1/auth/login 200 142ms'
|
|
452
|
+
is "a response line is kept" \
|
|
453
|
+
"$(netfilter 'HTTP/1.1 401 Unauthorized')" 'HTTP/1.1 401 Unauthorized'
|
|
454
|
+
is "an unrelated line is dropped" \
|
|
455
|
+
"$(netfilter 'ViewController viewDidLoad')" ''
|
|
456
|
+
# The case that decides whether the pattern is uppercase or case-insensitive.
|
|
457
|
+
is "a line merely containing the word get is dropped" \
|
|
458
|
+
"$(netfilter 'forgetting the cache')" ''
|
|
459
|
+
# The real risk is not that the pattern is wrong — the cases above cover that.
|
|
460
|
+
# It is that one adapter's default drifts from the other's, so the same app
|
|
461
|
+
# behaves differently on the two platforms and nobody notices until somebody
|
|
462
|
+
# compares two runs.
|
|
463
|
+
adapter_default() { # <logs.sh path>
|
|
464
|
+
grep -ho "cfg '.logs.network_pattern' '[^']*'" "$1" \
|
|
465
|
+
| sed "s/.*network_pattern' '//; s/'\$//"
|
|
466
|
+
}
|
|
467
|
+
is "both adapters default to the same network pattern" \
|
|
468
|
+
"$(adapter_default "$DT_HOME/scripts/ios/logs.sh")" \
|
|
469
|
+
"$(adapter_default "$DT_HOME/scripts/android/logs.sh")"
|
|
470
|
+
|
|
471
|
+
# And the shipped example config must agree with them, or a project created from
|
|
472
|
+
# it starts with a filter that behaves differently from the built-in default.
|
|
473
|
+
is "config.example.json ships that same pattern" \
|
|
474
|
+
"$(jq -r '.logs.network_pattern // ""' "$DT_HOME/config.example.json")" \
|
|
475
|
+
"$(adapter_default "$DT_HOME/scripts/ios/logs.sh")"
|
|
476
|
+
|
|
477
|
+
# The strict pattern exists because of a real line from a real phone. Keeping
|
|
478
|
+
# the case here means the reason survives the next person who simplifies it.
|
|
479
|
+
is "a bluetooth identifier containing GET is not a request" \
|
|
480
|
+
"$(netfilter 'Sending: BD_VSC_OLYMPIC_GET_EXT_ADV_DROPPED' \
|
|
481
|
+
"$(adapter_default "$DT_HOME/scripts/ios/logs.sh")")" ''
|
|
482
|
+
is "a real request survives the strict pattern" \
|
|
483
|
+
"$(netfilter 'DemoApp[75729] GET https://api.example.vn/accounts 200' \
|
|
484
|
+
"$(adapter_default "$DT_HOME/scripts/ios/logs.sh")")" \
|
|
485
|
+
'DemoApp[75729] GET https://api.example.vn/accounts 200'
|
|
486
|
+
|
|
487
|
+
# --- the action report ---------------------------------------------------------
|
|
488
|
+
#
|
|
489
|
+
# The rule under test: an action says whether the screen moved. Getting this
|
|
490
|
+
# backwards is the worst bug available in this file — an agent told the screen
|
|
491
|
+
# changed when it did not will walk a long way off a cliff of its own reasoning
|
|
492
|
+
# before anything contradicts it.
|
|
493
|
+
|
|
494
|
+
act_in() { # act_in <rows-before> <rows-after> [logs] — the report, as text
|
|
495
|
+
BEFORE="$1" AFTER="$2" FAKELOG="${3-}" bash -c '
|
|
496
|
+
source "$0"
|
|
497
|
+
snapshot_rows() { printf "%s\n" "$ROWS"; }
|
|
498
|
+
logs_since() { printf "%s" "$FAKELOG"; }
|
|
499
|
+
ROWS="$BEFORE"; act_before
|
|
500
|
+
ROWS="$AFTER"; act_report TAP "[2] Button \"Đăng nhập\""
|
|
501
|
+
' "$DT_HOME/scripts/lib.sh" 2>/dev/null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
SCREEN_A='WINDOW|375|812|portrait
|
|
505
|
+
1|0|0|Other||||0|0|375|812|visible,enabled
|
|
506
|
+
2|1|1|Button||Đăng nhập||24|612|327|50|visible,enabled,actionable'
|
|
507
|
+
SCREEN_B='WINDOW|375|812|portrait
|
|
508
|
+
1|0|0|Other||||0|0|375|812|visible,enabled
|
|
509
|
+
2|1|1|Button||Xác thực||24|612|327|50|visible,enabled,actionable'
|
|
510
|
+
|
|
511
|
+
moved="$(act_in "$SCREEN_A" "$SCREEN_B")"
|
|
512
|
+
same="$(act_in "$SCREEN_A" "$SCREEN_A")"
|
|
513
|
+
|
|
514
|
+
case "$moved" in
|
|
515
|
+
*'SCREEN changed'*) ok "a screen that moved is reported as changed" ;;
|
|
516
|
+
*) bad "a screen that moved is reported as changed" "$(printf '%s' "$moved" | join_lines ' / ')" ;;
|
|
517
|
+
esac
|
|
518
|
+
case "$same" in
|
|
519
|
+
*'SCREEN unchanged'*) ok "a screen that did not move is reported as unchanged" ;;
|
|
520
|
+
*) bad "a screen that did not move is reported as unchanged" "$(printf '%s' "$same" | join_lines ' / ')" ;;
|
|
521
|
+
esac
|
|
522
|
+
case "$moved" in
|
|
523
|
+
*'TAP [2] Button'*) ok "the action itself is on the first line" ;;
|
|
524
|
+
*) bad "the action itself is on the first line" "$(printf '%s' "$moved" | join_lines ' / ')" ;;
|
|
525
|
+
esac
|
|
526
|
+
case "$same" in
|
|
527
|
+
*LOG*) bad "no log lines means no LOG section" "$(printf '%s' "$same" | join_lines ' / ')" ;;
|
|
528
|
+
*) ok "no log lines means no LOG section" ;;
|
|
529
|
+
esac
|
|
530
|
+
|
|
531
|
+
withlog="$(act_in "$SCREEN_A" "$SCREEN_A" '[error] AuthService: token refresh failed')"
|
|
532
|
+
case "$withlog" in
|
|
533
|
+
*'LOG 1 line'*'token refresh failed'*) ok "log lines from the action are attached" ;;
|
|
534
|
+
*) bad "log lines from the action are attached" "$(printf '%s' "$withlog" | join_lines ' / ')" ;;
|
|
535
|
+
esac
|
|
536
|
+
|
|
537
|
+
# A tree that cannot be read on either side must say so rather than claim the
|
|
538
|
+
# screen held still. "unchanged" and "I could not look" are different facts.
|
|
539
|
+
blind="$(act_in '' '')"
|
|
540
|
+
case "$blind" in
|
|
541
|
+
*'SCREEN unknown'*) ok "an unreadable tree is not reported as unchanged" ;;
|
|
542
|
+
*) bad "an unreadable tree is not reported as unchanged" "$(printf '%s' "$blind" | join_lines ' / ')" ;;
|
|
543
|
+
esac
|
|
544
|
+
|
|
545
|
+
# --- an action can hand back the screen it produced ----------------------------
|
|
546
|
+
#
|
|
547
|
+
# The read–act–read cycle was half the round trips in a measured session:
|
|
548
|
+
# twelve calls to log in and open Settings, six of them snapshots taken only to
|
|
549
|
+
# see what the previous action produced. --snapshot folds that read into the
|
|
550
|
+
# action, from the rows the report already fetched — one read of the phone, two
|
|
551
|
+
# readings of it.
|
|
552
|
+
|
|
553
|
+
act_snap_in() { # act_snap_in <rows-before> <rows-after>
|
|
554
|
+
BEFORE="$1" AFTER="$2" bash -c '
|
|
555
|
+
source "$0"
|
|
556
|
+
snapshot_rows() { printf "%s\n" "$ROWS"; }
|
|
557
|
+
logs_since() { printf ""; }
|
|
558
|
+
ROWS="$BEFORE"; act_before
|
|
559
|
+
act_snapshot_on
|
|
560
|
+
ROWS="$AFTER"; act_report TAP "[2] Button"
|
|
561
|
+
' "$DT_HOME/scripts/lib.sh" 2>/dev/null
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
DEVICETOOLS_UID_STORE="$(mktemp)"; export DEVICETOOLS_UID_STORE
|
|
565
|
+
snapped="$(act_snap_in "$SCREEN_A" "$SCREEN_B")"
|
|
566
|
+
|
|
567
|
+
case "$snapped" in
|
|
568
|
+
*'SCREEN changed'*'[1] Button "Xác thực"'*) ok "--snapshot prints the screen the action produced" ;;
|
|
569
|
+
*) bad "--snapshot prints the screen the action produced" "$(printf '%s' "$snapped" | join_lines ' / ')" ;;
|
|
570
|
+
esac
|
|
571
|
+
case "$(act_in "$SCREEN_A" "$SCREEN_B")" in
|
|
572
|
+
*'Button "Xác thực"'*) bad "without the flag an action still costs one line" "a tree was printed anyway" ;;
|
|
573
|
+
*) ok "without the flag an action still costs one line" ;;
|
|
574
|
+
esac
|
|
575
|
+
|
|
576
|
+
# THE NUMBERS HAVE TO BE THE NEW SCREEN'S. Printing a tree without renumbering
|
|
577
|
+
# would be worse than printing nothing: every uid in it would resolve against
|
|
578
|
+
# the screen before the action, which is the one failure the uid store exists to
|
|
579
|
+
# prevent.
|
|
580
|
+
is "the uid store is renumbered to what was printed" \
|
|
581
|
+
"$(awk -F'|' 'NR == 1 { print $3 "|" $5 }' "$DEVICETOOLS_UID_STORE" 2>/dev/null)" \
|
|
582
|
+
'Button|Xác thực'
|
|
583
|
+
|
|
584
|
+
# An action whose screen cannot be read afterwards says so. The action itself
|
|
585
|
+
# still happened, so it must not be reported as a failure.
|
|
586
|
+
blindsnap="$(act_snap_in "$SCREEN_A" '')"
|
|
587
|
+
case "$blindsnap" in
|
|
588
|
+
*'TREE unavailable'*) ok "a tree that cannot be read says so instead of printing nothing" ;;
|
|
589
|
+
*) bad "a tree that cannot be read says so instead of printing nothing" "$(printf '%s' "$blindsnap" | join_lines ' / ')" ;;
|
|
590
|
+
esac
|
|
591
|
+
rm -f "$DEVICETOOLS_UID_STORE"
|
|
592
|
+
unset DEVICETOOLS_UID_STORE
|
|
593
|
+
|
|
594
|
+
# --- the identifier, on the line and in the lint -------------------------------
|
|
595
|
+
#
|
|
596
|
+
# --grep has always searched identifiers, so a screen could be found by a name
|
|
597
|
+
# the display never printed. It is also the only selector that survives a
|
|
598
|
+
# rewritten button or a second language, which makes it the thing anybody
|
|
599
|
+
# writing a flow wants to see first.
|
|
600
|
+
|
|
601
|
+
ID_ROWS='WINDOW|375|812|portrait
|
|
602
|
+
1|0|0|Other||||0|0|375|812|visible,enabled
|
|
603
|
+
2|1|1|Button|btn_login|Đăng nhập||24|600|327|50|visible,enabled,actionable
|
|
604
|
+
3|1|1|Button|btn_bare|||24|500|327|50|visible,enabled,actionable
|
|
605
|
+
4|1|1|Button||Quên mật khẩu||24|400|327|50|visible,enabled,actionable
|
|
606
|
+
5|1|1|StaticText||Xin chào||24|300|327|50|visible,enabled
|
|
607
|
+
6|1|1|Key||A||10|700|30|30|visible,enabled,actionable'
|
|
608
|
+
|
|
609
|
+
id_out="$(printf '%s\n' "$ID_ROWS" | snap 0 '')"
|
|
610
|
+
|
|
611
|
+
case "$id_out" in
|
|
612
|
+
*'Button #btn_login "Đăng nhập"'*) ok "an identifier is printed beside the label, not instead of it" ;;
|
|
613
|
+
*) bad "an identifier is printed beside the label, not instead of it" "$(printf '%s' "$id_out" | join_lines ' / ')" ;;
|
|
614
|
+
esac
|
|
615
|
+
# With no label, the identifier used to be printed in quotes by best()'s
|
|
616
|
+
# fallback, which reads as a caption the app never wrote.
|
|
617
|
+
case "$id_out" in
|
|
618
|
+
*'Button #btn_bare ('*) ok "an identifier with no label is not dressed up as one" ;;
|
|
619
|
+
*) bad "an identifier with no label is not dressed up as one" "$(printf '%s' "$id_out" | join_lines ' / ')" ;;
|
|
620
|
+
esac
|
|
621
|
+
|
|
622
|
+
# A uid is a position among the elements SHOWN, not a row number: the root
|
|
623
|
+
# container carries nothing and is filtered out, so the five rows below it are
|
|
624
|
+
# [1] btn_login, [2] btn_bare, [3] Quên mật khẩu, [4] the static text, [5] the
|
|
625
|
+
# keyboard key.
|
|
626
|
+
id_warn="$(printf '%s\n' "$ID_ROWS" | snap 0 '' | grep 'no identifier' || true)"
|
|
627
|
+
case "$id_warn" in
|
|
628
|
+
*'[3]'*) ok "an actionable control with no identifier is a lint" ;;
|
|
629
|
+
*) bad "an actionable control with no identifier is a lint" "${id_warn:-nothing warned}" ;;
|
|
630
|
+
esac
|
|
631
|
+
case "$id_warn" in
|
|
632
|
+
*'[1]'*|*'[2]'*) bad "a control that has an identifier is not linted" "$id_warn" ;;
|
|
633
|
+
*) ok "a control that has an identifier is not linted" ;;
|
|
634
|
+
esac
|
|
635
|
+
# Text nobody can tap needs no name to be tapped by.
|
|
636
|
+
case "$id_warn" in
|
|
637
|
+
*'[4]'*) bad "text nobody can tap is not linted for a missing identifier" "$id_warn" ;;
|
|
638
|
+
*) ok "text nobody can tap is not linted for a missing identifier" ;;
|
|
639
|
+
esac
|
|
640
|
+
# The identifiers on a keyboard key belong to Apple, and telling an application
|
|
641
|
+
# developer to add them is advice they cannot take.
|
|
642
|
+
case "$id_warn" in
|
|
643
|
+
*'[5]'*) bad "the keyboard is not linted for missing identifiers" "$id_warn" ;;
|
|
644
|
+
*) ok "the keyboard is not linted for missing identifiers" ;;
|
|
645
|
+
esac
|
|
646
|
+
|
|
647
|
+
# --- kind:, and the selector grammar -------------------------------------------
|
|
648
|
+
#
|
|
649
|
+
# snapshot warns "no identifier" about a SecureTextField that also has no label,
|
|
650
|
+
# and until now left nothing to reach it with but a raw coordinate — the one
|
|
651
|
+
# spelling this tool tells people to avoid. It was right to: after the keyboard
|
|
652
|
+
# went down, the sheet holding that field moved from y=507 to y=774 and the
|
|
653
|
+
# recorded tap missed entirely.
|
|
654
|
+
|
|
655
|
+
sel_kind() { bash -c 'source "$0"; parse_selector "$1"; printf "%s:%s" "$SEL_KIND" "$SEL_VALUE"' \
|
|
656
|
+
"$DT_HOME/scripts/lib.sh" "$1" 2>/dev/null; }
|
|
657
|
+
is "kind: names the element class" "$(sel_kind kind:SecureTextField)" "kind:SecureTextField"
|
|
658
|
+
is "id: is unaffected" "$(sel_kind id:btn_login)" "id:btn_login"
|
|
659
|
+
# A plain string still means "id, label or value" and must NOT quietly start
|
|
660
|
+
# matching type names — `tap Button` would then hit the first button on screen.
|
|
661
|
+
is "a plain string is not a type" "$(sel_kind Button)" "any:Button"
|
|
662
|
+
|
|
663
|
+
# The adapter has to honour the new kind, or the grammar accepts a selector
|
|
664
|
+
# nothing can resolve. Its own function, with the driver stubbed out.
|
|
665
|
+
android_match() { # android_match <tree rows> <kind> <value>
|
|
666
|
+
DEVICETOOLS_CONFIG="$DT_HOME/config.android.json" FAKE="$1" bash -c '
|
|
667
|
+
source "$0" 2>/dev/null
|
|
668
|
+
match_elements "$FAKE" "$1" "$2"
|
|
669
|
+
' "$DT_HOME/scripts/android/lib.sh" "$2" "$3" 2>/dev/null
|
|
670
|
+
}
|
|
671
|
+
kind_rows='WINDOW|1080|2400
|
|
672
|
+
0|FrameLayout||||0|0|1080|2400|1|1|0|0|0
|
|
673
|
+
1|EditText||||64|400|300|60|1|1|0|1|0
|
|
674
|
+
2|Button||Đăng nhập||64|600|300|60|1|1|0|1|0'
|
|
675
|
+
is "the adapter matches on the element class" \
|
|
676
|
+
"$(android_match "$kind_rows" kind EditText)" '64|400|300|60|EditText|||'
|
|
677
|
+
is "and a class that is not there matches nothing" \
|
|
678
|
+
"$(android_match "$kind_rows" kind SecureTextField)" ""
|
|
679
|
+
|
|
680
|
+
# --- "nothing on screen contains it", when the screen did contain it -----------
|
|
681
|
+
#
|
|
682
|
+
# Reading and acting search different sets on purpose: snapshot walks the whole
|
|
683
|
+
# tree, tap walks what a user could have touched. That is right, and the message
|
|
684
|
+
# for the gap between them was wrong — `tap label:Cài đặt` answered "nothing on
|
|
685
|
+
# screen contains it" while `snapshot --grep Cài đặt` was listing it. Reported
|
|
686
|
+
# from a Control Wheel whose four buttons share one rectangle exactly: three of
|
|
687
|
+
# them are covered, the driver marks those invisible, and invisible is not
|
|
688
|
+
# absent.
|
|
689
|
+
#
|
|
690
|
+
# The adapter supplies named_anywhere; this is the sentence built from it, so
|
|
691
|
+
# the adapter is stubbed and the wording is what is under test.
|
|
692
|
+
note_for() { # note_for <named_anywhere-output> <kind> <value>
|
|
693
|
+
ROW="$1" bash -c '
|
|
694
|
+
source "$0"
|
|
695
|
+
named_anywhere() { printf "%s\n" "$ROW"; }
|
|
696
|
+
invisible_note "" "$1" "$2"
|
|
697
|
+
' "$DT_HOME/scripts/lib.sh" "$2" "$3" 2>/dev/null
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
case "$(note_for '64|127|300|300|Button|4|0' label 'Cài đặt')" in
|
|
701
|
+
*'Button at 64,127 300x300'*'4 elements share that rectangle'*)
|
|
702
|
+
ok "a covered twin is named as covered, not as missing" ;;
|
|
703
|
+
*) bad "a covered twin is named as covered, not as missing" \
|
|
704
|
+
"$(note_for '64|127|300|300|Button|4|0' label 'Cài đặt')" ;;
|
|
705
|
+
esac
|
|
706
|
+
case "$(note_for '24|612|327|50|Button|1|0' label 'Đăng nhập')" in
|
|
707
|
+
*'reports it as not visible'*) ok "one invisible element says so without inventing a twin" ;;
|
|
708
|
+
*) bad "one invisible element says so without inventing a twin" \
|
|
709
|
+
"$(note_for '24|612|327|50|Button|1|0' label 'Đăng nhập')" ;;
|
|
710
|
+
esac
|
|
711
|
+
# A zero-sized element is a different fault with a different remedy, and telling
|
|
712
|
+
# somebody to scroll it into view would send them looking for a scroll view.
|
|
713
|
+
case "$(note_for '10|20|0|0|StaticText|1|1' label 'Nhãn')" in
|
|
714
|
+
*'measures 0x0'*) ok "a zero-sized element is not blamed on being covered" ;;
|
|
715
|
+
*) bad "a zero-sized element is not blamed on being covered" \
|
|
716
|
+
"$(note_for '10|20|0|0|StaticText|1|1' label 'Nhãn')" ;;
|
|
717
|
+
esac
|
|
718
|
+
# Nothing named at all: say nothing, and let the caller fall through to the near
|
|
719
|
+
# misses. A note here would be an explanation of something that is not the case.
|
|
720
|
+
is "a selector that names nothing gets no note" "$(note_for '' label 'Nope')" ""
|
|
721
|
+
|
|
722
|
+
# The adapter's own function, on the row shape it really reads.
|
|
723
|
+
android_named() { # android_named <tree rows> <kind> <value>
|
|
724
|
+
DEVICETOOLS_CONFIG="$DT_HOME/config.android.json" FAKE="$1" bash -c '
|
|
725
|
+
source "$0" 2>/dev/null
|
|
726
|
+
named_anywhere "$FAKE" "$1" "$2"
|
|
727
|
+
' "$DT_HOME/scripts/android/lib.sh" "$2" "$3" 2>/dev/null
|
|
728
|
+
}
|
|
729
|
+
android_wheel='WINDOW|1080|2400
|
|
730
|
+
0|FrameLayout||||0|0|1080|2400|1|1|0|0|0
|
|
731
|
+
1|Button||Cài đặt||64|127|300|300|0|1|0|1|0
|
|
732
|
+
2|Button||Trang chủ||64|127|300|300|1|1|0|1|0'
|
|
733
|
+
is "the adapter counts the elements sharing that exact rectangle" \
|
|
734
|
+
"$(android_named "$android_wheel" label 'Cài đặt')" '64|127|300|300|Button|2|0'
|
|
735
|
+
|
|
736
|
+
# --- the Android row shaping ---------------------------------------------------
|
|
737
|
+
#
|
|
738
|
+
# There was no Android device attached when snapshot_rows was written for that
|
|
739
|
+
# platform, so there is no recorded screen for it. What can still be checked is
|
|
740
|
+
# the part where the two adapters would diverge: turning tree.awk's fourteen
|
|
741
|
+
# columns into the twelve every consumer reads.
|
|
742
|
+
#
|
|
743
|
+
# The adapter's own function is called, with the driver stubbed out. A copy of
|
|
744
|
+
# the rule written here would pass this file happily while the adapter did
|
|
745
|
+
# something else, which is the failure mode a test like this exists to prevent.
|
|
746
|
+
#
|
|
747
|
+
# Its own process, and its own config: sourcing an adapter replaces functions
|
|
748
|
+
# this script is still using.
|
|
749
|
+
android_shape() { # <tree.awk rows> — the adapter's twelve-field output
|
|
750
|
+
DEVICETOOLS_CONFIG="$DT_HOME/config.android.json" FAKE="$1" bash -c '
|
|
751
|
+
source "$0" 2>/dev/null
|
|
752
|
+
fetch_source() { printf "%s\n" "$FAKE"; }
|
|
753
|
+
window_size() { printf "1080 2400"; }
|
|
754
|
+
snapshot_rows
|
|
755
|
+
' "$DT_HOME/scripts/android/lib.sh" 2>/dev/null
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
android_raw='WINDOW|1080|2400
|
|
759
|
+
0|FrameLayout||||0|0|1080|2400|1|1|0|0|0
|
|
760
|
+
1|LinearLayout||||0|100|1080|200|1|1|0|0|0
|
|
761
|
+
2|Button|btn_login|Đăng nhập||24|120|300|150|1|1|0|1|0
|
|
762
|
+
2|TextView||Quên mật khẩu|Quên mật khẩu|24|300|300|40|1|0|0|0|0'
|
|
763
|
+
|
|
764
|
+
android_out="$(android_shape "$android_raw")"
|
|
765
|
+
|
|
766
|
+
is "the window row carries an orientation" \
|
|
767
|
+
"$(printf '%s\n' "$android_out" | sed -n 1p)" 'WINDOW|1080|2400|portrait'
|
|
768
|
+
is "clickable becomes actionable, and enabled survives" \
|
|
769
|
+
"$(printf '%s\n' "$android_out" | sed -n 4p)" \
|
|
770
|
+
'3|2|2|Button|btn_login|Đăng nhập||24|120|300|150|visible,enabled,actionable'
|
|
771
|
+
is "a disabled, unclickable element carries only visible" \
|
|
772
|
+
"$(printf '%s\n' "$android_out" | sed -n 5p | awk -F'|' '{print $12}')" 'visible'
|
|
773
|
+
is "two siblings at one depth share a parent" \
|
|
774
|
+
"$(printf '%s\n' "$android_out" | awk -F'|' 'NR>=4 {print $3}' | sort -u | join_lines ',')" '2'
|
|
775
|
+
is "every element is numbered, filtered or not" \
|
|
776
|
+
"$(printf '%s\n' "$android_out" | grep -c '^[0-9]')" "4"
|
|
777
|
+
|
|
778
|
+
# --- two warnings that had to learn what not to say ----------------------------
|
|
779
|
+
#
|
|
780
|
+
# Both came from an agent using this against a real phone and reading the noise.
|
|
781
|
+
|
|
782
|
+
snap_warns() { # snap_warns <rows> — the warning lines only
|
|
783
|
+
printf '%s\n' "$1" | awk -v ALL=1 -v GREP="" -v MINHIT=44 \
|
|
784
|
+
-f "$DT_HOME/scripts/snapshot.awk" 2>/dev/null | grep '⚠' || true
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
# A screen is built from boxes that each cover the window. Saying they overlap
|
|
788
|
+
# is true and useless: "overlaps a sibling by 428pt — [7] [8] [10] and 9 more".
|
|
789
|
+
STACK='WINDOW|428|926|portrait
|
|
790
|
+
1|0|0|Application||App||0|0|428|926|visible,enabled
|
|
791
|
+
2|1|1|Other||A||0|0|428|926|visible,enabled
|
|
792
|
+
3|1|1|Other||B||0|0|428|926|visible,enabled'
|
|
793
|
+
case "$(snap_warns "$STACK")" in
|
|
794
|
+
*"overlaps a sibling"*) bad "two full-screen layers are a stack, not an overlap" \
|
|
795
|
+
"$(snap_warns "$STACK" | join_lines ' ')" ;;
|
|
796
|
+
*) ok "two full-screen layers are a stack, not an overlap" ;;
|
|
797
|
+
esac
|
|
798
|
+
|
|
799
|
+
# But two boxes that are not full-screen still overlap.
|
|
800
|
+
LAP='WINDOW|428|926|portrait
|
|
801
|
+
1|0|0|Application||App||0|0|428|926|visible,enabled
|
|
802
|
+
2|1|1|Other||A||0|100|400|200|visible,enabled
|
|
803
|
+
3|1|1|Other||B||0|150|400|200|visible,enabled'
|
|
804
|
+
case "$(snap_warns "$LAP")" in
|
|
805
|
+
*"overlaps a sibling"*) ok "two ordinary boxes still overlap" ;;
|
|
806
|
+
*) bad "two ordinary boxes still overlap" "the full-screen rule swallowed a real one" ;;
|
|
807
|
+
esac
|
|
808
|
+
|
|
809
|
+
# A tap goes to the centre. Spotlight's field is (32,4) 364x48 — generous, and
|
|
810
|
+
# its centre sits at y=28 under a 47pt status bar, so the tap hits the clock.
|
|
811
|
+
UNDER='WINDOW|428|926|portrait
|
|
812
|
+
1|0|0|Application||App||0|0|428|926|visible,enabled
|
|
813
|
+
2|1|1|StatusBar||||0|0|428|47|visible,enabled
|
|
814
|
+
3|1|1|TextField||Tìm kiếm||32|4|364|48|visible,enabled,actionable
|
|
815
|
+
4|1|1|Button||OK||32|100|364|48|visible,enabled,actionable'
|
|
816
|
+
case "$(snap_warns "$UNDER")" in
|
|
817
|
+
*"centre is under the 47pt status bar"*) ok "a centre under the status bar is reported" ;;
|
|
818
|
+
*) bad "a centre under the status bar is reported" "$(snap_warns "$UNDER" | join_lines ' ')" ;;
|
|
819
|
+
esac
|
|
820
|
+
# and only the one that is under it
|
|
821
|
+
case "$(snap_warns "$UNDER" | grep 'status bar')" in
|
|
822
|
+
*'[2]'*) bad "only the control under the status bar is named" "[2] is the status bar itself" ;;
|
|
823
|
+
*'[4]'*) bad "only the control under the status bar is named" "[4] sits below it" ;;
|
|
824
|
+
*) ok "only the control under the status bar is named" ;;
|
|
825
|
+
esac
|
|
826
|
+
|
|
827
|
+
# No StatusBar in the tree, no claim about one. A full-screen app has none, and
|
|
828
|
+
# a height guessed from the device model would be wrong on the next device.
|
|
829
|
+
NOSB='WINDOW|428|926|portrait
|
|
830
|
+
1|0|0|Application||App||0|0|428|926|visible,enabled
|
|
831
|
+
2|1|1|TextField||Tìm kiếm||32|4|364|48|visible,enabled,actionable'
|
|
832
|
+
case "$(snap_warns "$NOSB")" in
|
|
833
|
+
*"status bar"*) bad "no status bar in the tree means no status bar warning" "it invented one" ;;
|
|
834
|
+
*) ok "no status bar in the tree means no status bar warning" ;;
|
|
835
|
+
esac
|
|
836
|
+
|
|
837
|
+
# --- recorded screens ----------------------------------------------------------
|
|
838
|
+
#
|
|
839
|
+
# The cases above are rectangles chosen to make one rule checkable by hand. These
|
|
840
|
+
# are whole screens a real device actually produced, frozen. They catch what
|
|
841
|
+
# hand-picked numbers cannot: a rule that is right in isolation and wrong when
|
|
842
|
+
# forty elements are on screen at once, which is how the noise problem was found
|
|
843
|
+
# in the first place.
|
|
844
|
+
#
|
|
845
|
+
# A fixture is a claim about what a driver did. Re-record one only after reading
|
|
846
|
+
# the diff and agreeing with it.
|
|
847
|
+
|
|
848
|
+
rows_shape_ok() { # <file> — the seam between two adapters and five consumers
|
|
849
|
+
awk -F'|' '
|
|
850
|
+
NR == 1 { if ($1 != "WINDOW" || NF != 4) { print "line 1 is not a WINDOW row"; exit 1 } ; next }
|
|
851
|
+
NF != 12 { printf "line %d has %d fields, want 12\n", NR, NF; exit 1 }
|
|
852
|
+
$1 + 0 != NR - 1 { printf "line %d has serial %s, want %d\n", NR, $1, NR - 1; exit 1 }
|
|
853
|
+
$3 + 0 >= $1 + 0 { printf "line %d says its parent is %s, which is not before it\n", NR, $3; exit 1 }
|
|
854
|
+
' "$1"
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
fixtures=0
|
|
858
|
+
for f in "$DT_HOME"/tests/fixtures/*.rows; do
|
|
859
|
+
[ -e "$f" ] || continue
|
|
860
|
+
fixtures=$((fixtures + 1))
|
|
861
|
+
b="$(basename "$f")"
|
|
862
|
+
|
|
863
|
+
if out="$(rows_shape_ok "$f")" && [ -z "$out" ]; then
|
|
864
|
+
ok "$b obeys the row contract"
|
|
865
|
+
else
|
|
866
|
+
bad "$b obeys the row contract" "$out"
|
|
867
|
+
fi
|
|
868
|
+
|
|
869
|
+
exp="${f%.rows}.expected"
|
|
870
|
+
if [ -e "$exp" ]; then
|
|
871
|
+
min=44; case "$f" in *android*) min=48 ;; esac
|
|
872
|
+
got="$(awk -v ALL=0 -v GREP="" -v MINHIT="$min" -f "$DT_HOME/scripts/snapshot.awk" < "$f")"
|
|
873
|
+
if [ "$got" = "$(cat "$exp")" ]; then
|
|
874
|
+
ok "$b still renders as recorded"
|
|
875
|
+
else
|
|
876
|
+
bad "$b still renders as recorded" \
|
|
877
|
+
"$(diff <(printf '%s\n' "$got") "$exp" | head -4 | join_lines ' ')"
|
|
878
|
+
fi
|
|
879
|
+
fi
|
|
880
|
+
done
|
|
881
|
+
|
|
882
|
+
# A fixture directory that has quietly emptied would make every check above
|
|
883
|
+
# vacuous, and a green run that tested nothing is worse than a red one.
|
|
884
|
+
if [ "$fixtures" -gt 0 ]; then
|
|
885
|
+
ok "there are $fixtures recorded screen(s) to check against"
|
|
886
|
+
else
|
|
887
|
+
bad "there are recorded screens to check against" "tests/fixtures/ has no .rows files"
|
|
888
|
+
fi
|
|
889
|
+
|
|
890
|
+
# --- install.sh ----------------------------------------------------------------
|
|
891
|
+
#
|
|
892
|
+
# It edits config.json, puts a symlink on PATH and installs Homebrew packages, so
|
|
893
|
+
# the property worth pinning down is that --dry-run does NONE of it. A dry run
|
|
894
|
+
# that mutates something is worse than no dry run at all: it is the flag people
|
|
895
|
+
# reach for precisely when they do not yet trust the script.
|
|
896
|
+
#
|
|
897
|
+
# Every other flag is off here so the check stays hermetic — no Homebrew, no
|
|
898
|
+
# `claude mcp list` health probe over the network, no WebDriverAgent build.
|
|
899
|
+
|
|
900
|
+
INSTALL="$DT_HOME/install.sh"
|
|
901
|
+
if [ ! -x "$INSTALL" ]; then
|
|
902
|
+
bad "install.sh is executable" "not found or not +x at $INSTALL"
|
|
903
|
+
else
|
|
904
|
+
ok "install.sh is executable"
|
|
905
|
+
|
|
906
|
+
# STDIN FROM /dev/null, ALWAYS. install.sh asks which phone and which Apple
|
|
907
|
+
# team when there is more than one and a terminal to ask on. A check that
|
|
908
|
+
# inherits this script's terminal would sit at that prompt forever.
|
|
909
|
+
install_rc() { "$INSTALL" "$@" </dev/null >/dev/null 2>&1; printf '%s' "$?"; }
|
|
910
|
+
|
|
911
|
+
is "install.sh with no platform is a usage error" "$(install_rc)" "1"
|
|
912
|
+
is "install.sh rejects an unknown argument" "$(install_rc --nope)" "1"
|
|
913
|
+
is "install.sh rejects a bad --mcp-scope" "$(install_rc --ios --mcp-scope nope)" "1"
|
|
914
|
+
|
|
915
|
+
# The config it would rewrite is the one this very run is reading, which is
|
|
916
|
+
# what makes the check meaningful rather than theoretical.
|
|
917
|
+
CFG="$DT_HOME/config.json"
|
|
918
|
+
if [ -f "$CFG" ]; then
|
|
919
|
+
before="$(cksum < "$CFG")"
|
|
920
|
+
"$INSTALL" --ios --dry-run --no-deps --no-link --no-driver --no-mcp >/dev/null 2>&1 || true
|
|
921
|
+
is "a dry run leaves config.json byte-identical" "$(cksum < "$CFG")" "$before"
|
|
922
|
+
else
|
|
923
|
+
printf 'SKIP no config.json — the dry-run mutation check needs one\n'
|
|
924
|
+
fi
|
|
925
|
+
|
|
926
|
+
# A dry run must not create the PATH symlink either. Checked by asking for the
|
|
927
|
+
# link explicitly and then looking for it, rather than by trusting the flag.
|
|
928
|
+
DTLINK=""
|
|
929
|
+
for d in /usr/local/bin "$HOME/.local/bin"; do
|
|
930
|
+
[ -d "$d" ] && [ -w "$d" ] && { DTLINK="$d/devicetools"; break; }
|
|
931
|
+
done
|
|
932
|
+
if [ -n "$DTLINK" ] && [ ! -e "$DTLINK" ]; then
|
|
933
|
+
"$INSTALL" --ios --dry-run --no-deps --no-driver </dev/null >/dev/null 2>&1 || true
|
|
934
|
+
if [ -e "$DTLINK" ]; then
|
|
935
|
+
bad "a dry run does not create the PATH symlink" "created $DTLINK"
|
|
936
|
+
rm -f "$DTLINK"
|
|
937
|
+
else
|
|
938
|
+
ok "a dry run does not create the PATH symlink"
|
|
939
|
+
fi
|
|
940
|
+
else
|
|
941
|
+
printf 'SKIP the symlink already exists, so its absence proves nothing\n'
|
|
942
|
+
fi
|
|
943
|
+
|
|
944
|
+
# REGISTERING AN MCP SERVER IS NOT THIS SCRIPT'S DECISION TO MAKE.
|
|
945
|
+
#
|
|
946
|
+
# ~/.claude.json is shared with every other project on the machine. install.sh
|
|
947
|
+
# edits it only when asked with --mcp, and the default must stay the default:
|
|
948
|
+
# a run with no flags prints the command instead of running it.
|
|
949
|
+
out="$("$INSTALL" --ios --dry-run --no-deps --no-link --no-driver </dev/null 2>&1 || true)"
|
|
950
|
+
case "$out" in
|
|
951
|
+
*"claude mcp add devicetools"*) ok "a default run prints the mcp command rather than running it" ;;
|
|
952
|
+
*) bad "a default run prints the mcp command rather than running it" \
|
|
953
|
+
"the command is not in the output" ;;
|
|
954
|
+
esac
|
|
955
|
+
case "$out" in
|
|
956
|
+
*"dry claude mcp add"*) bad "a default run does not call claude" "it tried to" ;;
|
|
957
|
+
*) ok "a default run does not call claude" ;;
|
|
958
|
+
esac
|
|
959
|
+
|
|
960
|
+
# WITH NO TERMINAL THERE IS NOBODY TO ASK, so it must refuse in writing
|
|
961
|
+
# rather than block. This is the CI case, and the case of anyone piping the
|
|
962
|
+
# output somewhere. Two iPhones or fifteen teams on the machine running this
|
|
963
|
+
# would otherwise hang the whole suite.
|
|
964
|
+
out="$("$INSTALL" --ios --dry-run --no-deps --no-link --no-driver </dev/null 2>&1 || true)"
|
|
965
|
+
case "$out" in
|
|
966
|
+
*"choose 1-"*) bad "no terminal means no prompt" "it printed a menu" ;;
|
|
967
|
+
*) ok "no terminal means no prompt" ;;
|
|
968
|
+
esac
|
|
969
|
+
fi
|
|
970
|
+
|
|
971
|
+
# --- remedies are spelled the way the reader can use them ----------------------
|
|
972
|
+
#
|
|
973
|
+
# Over MCP a model sees tool names and nothing else, so "run scripts/snapshot.sh"
|
|
974
|
+
# is an instruction it cannot follow. Every remedy goes through as_cmd, which
|
|
975
|
+
# spells the verb three ways depending on how it was reached. Reported from a
|
|
976
|
+
# real session: tap failed and told the model to run a shell script.
|
|
977
|
+
|
|
978
|
+
hits="$(grep -rn 'run scripts/[a-z]*\.sh' "$DT_HOME/scripts" 2>/dev/null \
|
|
979
|
+
| grep -v '/selftest\.sh:' || true)"
|
|
980
|
+
if [ -z "$hits" ]; then
|
|
981
|
+
ok "no message tells the reader to run a script by path"
|
|
982
|
+
else
|
|
983
|
+
bad "no message tells the reader to run a script by path" \
|
|
984
|
+
"$(printf '%s' "$hits" | head -3 | join_lines ' ')"
|
|
985
|
+
fi
|
|
986
|
+
|
|
987
|
+
is "as_cmd spells a verb for the CLI" "$(DT_CLI=1 as_cmd doctor --recover)" "devicetools doctor --recover"
|
|
988
|
+
is "as_cmd spells a verb for MCP" "$(DT_MCP=1 as_cmd doctor --recover)" "doctor --recover"
|
|
989
|
+
is "as_cmd spells a verb for the shell" "$(as_cmd doctor --recover)" "scripts/doctor.sh --recover"
|
|
990
|
+
|
|
991
|
+
# --- what an action reports about the screen -----------------------------------
|
|
992
|
+
#
|
|
993
|
+
# Three fields out of screen_state, and two callers reading them. The bug this
|
|
994
|
+
# guards against is a third field landing inside the second.
|
|
995
|
+
|
|
996
|
+
split_state "$(printf 'abcd\tLogin\t12345')"
|
|
997
|
+
is "a full state splits into three" "$ST_HASH/$ST_NAME/$ST_CONTENT" "abcd/Login/12345"
|
|
998
|
+
split_state "$(printf 'abcd\t\t12345')"
|
|
999
|
+
is "an unnamed screen keeps its digest" "$ST_HASH/$ST_NAME/$ST_CONTENT" "abcd//12345"
|
|
1000
|
+
split_state "abcd"
|
|
1001
|
+
is "a hash alone leaves the rest empty" "$ST_HASH/$ST_NAME/$ST_CONTENT" "abcd//"
|
|
1002
|
+
split_state ""
|
|
1003
|
+
is "an empty state is empty throughout" "$ST_HASH/$ST_NAME/$ST_CONTENT" "//"
|
|
1004
|
+
|
|
1005
|
+
# --- tap --count ---------------------------------------------------------------
|
|
1006
|
+
#
|
|
1007
|
+
# A keypad entry is one call, not six. The bound is enforced, not merely
|
|
1008
|
+
# documented: an unbounded count is a way to hammer a real device by typo.
|
|
1009
|
+
|
|
1010
|
+
tap_rc() { "$DT_HOME/scripts/tap.sh" "$@" >/dev/null 2>&1; printf '%s' "$?"; }
|
|
1011
|
+
is "--count rejects zero" "$(tap_rc --count 0 x)" "1"
|
|
1012
|
+
is "--count rejects a non-number" "$(tap_rc --count abc x)" "1"
|
|
1013
|
+
is "--count rejects a negative" "$(tap_rc --count -3 x)" "1"
|
|
1014
|
+
is "--count is capped" "$(tap_rc --count 21 x)" "1"
|
|
1015
|
+
|
|
1016
|
+
# --- a suggestion names the selector that would reach it -----------------------
|
|
1017
|
+
#
|
|
1018
|
+
# `tap text:8` was refused, and the list of near misses offered `Key "8"` — the
|
|
1019
|
+
# very thing asked for, matching on its label rather than its value. Naming the
|
|
1020
|
+
# answer without spelling it is a riddle.
|
|
1021
|
+
|
|
1022
|
+
# candidate_elements belongs to an adapter, so it runs in an adapter's process.
|
|
1023
|
+
# The Android one is pure awk over the row format, which is why it is the one
|
|
1024
|
+
# that can be exercised with no device and no driver.
|
|
1025
|
+
cand_in() { # cand_in <row> <value>
|
|
1026
|
+
bash -c 'source "$0" >/dev/null 2>&1; candidate_elements "$1" "$2"' \
|
|
1027
|
+
"$DT_HOME/scripts/android/lib.sh" "$1" "$2" 2>/dev/null || true
|
|
1028
|
+
}
|
|
1029
|
+
cand="$(cand_in '1|Key||8||10|20|40|40|1|1|0|1|' '8')"
|
|
1030
|
+
case "$cand" in
|
|
1031
|
+
*"try label:8"*) ok "an exact match on another field names that selector" ;;
|
|
1032
|
+
*) bad "an exact match on another field names that selector" "got [$cand]" ;;
|
|
1033
|
+
esac
|
|
1034
|
+
cand="$(cand_in '1|TextView||88||10|20|40|40|1|1|0|0|' '8')"
|
|
1035
|
+
case "$cand" in
|
|
1036
|
+
*"try "*) bad "a partial match suggests nothing" "got [$cand]" ;;
|
|
1037
|
+
*) ok "a partial match suggests nothing" ;;
|
|
1038
|
+
esac
|
|
1039
|
+
|
|
1040
|
+
# --- what is inside a bundle, and what will let it onto a phone ----------------
|
|
1041
|
+
#
|
|
1042
|
+
# DeviceTools does not build, and has to say why a build will not install. Both
|
|
1043
|
+
# helpers are pure file reading, so both are checkable here.
|
|
1044
|
+
|
|
1045
|
+
in_ios() { bash -c 'source "$0" >/dev/null 2>&1; "$@"' "$DT_HOME/scripts/ios/lib.sh" "$@" 2>/dev/null || true; }
|
|
1046
|
+
|
|
1047
|
+
BUNDLE_DIR="$(mktemp -d)/Demo.app"
|
|
1048
|
+
dt_cleanup_add "$(dirname "$BUNDLE_DIR")"
|
|
1049
|
+
mkdir -p "$BUNDLE_DIR"
|
|
1050
|
+
cat > "$BUNDLE_DIR/Info.plist" <<'PLIST'
|
|
1051
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
1052
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1053
|
+
<plist version="1.0"><dict>
|
|
1054
|
+
<key>CFBundleIdentifier</key><string>com.example.demo</string>
|
|
1055
|
+
<key>CFBundleShortVersionString</key><string>3.4.1</string>
|
|
1056
|
+
<key>CFBundleVersion</key><string>1207</string>
|
|
1057
|
+
</dict></plist>
|
|
1058
|
+
PLIST
|
|
1059
|
+
|
|
1060
|
+
is "a bundle reports id, version and build" \
|
|
1061
|
+
"$(in_ios bundle_facts "$BUNDLE_DIR")" "com.example.demo|3.4.1|1207||unsigned"
|
|
1062
|
+
|
|
1063
|
+
# The message this exists for: CODE_SIGNING_ALLOWED=NO in a Makefile produces a
|
|
1064
|
+
# bundle a simulator takes and a device refuses, and iOS calls that
|
|
1065
|
+
# ApplicationVerificationFailed.
|
|
1066
|
+
case "$(in_ios bundle_facts "$BUNDLE_DIR")" in
|
|
1067
|
+
*"|unsigned") ok "an unsigned bundle is recognised as unsigned" ;;
|
|
1068
|
+
*) bad "an unsigned bundle is recognised as unsigned" "$(in_ios bundle_facts "$BUNDLE_DIR")" ;;
|
|
1069
|
+
esac
|
|
1070
|
+
|
|
1071
|
+
rmdir "$BUNDLE_DIR" 2>/dev/null || rm -rf "$BUNDLE_DIR"
|
|
1072
|
+
mkdir -p "$BUNDLE_DIR"
|
|
1073
|
+
holds_not "a directory with no Info.plist is not a bundle" \
|
|
1074
|
+
bash -c 'source "$0" >/dev/null 2>&1; bundle_facts "$1"' "$DT_HOME/scripts/ios/lib.sh" "$BUNDLE_DIR"
|
|
1075
|
+
|
|
1076
|
+
# The provisioning checks that stood here are gone with doctor's section 9.
|
|
1077
|
+
# Whose certificate signs the app is the build's business, not this tool's, and
|
|
1078
|
+
# a check standing outside the build could only guess — which it did, twice,
|
|
1079
|
+
# naming a team the project had nothing to do with.
|
|
1080
|
+
#
|
|
1081
|
+
# What survives is above: `app install` still reads the bundle's own signature,
|
|
1082
|
+
# because "will this verb work" is a different question from "whose account is
|
|
1083
|
+
# this".
|
|
1084
|
+
|
|
1085
|
+
# --- app.sh takes its arguments seriously --------------------------------------
|
|
1086
|
+
|
|
1087
|
+
app_rc() { "$DT_HOME/scripts/app.sh" "$@" >/dev/null 2>&1; printf '%s' "$?"; }
|
|
1088
|
+
is "install rejects an unknown flag" "$(app_rc install --bogus)" "1"
|
|
1089
|
+
is "install takes one path" "$(app_rc install a b)" "1"
|
|
1090
|
+
is "launch takes at most one bundle id" "$(app_rc launch a b)" "1"
|
|
1091
|
+
is "uninstall takes at most one bundle id" "$(app_rc uninstall a b)" "1"
|
|
1092
|
+
|
|
1093
|
+
# --- devicetools config --------------------------------------------------------
|
|
1094
|
+
#
|
|
1095
|
+
# It rewrites the file every other script reads, so the checks are about the two
|
|
1096
|
+
# ways that goes wrong quietly: a typo'd key that reports success, and a number
|
|
1097
|
+
# turned into a string that still parses and stops comparing.
|
|
1098
|
+
#
|
|
1099
|
+
# Run against a copy, never the real config — a self-test that edits the config
|
|
1100
|
+
# it is testing with is a self-test that can leave the tool broken.
|
|
1101
|
+
|
|
1102
|
+
DT="$DT_HOME/bin/devicetools"
|
|
1103
|
+
CFGDIR="$(mktemp -d)"
|
|
1104
|
+
dt_cleanup_add "$CFGDIR"
|
|
1105
|
+
cp "$DT_HOME/config.example.json" "$CFGDIR/config.json"
|
|
1106
|
+
|
|
1107
|
+
dtc() { DEVICETOOLS_CONFIG="$CFGDIR/config.json" "$DT" config "$@" 2>&1; }
|
|
1108
|
+
dtc_rc() { DEVICETOOLS_CONFIG="$CFGDIR/config.json" "$DT" config "$@" >/dev/null 2>&1; printf '%s' "$?"; }
|
|
1109
|
+
|
|
1110
|
+
dtc set app.bundle_id com.example.set >/dev/null 2>&1 || true
|
|
1111
|
+
is "config set writes the value" "$(dtc get app.bundle_id)" "com.example.set"
|
|
1112
|
+
|
|
1113
|
+
# There is one way to write a value. A shorthand for the app id would be a
|
|
1114
|
+
# second spelling of `set` to keep in step forever.
|
|
1115
|
+
is "there is no config app shorthand" "$(dtc_rc app com.example.viaapp)" "1"
|
|
1116
|
+
|
|
1117
|
+
# Both app keys exist in every config and only one is read, so the "no such key"
|
|
1118
|
+
# guard cannot catch this one — the key is there, it is simply the wrong one.
|
|
1119
|
+
case "$(dtc set app.package com.example.wrongkey)" in
|
|
1120
|
+
*"platform is ios, which reads app.bundle_id"*)
|
|
1121
|
+
ok "setting the other platform's app key says so" ;;
|
|
1122
|
+
*) bad "setting the other platform's app key says so" "no note was printed" ;;
|
|
1123
|
+
esac
|
|
1124
|
+
|
|
1125
|
+
is "config set refuses a key that does not exist" "$(dtc_rc set app.bundleid x)" "1"
|
|
1126
|
+
is "--new creates it anyway" "$(dtc_rc set app.bundleid x --new)" "0"
|
|
1127
|
+
|
|
1128
|
+
# The type is taken from the value already there. Without that, wait_seconds
|
|
1129
|
+
# becomes "30" and every numeric comparison against it silently changes meaning.
|
|
1130
|
+
dtc set timeouts.wait_seconds 30 >/dev/null 2>&1 || true
|
|
1131
|
+
is "a number stays a number" \
|
|
1132
|
+
"$(jq -r '.timeouts.wait_seconds | type' "$CFGDIR/config.json")" "number"
|
|
1133
|
+
is "a number rejects a non-number" "$(dtc_rc set timeouts.wait_seconds abc)" "1"
|
|
1134
|
+
|
|
1135
|
+
# The key reaches a jq program, so anything but names and dots is refused rather
|
|
1136
|
+
# than escaped.
|
|
1137
|
+
is "a key with punctuation is refused" "$(dtc_rc set 'app.bundle_id; x' y)" "1"
|
|
1138
|
+
|
|
1139
|
+
is "the config stays valid JSON throughout" \
|
|
1140
|
+
"$(jq -e . "$CFGDIR/config.json" >/dev/null 2>&1 && printf yes || printf no)" "yes"
|
|
1141
|
+
|
|
1142
|
+
# An interrupted write must not leave a half-file beside the real one.
|
|
1143
|
+
if ls "$CFGDIR"/config.json.tmp.* >/dev/null 2>&1; then
|
|
1144
|
+
bad "config set leaves no temp file behind" "found $(ls "$CFGDIR"/config.json.tmp.* | head -1)"
|
|
1145
|
+
else
|
|
1146
|
+
ok "config set leaves no temp file behind"
|
|
1147
|
+
fi
|
|
1148
|
+
|
|
1149
|
+
# --- install.sh and uninstall.sh document the flags they accept ----------------
|
|
1150
|
+
#
|
|
1151
|
+
# The help text is the leading comment block, and the parser is a `case` twenty
|
|
1152
|
+
# lines below it. Nothing forces the two to agree, and a flag that works but is
|
|
1153
|
+
# undocumented is a flag nobody uses — while one that is documented and rejected
|
|
1154
|
+
# is worse. So every `--flag)` arm has to appear in `--help`.
|
|
1155
|
+
|
|
1156
|
+
# EVERY VERB TOO, NOT JUST THE INSTALLERS. --snapshot went onto ten of them in
|
|
1157
|
+
# one change; nine documented headers and one silently accepting an undocumented
|
|
1158
|
+
# flag is exactly the drift this rule is for. The adapter file is run rather
|
|
1159
|
+
# than the dispatcher, because the dispatcher loads a config before it reaches
|
|
1160
|
+
# --help and this suite must answer with no device and no phone.
|
|
1161
|
+
verb_scripts="$DT_HOME/scripts/run.sh $DT_HOME/scripts/flow.sh"
|
|
1162
|
+
for v in "$DT_HOME"/scripts/ios/*.sh "$DT_HOME"/scripts/android/*.sh; do
|
|
1163
|
+
case "$(basename "$v")" in lib.sh|setup.sh) continue ;; esac
|
|
1164
|
+
verb_scripts="$verb_scripts $v"
|
|
1165
|
+
done
|
|
1166
|
+
|
|
1167
|
+
for f in "$DT_HOME/install.sh" "$DT_HOME/uninstall.sh" $verb_scripts; do
|
|
1168
|
+
b="$(basename "$(dirname "$f")")/$(basename "$f")"
|
|
1169
|
+
case "$f" in "$DT_HOME"/install.sh|"$DT_HOME"/uninstall.sh) b="$(basename "$f")" ;; esac
|
|
1170
|
+
[ -x "$f" ] || { bad "$b documents every flag it accepts" "not executable"; continue; }
|
|
1171
|
+
help_out="$("$f" --help </dev/null 2>&1 || true)"
|
|
1172
|
+
undocumented=""
|
|
1173
|
+
# Flag names from the case arms: " --foo|-f)" -> --foo -f
|
|
1174
|
+
for flag in $(awk '/^ *--[a-z-]+[|)]/ {
|
|
1175
|
+
sub(/\).*/, ""); gsub(/^ +/, "");
|
|
1176
|
+
n = split($0, a, "|");
|
|
1177
|
+
for (i = 1; i <= n; i++) if (a[i] ~ /^--/) print a[i]
|
|
1178
|
+
}' "$f" | sort -u); do
|
|
1179
|
+
case "$help_out" in *"$flag"*) ;; *) undocumented="$undocumented $flag" ;; esac
|
|
1180
|
+
done
|
|
1181
|
+
if [ -z "$undocumented" ]; then
|
|
1182
|
+
ok "$b documents every flag it accepts"
|
|
1183
|
+
else
|
|
1184
|
+
bad "$b documents every flag it accepts" "missing from --help:$undocumented"
|
|
1185
|
+
fi
|
|
1186
|
+
done
|
|
1187
|
+
|
|
1188
|
+
# --- no verb leaves an unset variable on a path it can reach -------------------
|
|
1189
|
+
#
|
|
1190
|
+
# type.sh reported success on the OTP path and then put `type: unbound variable`
|
|
1191
|
+
# on stderr, because the journal line at the bottom is shared by all three
|
|
1192
|
+
# outcomes and only one of them names the field. Nothing failed, so nothing
|
|
1193
|
+
# announced it; the branch had simply never been reachable until `type` stopped
|
|
1194
|
+
# being refused inside a batch.
|
|
1195
|
+
#
|
|
1196
|
+
# `set -u` catches this AT RUN TIME, which means only on a path something walks.
|
|
1197
|
+
# Reading every expansion here would be a shell parser. What is checkable is the
|
|
1198
|
+
# shape that caused it: a bare $var used after the branch that assigns it, on a
|
|
1199
|
+
# line every branch reaches. So the rule is narrower and mechanical — the last
|
|
1200
|
+
# statement of a verb may not use a variable that only one branch sets.
|
|
1201
|
+
#
|
|
1202
|
+
# Checked by asking bash itself, on the one file that had it.
|
|
1203
|
+
unbound="$(bash -c '
|
|
1204
|
+
set -u
|
|
1205
|
+
# The two branches type.sh takes when it finds no input to name.
|
|
1206
|
+
field_type=""; unset field_type
|
|
1207
|
+
printf "%s" "${field_type-}"
|
|
1208
|
+
' 2>&1 || true)"
|
|
1209
|
+
is "an unset field name does not abort the journal line" "$unbound" ""
|
|
1210
|
+
holds "type.sh guards the variable that only one of its branches sets" \
|
|
1211
|
+
grep -q 'journal_append type "\$TEXT" "\${field_type-}"' "$DT_HOME/scripts/ios/type.sh"
|
|
1212
|
+
# And it is not called `type`, which is a shell builtin — the error read like a
|
|
1213
|
+
# missing command rather than a missing value.
|
|
1214
|
+
holds_not "no verb names a variable after a shell builtin" \
|
|
1215
|
+
grep -qE '^\s*type=' "$DT_HOME/scripts/ios/type.sh" "$DT_HOME/scripts/android/type.sh"
|
|
1216
|
+
|
|
1217
|
+
# --- --project, because MCP has no working directory ---------------------------
|
|
1218
|
+
#
|
|
1219
|
+
# dt_find_project walks up from $PWD, which is right for a shell and impossible
|
|
1220
|
+
# for a server: the host sets its working directory at spawn time and it never
|
|
1221
|
+
# changes, so every walk-up lands in the DeviceTools checkout whatever repository
|
|
1222
|
+
# the person is standing in. Reported after `flow save` wrote into the checkout,
|
|
1223
|
+
# advised running `init` in the app repository, and had no way to make the result
|
|
1224
|
+
# take effect.
|
|
1225
|
+
#
|
|
1226
|
+
# The flag is stripped in lib.sh, so it works on every verb without any of them
|
|
1227
|
+
# knowing. These checks are about that stripping, which is the part with teeth:
|
|
1228
|
+
# it edits the caller's own argument list.
|
|
1229
|
+
|
|
1230
|
+
PROJDIR="$(mktemp -d)"
|
|
1231
|
+
dt_cleanup_add "$PROJDIR"
|
|
1232
|
+
printf 'devicetools-project 1\n' > "$PROJDIR/.devicetools"
|
|
1233
|
+
cp "$DT_HOME/config.example.json" "$PROJDIR/config.json"
|
|
1234
|
+
|
|
1235
|
+
# What the verb is left holding, and where the project ended up.
|
|
1236
|
+
proj_argv() {
|
|
1237
|
+
bash -c 'source "$0"; printf "%s|" "$DT_ROOT"; printf "[%s]" "$@"' "$DT_HOME/scripts/lib.sh" "$@" 2>/dev/null
|
|
1238
|
+
}
|
|
1239
|
+
is "--project sets the root and disappears from the arguments" \
|
|
1240
|
+
"$(proj_argv label:X --project "$PROJDIR" --index 2)" "$PROJDIR|[label:X][--index][2]"
|
|
1241
|
+
is "without it the checkout is still the project" \
|
|
1242
|
+
"$(proj_argv label:X)" "$DT_HOME|[label:X]"
|
|
1243
|
+
# `type -- --project` types the words. Option processing ends at --, or lib.sh
|
|
1244
|
+
# would silently eat text a verb was told to enter.
|
|
1245
|
+
is "-- ends option processing" \
|
|
1246
|
+
"$(proj_argv -- --project "$PROJDIR")" "$DT_HOME|[--][--project][$PROJDIR]"
|
|
1247
|
+
# A trailing --project with nothing after it is left for the verb to refuse,
|
|
1248
|
+
# rather than swallowed as if it had been understood.
|
|
1249
|
+
is "a --project with no value is left alone" \
|
|
1250
|
+
"$(proj_argv --project)" "$DT_HOME|[--project]"
|
|
1251
|
+
|
|
1252
|
+
# Named explicitly, its config wins over one inherited from the environment —
|
|
1253
|
+
# a flag on this call is more specific than a variable set when the server
|
|
1254
|
+
# started, and a child that resolved half of each would be worse than either.
|
|
1255
|
+
is "the project config beats an inherited DEVICETOOLS_CONFIG" \
|
|
1256
|
+
"$(DEVICETOOLS_CONFIG=/nowhere/config.json bash -c \
|
|
1257
|
+
'source "$0" >/dev/null 2>&1; printf "%s" "$DT_CONFIG"' \
|
|
1258
|
+
"$DT_HOME/scripts/lib.sh" --project "$PROJDIR" 2>/dev/null)" \
|
|
1259
|
+
"$PROJDIR/config.json"
|
|
1260
|
+
|
|
1261
|
+
# --- init is a verb, so both sides can reach it ---------------------------------
|
|
1262
|
+
#
|
|
1263
|
+
# It was a `case` arm in bin/devicetools, which made it invisible over MCP:
|
|
1264
|
+
# `flow save` told an agent to run it and no tool could.
|
|
1265
|
+
initsh() { "$DT_HOME/scripts/init.sh" "$@" 2>&1; }
|
|
1266
|
+
initsh_rc() { "$DT_HOME/scripts/init.sh" "$@" >/dev/null 2>&1; printf '%s' "$?"; }
|
|
1267
|
+
|
|
1268
|
+
NEWPROJ="$(mktemp -d)"
|
|
1269
|
+
dt_cleanup_add "$NEWPROJ"
|
|
1270
|
+
is "init makes a directory a project" "$(initsh_rc "$NEWPROJ")" "0"
|
|
1271
|
+
holds "and leaves the marker" test -f "$NEWPROJ/.devicetools"
|
|
1272
|
+
holds "and a config to fill in" test -f "$NEWPROJ/config.json"
|
|
1273
|
+
# flows/ is the reason to have a project, so it is NOT ignored.
|
|
1274
|
+
holds_not "flows/ is not gitignored" grep -q '^flows/' "$NEWPROJ/.gitignore"
|
|
1275
|
+
holds "but the state directory is" grep -q '^\.state/' "$NEWPROJ/.gitignore"
|
|
1276
|
+
|
|
1277
|
+
# It writes into somebody else'"'"'s repository, so a second run must change nothing.
|
|
1278
|
+
printf 'edited by hand\n' > "$NEWPROJ/config.json"
|
|
1279
|
+
initsh "$NEWPROJ" >/dev/null 2>&1 || true
|
|
1280
|
+
is "a second init does not overwrite a config" \
|
|
1281
|
+
"$(cat "$NEWPROJ/config.json")" "edited by hand"
|
|
1282
|
+
|
|
1283
|
+
is "init refuses the checkout itself" "$(initsh_rc "$DT_HOME")" "1"
|
|
1284
|
+
is "init refuses a directory that is not there" "$(initsh_rc /no/such/place)" "1"
|
|
1285
|
+
# On a command line "no argument" means "here". A server has no here.
|
|
1286
|
+
is "over MCP the directory is required" \
|
|
1287
|
+
"$(DT_MCP=1 "$DT_HOME/scripts/init.sh" >/dev/null 2>&1; printf '%s' "$?")" "1"
|
|
1288
|
+
|
|
1289
|
+
# --- run: a batch is checked before any of it happens --------------------------
|
|
1290
|
+
#
|
|
1291
|
+
# Everything here is --dry-run, so nothing reaches a device and the suite still
|
|
1292
|
+
# runs with no phone attached. That is also the property being tested: the whole
|
|
1293
|
+
# step list is validated before the first step is sent, because a typo in step
|
|
1294
|
+
# seven must not be found out after step six has tapped something in a live session.
|
|
1295
|
+
|
|
1296
|
+
RUNDIR="$(mktemp -d)"
|
|
1297
|
+
dt_cleanup_add "$RUNDIR"
|
|
1298
|
+
printf 'devicetools-project 1\n' > "$RUNDIR/.devicetools"
|
|
1299
|
+
cp "$DT_HOME/config.example.json" "$RUNDIR/config.json"
|
|
1300
|
+
|
|
1301
|
+
runsh() { # runsh <args...> — run.sh against the throwaway project
|
|
1302
|
+
DEVICETOOLS_PROJECT="$RUNDIR" DEVICETOOLS_CONFIG="$RUNDIR/config.json" \
|
|
1303
|
+
"$DT_HOME/scripts/run.sh" "$@" 2>&1
|
|
1304
|
+
}
|
|
1305
|
+
runsh_rc() {
|
|
1306
|
+
DEVICETOOLS_PROJECT="$RUNDIR" DEVICETOOLS_CONFIG="$RUNDIR/config.json" \
|
|
1307
|
+
"$DT_HOME/scripts/run.sh" "$@" >/dev/null 2>&1; printf '%s' "$?"
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
is "a batch runs its steps in order" \
|
|
1311
|
+
"$(runsh --dry-run --steps '[{"tap":"label:A"},{"back":true},{"key":"hide"}]' \
|
|
1312
|
+
| awk '/^STEP/ { printf "%s ", $3 }')" \
|
|
1313
|
+
"tap back key "
|
|
1314
|
+
|
|
1315
|
+
# Flags become --key value, a true boolean becomes a bare flag, and two
|
|
1316
|
+
# positional arguments go in an array. Same rule as the MCP server's argv_from,
|
|
1317
|
+
# because it is the same question asked twice.
|
|
1318
|
+
is "extra keys become flags, in the verb's own spelling" \
|
|
1319
|
+
"$(runsh --dry-run --steps '[{"scroll":"down","until":"label:X","times":3}]' | sed -n '2p')" \
|
|
1320
|
+
'STEP 1/1 scroll down --until label:X --times 3'
|
|
1321
|
+
is "an array is two positional arguments" \
|
|
1322
|
+
"$(runsh --dry-run --steps '[{"swipe":["label:A","label:B"]}]' | sed -n '2p')" \
|
|
1323
|
+
'STEP 1/1 swipe label:A label:B'
|
|
1324
|
+
|
|
1325
|
+
# TEXT THAT LOOKS LIKE A UID IS STILL TEXT.
|
|
1326
|
+
#
|
|
1327
|
+
# The uid guard was checking the positional argument of EVERY verb, and `type`'s
|
|
1328
|
+
# positional argument is what to type — so a batch could not enter an OTP, a
|
|
1329
|
+
# PIN, an amount, a card number or a phone number. On a production app that is
|
|
1330
|
+
# close to every field there is, and it meant `flow` could not hold a login,
|
|
1331
|
+
# which is the one example the tool's own description gives for why flows exist.
|
|
1332
|
+
#
|
|
1333
|
+
# Asked for by name in the report that found it: a `type` of digits must always
|
|
1334
|
+
# get through.
|
|
1335
|
+
is "a batch can type an OTP" "$(runsh_rc --dry-run --steps '[{"type":"888888"}]')" "0"
|
|
1336
|
+
is "a batch can type an amount" "$(runsh_rc --dry-run --steps '[{"type":"5000000"}]')" "0"
|
|
1337
|
+
is "and through a variable too" "$(runsh_rc --dry-run --vars '{"otp":"888888"}' --steps '[{"type":"{{ otp }}"}]')" "0"
|
|
1338
|
+
# Substitution runs before validation, so this is the same check from the other
|
|
1339
|
+
# side: the digits must survive it.
|
|
1340
|
+
is "the digits reach the step, masked as a length" \
|
|
1341
|
+
"$(runsh --dry-run --vars '{"otp":"888888"}' --steps '[{"type":"{{ otp }}"}]' | sed -n '2p')" \
|
|
1342
|
+
'STEP 1/1 type (6 chars)'
|
|
1343
|
+
|
|
1344
|
+
# `snapshot` IS BOTH A VERB AND A FLAG. {"tap": …, "snapshot": true} is the most
|
|
1345
|
+
# useful step anybody can write, and the verb list was eating the word before it
|
|
1346
|
+
# could be read as a flag — so it came back as "two verbs in one step".
|
|
1347
|
+
is "snapshot is a flag when the step already has a verb" \
|
|
1348
|
+
"$(runsh --dry-run --steps '[{"tap":"label:X","snapshot":true}]' | sed -n '2p')" \
|
|
1349
|
+
'STEP 1/1 tap label:X --snapshot'
|
|
1350
|
+
is "and still a verb when it is the only one" \
|
|
1351
|
+
"$(runsh --dry-run --steps '[{"snapshot":true}]' | sed -n '2p')" \
|
|
1352
|
+
'STEP 1/1 snapshot'
|
|
1353
|
+
|
|
1354
|
+
# expect is wait with a deadline that means "now" — spelling, not a second
|
|
1355
|
+
# implementation, so it runs wait.sh.
|
|
1356
|
+
is "expect is accepted as a step" "$(runsh_rc --dry-run --steps '[{"expect":"screen:Home"}]')" "0"
|
|
1357
|
+
|
|
1358
|
+
# NO UIDS, AND THE REASON IS NOT TIDINESS. A uid names a position on a screen
|
|
1359
|
+
# the caller was shown before the batch started, and by step three that screen
|
|
1360
|
+
# is two actions old.
|
|
1361
|
+
is "a bare uid is refused" "$(runsh_rc --dry-run --steps '[{"tap":"7"}]')" "1"
|
|
1362
|
+
is "a uid: selector is refused" "$(runsh_rc --dry-run --steps '[{"tap":"uid:7"}]')" "1"
|
|
1363
|
+
is "a uid inside --until is refused" \
|
|
1364
|
+
"$(runsh_rc --dry-run --steps '[{"scroll":"down","until":"3"}]')" "1"
|
|
1365
|
+
# Substitution happens before validation, so a variable holding a uid is one.
|
|
1366
|
+
is "a variable that expands to a uid is still a uid" \
|
|
1367
|
+
"$(runsh_rc --dry-run --var n=7 --steps '[{"tap":"{{ n }}"}]')" "1"
|
|
1368
|
+
|
|
1369
|
+
is "a step with two verbs is refused" \
|
|
1370
|
+
"$(runsh_rc --dry-run --steps '[{"tap":"label:A","type":"x"}]')" "1"
|
|
1371
|
+
is "a step with no verb is refused" \
|
|
1372
|
+
"$(runsh_rc --dry-run --steps '[{"nope":"x"}]')" "1"
|
|
1373
|
+
is "a verb that is not on the list is refused" \
|
|
1374
|
+
"$(runsh_rc --dry-run --steps '[{"doctor":true}]')" "1"
|
|
1375
|
+
is "an object is not a step list" "$(runsh_rc --dry-run --steps '{"tap":"label:A"}')" "1"
|
|
1376
|
+
|
|
1377
|
+
# THE REFUSAL COMES BEFORE THE FIRST STEP, NOT AT THE BAD ONE. Checked by
|
|
1378
|
+
# putting the fault last and looking for any sign the earlier steps announced
|
|
1379
|
+
# themselves.
|
|
1380
|
+
case "$(runsh --dry-run --steps '[{"tap":"label:A"},{"tap":"label:B"},{"tap":"9"}]')" in
|
|
1381
|
+
*STEP*) bad "a bad step stops the batch before any of it runs" "steps were announced anyway" ;;
|
|
1382
|
+
*) ok "a bad step stops the batch before any of it runs" ;;
|
|
1383
|
+
esac
|
|
1384
|
+
|
|
1385
|
+
# A MISSING VARIABLE IS NOT A STRING TO TYPE. Without this, a flow whose
|
|
1386
|
+
# password was never supplied types the fourteen characters "{{ password }}"
|
|
1387
|
+
# into a login form and reports success, because something did change.
|
|
1388
|
+
is "an unfilled placeholder stops the batch" \
|
|
1389
|
+
"$(runsh_rc --dry-run --steps '[{"type":"{{ password }}"}]')" "1"
|
|
1390
|
+
case "$(runsh --dry-run --steps '[{"type":"{{ password }}"}]')" in
|
|
1391
|
+
*"no value for: password"*) ok "the unfilled placeholder is named" ;;
|
|
1392
|
+
*) bad "the unfilled placeholder is named" "$(runsh --dry-run --steps '[{"type":"{{ password }}"}]' | join_lines ' / ')" ;;
|
|
1393
|
+
esac
|
|
1394
|
+
|
|
1395
|
+
# THE SECRET NEVER APPEARS. `type` is announced as a length, and every other
|
|
1396
|
+
# substituted value is masked back to {{ name }} — this output is read by a
|
|
1397
|
+
# model, written to a log and pasted into tickets.
|
|
1398
|
+
typed="$(runsh --dry-run --var password='hunter2' --steps '[{"type":"{{ password }}"},{"tap":"label:{{ password }}"}]')"
|
|
1399
|
+
case "$typed" in
|
|
1400
|
+
*'hunter2'*) bad "a variable's value never reaches the transcript" "the value was printed" ;;
|
|
1401
|
+
*) ok "a variable's value never reaches the transcript" ;;
|
|
1402
|
+
esac
|
|
1403
|
+
case "$typed" in
|
|
1404
|
+
*'type (7 chars)'*) ok "typed text is announced as a length" ;;
|
|
1405
|
+
*) bad "typed text is announced as a length" "$(printf '%s' "$typed" | join_lines ' / ')" ;;
|
|
1406
|
+
esac
|
|
1407
|
+
case "$typed" in
|
|
1408
|
+
*'tap label:{{ password }}'*) ok "a substituted value is masked back to its name" ;;
|
|
1409
|
+
*) bad "a substituted value is masked back to its name" "$(printf '%s' "$typed" | join_lines ' / ')" ;;
|
|
1410
|
+
esac
|
|
1411
|
+
|
|
1412
|
+
# A BATCH OF EIGHT PRINTED FIFTY-SIX LINES OF SYSTEM LOG.
|
|
1413
|
+
#
|
|
1414
|
+
# Every action attaches the app's log lines since it began. Alone that is a few
|
|
1415
|
+
# lines; in a batch it multiplies, almost all of it `UIAccessibility: Attempting
|
|
1416
|
+
# to send notification` — more noise than the batch produced in signal, growing
|
|
1417
|
+
# with the length of the batch, which is the thing this verb exists to make
|
|
1418
|
+
# longer. So a step that worked loses its LOG block and the step that failed
|
|
1419
|
+
# keeps it, because that is the one anybody is reading.
|
|
1420
|
+
strip_in() { printf '%s\n' "$1" | strip_log_block; }
|
|
1421
|
+
LOGGY='TAP label:X — resolved by label, Button at 24,600,327,50 → tapped 187,625
|
|
1422
|
+
SCREEN changed — "A" #1111 → "B" #2222
|
|
1423
|
+
LOG 2 lines
|
|
1424
|
+
UIAccessibility: Attempting to send notification
|
|
1425
|
+
UIAccessibility: Attempting to send notification
|
|
1426
|
+
|
|
1427
|
+
SCREEN "B" #2222 428x926 portrait
|
|
1428
|
+
|
|
1429
|
+
[1] Button "Xác nhận" (24,600) 327x50 enabled'
|
|
1430
|
+
# Four lines of substance survive: the action, the SCREEN verdict, the tree's
|
|
1431
|
+
# own SCREEN header and its one element. Three go: the LOG header and two log
|
|
1432
|
+
# lines.
|
|
1433
|
+
is "the log block goes, and nothing else does" \
|
|
1434
|
+
"$(strip_in "$LOGGY" | grep -c .)" "4"
|
|
1435
|
+
case "$(strip_in "$LOGGY")" in
|
|
1436
|
+
*UIAccessibility*) bad "the log lines themselves are dropped" "they survived" ;;
|
|
1437
|
+
*) ok "the log lines themselves are dropped" ;;
|
|
1438
|
+
esac
|
|
1439
|
+
# The --snapshot tree is neither indented by seven nor introduced by LOG, so it
|
|
1440
|
+
# must come through whole — dropping it would take the point out of the flag.
|
|
1441
|
+
case "$(strip_in "$LOGGY")" in
|
|
1442
|
+
*'[1] Button "Xác nhận"'*) ok "a --snapshot tree survives the log stripping" ;;
|
|
1443
|
+
*) bad "a --snapshot tree survives the log stripping" "$(strip_in "$LOGGY" | join_lines ' / ')" ;;
|
|
1444
|
+
esac
|
|
1445
|
+
|
|
1446
|
+
# --- flow: the batch, kept ------------------------------------------------------
|
|
1447
|
+
|
|
1448
|
+
flowsh() {
|
|
1449
|
+
DEVICETOOLS_PROJECT="$RUNDIR" DEVICETOOLS_CONFIG="$RUNDIR/config.json" \
|
|
1450
|
+
"$DT_HOME/scripts/flow.sh" "$@" 2>&1
|
|
1451
|
+
}
|
|
1452
|
+
flowsh_rc() {
|
|
1453
|
+
DEVICETOOLS_PROJECT="$RUNDIR" DEVICETOOLS_CONFIG="$RUNDIR/config.json" \
|
|
1454
|
+
"$DT_HOME/scripts/flow.sh" "$@" >/dev/null 2>&1; printf '%s' "$?"
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
case "$(flowsh list)" in
|
|
1458
|
+
*'no flows yet'*) ok "an empty flows directory says so rather than failing" ;;
|
|
1459
|
+
*) bad "an empty flows directory says so rather than failing" "$(flowsh list | join_lines ' / ')" ;;
|
|
1460
|
+
esac
|
|
1461
|
+
|
|
1462
|
+
flowsh save login --description "log in" \
|
|
1463
|
+
--steps '[{"wait":"screen:Auth.LoginView","timeout":2},{"type":"{{ password }}"}]' >/dev/null 2>&1 || true
|
|
1464
|
+
holds "a saved flow is a file in the project" test -f "$RUNDIR/flows/login.json"
|
|
1465
|
+
|
|
1466
|
+
# THE VARIABLES A FLOW NEEDS ARE READ OUT OF ITS OWN STEPS, not declared by
|
|
1467
|
+
# hand. A hand-kept list is a second copy of the truth, and it is the copy that
|
|
1468
|
+
# goes stale.
|
|
1469
|
+
is "a flow records the variables its steps use" \
|
|
1470
|
+
"$(jq -rc '.vars | join(",")' "$RUNDIR/flows/login.json" 2>/dev/null)" "password"
|
|
1471
|
+
|
|
1472
|
+
is "a flow will not save steps that would not run" \
|
|
1473
|
+
"$(flowsh_rc save broken --steps '[{"tap":"7"}]')" "1"
|
|
1474
|
+
holds_not "a refused flow leaves no file behind" test -f "$RUNDIR/flows/broken.json"
|
|
1475
|
+
|
|
1476
|
+
# `flow list` could never run a flow called "list".
|
|
1477
|
+
is "a flow may not be named after a subcommand" "$(flowsh_rc save list --steps '[{"back":true}]')" "1"
|
|
1478
|
+
# The name becomes a filename, so it may not be a path.
|
|
1479
|
+
is "a flow name may not be a path" "$(flowsh_rc run ../../etc/passwd)" "1"
|
|
1480
|
+
is "an unknown flow is refused" "$(flowsh_rc run nosuchflow)" "1"
|
|
1481
|
+
|
|
1482
|
+
is "a bare name means run" "$(flowsh_rc login --dry-run --var password=x)" "0"
|
|
1483
|
+
is "a flow refuses to run without the values it needs" "$(flowsh_rc login --dry-run)" "1"
|
|
1484
|
+
|
|
1485
|
+
flowsh save outer --steps '[{"flow":"login"},{"back":true}]' >/dev/null 2>&1 || true
|
|
1486
|
+
case "$(flowsh outer --dry-run --var password=x)" in
|
|
1487
|
+
*'STEP 1/2 flow login'*) ok "a flow can be a step in another flow" ;;
|
|
1488
|
+
*) bad "a flow can be a step in another flow" "$(flowsh outer --dry-run --var password=x | join_lines ' / ')" ;;
|
|
1489
|
+
esac
|
|
1490
|
+
# `outer` has no placeholder of its own and still cannot run without login's
|
|
1491
|
+
# password. Declaring only its own would make `flow list` under-report it and
|
|
1492
|
+
# `flow run outer` fail at the step instead of at the door.
|
|
1493
|
+
is "a composing flow declares what its children need" \
|
|
1494
|
+
"$(jq -rc '.vars | join(",")' "$RUNDIR/flows/outer.json" 2>/dev/null)" "password"
|
|
1495
|
+
is "and it refuses to run without them" "$(flowsh_rc outer --dry-run)" "1"
|
|
1496
|
+
|
|
1497
|
+
# A FLOW THAT INCLUDES ITSELF IS REFUSED WHEN IT IS SAVED, not when it is run.
|
|
1498
|
+
# save validates with a dry run, the dry run descends into nested flows, and a
|
|
1499
|
+
# flow cannot include one that does not exist yet — so a cycle cannot be written
|
|
1500
|
+
# in the first place.
|
|
1501
|
+
is "a self-referential flow cannot be saved" \
|
|
1502
|
+
"$(flowsh_rc save selfref --steps '[{"flow":"selfref"}]')" "1"
|
|
1503
|
+
|
|
1504
|
+
# And the depth cap is still there underneath, for a cycle assembled some other
|
|
1505
|
+
# way — by editing two files by hand, say.
|
|
1506
|
+
is "a flow refuses to nest past the cap" \
|
|
1507
|
+
"$(DT_FLOW_DEPTH=3 DEVICETOOLS_PROJECT="$RUNDIR" DEVICETOOLS_CONFIG="$RUNDIR/config.json" \
|
|
1508
|
+
"$DT_HOME/scripts/flow.sh" login --dry-run >/dev/null 2>&1; printf '%s' "$?")" "1"
|
|
1509
|
+
|
|
1510
|
+
is "delete removes it" "$(flowsh_rc delete login)" "0"
|
|
1511
|
+
holds_not "and the file is gone" test -f "$RUNDIR/flows/login.json"
|
|
1512
|
+
|
|
1513
|
+
# --- nothing calls a function that was deleted ---------------------------------
|
|
1514
|
+
#
|
|
1515
|
+
# wait.sh guarded an early exit with `[ -n "$(baseline_file)" ]`, and
|
|
1516
|
+
# baseline_file belonged to the test layer that was demolished. So every poll ran
|
|
1517
|
+
# it as a command, put "command not found" on stderr, and read the condition as
|
|
1518
|
+
# false — eight lines of noise per wait, on a phone, guarding a block that had
|
|
1519
|
+
# not run since the day the function was removed. Nothing failed, which is why
|
|
1520
|
+
# it lasted.
|
|
1521
|
+
#
|
|
1522
|
+
# The demolition check above catches a deleted FILE being named. This catches a
|
|
1523
|
+
# deleted FUNCTION being called, which is the quieter half.
|
|
1524
|
+
for gonefn in baseline_file fingerprint_score repair_log record_step coverage_of; do
|
|
1525
|
+
hits="$(grep -rn "\\b$gonefn\\b" "$DT_HOME/scripts" "$DT_HOME/bin" "$DT_HOME/integrations" 2>/dev/null \
|
|
1526
|
+
| grep -v '/selftest\.sh:' | grep -vE ':[0-9]+: *#' || true)"
|
|
1527
|
+
if [ -z "$hits" ]; then
|
|
1528
|
+
ok "nothing calls $gonefn"
|
|
1529
|
+
else
|
|
1530
|
+
bad "nothing calls $gonefn" "$(printf '%s' "$hits" | awk -F: '{ printf "%s:%s ", $1, $2 }')"
|
|
1531
|
+
fi
|
|
1532
|
+
done
|
|
1533
|
+
|
|
1534
|
+
# --- --help is derived, never addressed by line number -------------------------
|
|
1535
|
+
#
|
|
1536
|
+
# `sed -n '2,31p'` is correct exactly until somebody adds a paragraph above the
|
|
1537
|
+
# range, and then it prints half a sentence and stops. Every verb in this tool
|
|
1538
|
+
# had one, and snapshot's was already cutting `--grep`'s description in two.
|
|
1539
|
+
# Nothing announces it: the flag is still mentioned, so even the check that
|
|
1540
|
+
# every flag appears in --help went on passing.
|
|
1541
|
+
ranged="$(grep -rln "sed -n '2,[0-9]*p' \"\${BASH_SOURCE\[0\]}\"" \
|
|
1542
|
+
"$DT_HOME/scripts" "$DT_HOME/bin" "$DT_HOME/integrations" 2>/dev/null || true)"
|
|
1543
|
+
if [ -z "$ranged" ]; then
|
|
1544
|
+
ok "no script prints its help by line number"
|
|
1545
|
+
else
|
|
1546
|
+
bad "no script prints its help by line number" \
|
|
1547
|
+
"$(printf '%s' "$ranged" | join_lines ' ')"
|
|
1548
|
+
fi
|
|
1549
|
+
|
|
1550
|
+
# --- nothing writes into a reader that leaves early ----------------------------
|
|
1551
|
+
#
|
|
1552
|
+
# `printf … | grep -q "$x"` prints a broken pipe on every match. grep -q exits
|
|
1553
|
+
# the instant it finds the string, the shell's own printf is still writing, and
|
|
1554
|
+
# bash puts `lib.sh: line N: printf: write error: Broken pipe` on stderr.
|
|
1555
|
+
# Doctor leaked two of those on every run against a healthy phone — a message
|
|
1556
|
+
# that reads like a fault, next to a column of OKs, in the one command people
|
|
1557
|
+
# run when they already suspect something is broken.
|
|
1558
|
+
#
|
|
1559
|
+
# Only the SHELL's printf is caught here, and only when it feeds the early
|
|
1560
|
+
# exiter directly. `printf | jq | head` is a different shape: jq reads its input
|
|
1561
|
+
# to the end before it emits anything, so the write completes.
|
|
1562
|
+
# Comment lines are dropped, or this check fails on the paragraphs explaining
|
|
1563
|
+
# it — including the two above the real fixes, which name the shape they are
|
|
1564
|
+
# there to stop somebody writing again.
|
|
1565
|
+
pipebug="$(grep -rnE "printf[^|]*\| *(grep -q|head )" \
|
|
1566
|
+
"$DT_HOME/scripts" "$DT_HOME/bin" "$DT_HOME/integrations" 2>/dev/null \
|
|
1567
|
+
| grep -v '/selftest\.sh:' | grep -vE ':[0-9]+: *#' || true)"
|
|
1568
|
+
if [ -z "$pipebug" ]; then
|
|
1569
|
+
ok "no shell printf writes into grep -q or head"
|
|
1570
|
+
else
|
|
1571
|
+
bad "no shell printf writes into grep -q or head" \
|
|
1572
|
+
"$(printf '%s' "$pipebug" | awk -F: '{ printf "%s:%s ", $1, $2 }')"
|
|
1573
|
+
fi
|
|
1574
|
+
|
|
1575
|
+
# --- the link manifest cannot drift from the tree ------------------------------
|
|
1576
|
+
#
|
|
1577
|
+
# scripts/links.tsv is what puts the verb surface back after a package manager
|
|
1578
|
+
# throws it away, and it is generated from the tree — so the only way it goes
|
|
1579
|
+
# wrong is by being generated once and then forgotten. Regenerating and
|
|
1580
|
+
# comparing is the same rule the recorded screen lives under.
|
|
1581
|
+
if diff <(cd "$DT_HOME/scripts" && find . -type l | sed 's|^\./||' | sort \
|
|
1582
|
+
| while IFS= read -r l; do printf '%s\t%s\n' "$l" "$(readlink "$l")"; done) \
|
|
1583
|
+
"$DT_HOME/scripts/links.tsv" >/dev/null 2>&1; then
|
|
1584
|
+
ok "the link manifest matches the symlinks in the tree"
|
|
1585
|
+
else
|
|
1586
|
+
bad "the link manifest matches the symlinks in the tree" \
|
|
1587
|
+
"run scripts/relink.sh --write"
|
|
1588
|
+
fi
|
|
1589
|
+
|
|
1590
|
+
# A checkout with its links intact must find nothing to do — this runs from both
|
|
1591
|
+
# entry points on every invocation, and one that did work every time would be a
|
|
1592
|
+
# tool rewriting its own installation directory for no reason.
|
|
1593
|
+
is "relink is a no-op on a complete tree" \
|
|
1594
|
+
"$("$DT_HOME/scripts/relink.sh" --check >/dev/null 2>&1; printf '%s' "$?")" "0"
|
|
1595
|
+
|
|
1596
|
+
# The restore itself, on a copy — never on the tree the suite is running from.
|
|
1597
|
+
LINKDIR="$(mktemp -d)"
|
|
1598
|
+
dt_cleanup_add "$LINKDIR"
|
|
1599
|
+
cp -R "$DT_HOME/scripts" "$LINKDIR/scripts"
|
|
1600
|
+
find "$LINKDIR/scripts" -type l -delete
|
|
1601
|
+
is "a tree with no links at all is reported incomplete" \
|
|
1602
|
+
"$("$LINKDIR/scripts/relink.sh" --check >/dev/null 2>&1; printf '%s' "$?")" "2"
|
|
1603
|
+
is "and relink puts every one of them back" \
|
|
1604
|
+
"$("$LINKDIR/scripts/relink.sh" >/dev/null 2>&1 && "$LINKDIR/scripts/relink.sh" --check >/dev/null 2>&1; printf '%s' "$?")" "0"
|
|
1605
|
+
is "the restored links point where the manifest says" \
|
|
1606
|
+
"$(readlink "$LINKDIR/scripts/ios/tap.sh"),$(readlink "$LINKDIR/scripts/tap.sh")" \
|
|
1607
|
+
"../common/tap.sh,dispatch.sh"
|
|
1608
|
+
|
|
1609
|
+
# A REGULAR FILE IN THE WAY IS NOT REPLACED. Somebody may have made a verb into
|
|
1610
|
+
# a real script on purpose, and an installer may have dereferenced a link into a
|
|
1611
|
+
# copy; overwriting either to restore a symlink is worse than saying what is there.
|
|
1612
|
+
find "$LINKDIR/scripts" -type l -delete
|
|
1613
|
+
printf '#!/usr/bin/env bash\n' > "$LINKDIR/scripts/tap.sh"
|
|
1614
|
+
"$LINKDIR/scripts/relink.sh" >/dev/null 2>&1 || true
|
|
1615
|
+
is "a real file where a link belongs is left alone" \
|
|
1616
|
+
"$(head -1 "$LINKDIR/scripts/tap.sh")" "#!/usr/bin/env bash"
|
|
1617
|
+
|
|
1618
|
+
# --- the two config resolvers agree --------------------------------------------
|
|
1619
|
+
#
|
|
1620
|
+
# lib.sh and the MCP server each work out where config.json is, and the server
|
|
1621
|
+
# deliberately sources nothing — it owns its own EXIT trap. Two copies of an
|
|
1622
|
+
# order is two chances for them to answer differently, and a session where the
|
|
1623
|
+
# CLI and the agent are driving different apps is a long way from obvious.
|
|
1624
|
+
CFGSEQ="$(mktemp -d)"; dt_cleanup_add "$CFGSEQ"
|
|
1625
|
+
mkdir -p "$CFGSEQ/xdg/devicetools" "$CFGSEQ/proj"
|
|
1626
|
+
cp "$DT_HOME/config.example.json" "$CFGSEQ/xdg/devicetools/config.json"
|
|
1627
|
+
cp "$DT_HOME/config.example.json" "$CFGSEQ/proj/config.json"
|
|
1628
|
+
printf 'devicetools-project 1\n' > "$CFGSEQ/proj/.devicetools"
|
|
1629
|
+
|
|
1630
|
+
lib_config() { # lib_config <env-assignments...> — what lib.sh resolves to
|
|
1631
|
+
env "$@" bash -c 'source "$0" >/dev/null 2>&1; printf "%s" "$DT_CONFIG"' \
|
|
1632
|
+
"$DT_HOME/scripts/lib.sh" 2>/dev/null
|
|
1633
|
+
}
|
|
1634
|
+
is "an explicit DEVICETOOLS_CONFIG wins" \
|
|
1635
|
+
"$(lib_config DEVICETOOLS_CONFIG=/named/config.json XDG_CONFIG_HOME="$CFGSEQ/xdg")" \
|
|
1636
|
+
"/named/config.json"
|
|
1637
|
+
is "then the project" \
|
|
1638
|
+
"$(lib_config DEVICETOOLS_PROJECT="$CFGSEQ/proj" XDG_CONFIG_HOME="$CFGSEQ/xdg")" \
|
|
1639
|
+
"$CFGSEQ/proj/config.json"
|
|
1640
|
+
# The npx case: the package directory has no config, so the per-user one is what
|
|
1641
|
+
# is left — and it must be found rather than reported as missing beside a cache
|
|
1642
|
+
# directory npm may delete before the next run.
|
|
1643
|
+
is "then the per-user one" \
|
|
1644
|
+
"$(lib_config DEVICETOOLS_PROJECT="$CFGSEQ" XDG_CONFIG_HOME="$CFGSEQ/xdg")" \
|
|
1645
|
+
"$CFGSEQ/xdg/devicetools/config.json"
|
|
1646
|
+
|
|
1647
|
+
# --- the verb surface ---------------------------------------------------------
|
|
1648
|
+
#
|
|
1649
|
+
# Every scripts/<verb>.sh is a symlink to dispatch.sh, and dispatch execs
|
|
1650
|
+
# scripts/<platform>/<verb>.sh. A verb whose adapter file was deleted still
|
|
1651
|
+
# looks fine in `ls` and fails only when somebody calls it, which during a
|
|
1652
|
+
# demolition is exactly the mistake worth catching.
|
|
1653
|
+
|
|
1654
|
+
# A SYMLINK IS THE TEST, NOT A LIST OF EXCEPTIONS. Every dispatched verb is a
|
|
1655
|
+
# symlink to dispatch.sh; a real file in scripts/ is platform-neutral by
|
|
1656
|
+
# construction — lib.sh, selftest.sh, run.sh, flow.sh — and has no adapter to
|
|
1657
|
+
# be missing. Naming them would be a second list to keep, and it is the list
|
|
1658
|
+
# that would not be kept.
|
|
1659
|
+
for v in "$DT_HOME"/scripts/*.sh; do
|
|
1660
|
+
[ -L "$v" ] || continue
|
|
1661
|
+
b="$(basename "$v" .sh)"
|
|
1662
|
+
missing=""
|
|
1663
|
+
for p in ios android; do
|
|
1664
|
+
[ -e "$DT_HOME/scripts/$p/$b.sh" ] || missing="$missing $p"
|
|
1665
|
+
done
|
|
1666
|
+
if [ -z "$missing" ]; then
|
|
1667
|
+
ok "verb '$b' exists for both platforms"
|
|
1668
|
+
else
|
|
1669
|
+
bad "verb '$b' exists for both platforms" "missing for:$missing"
|
|
1670
|
+
fi
|
|
1671
|
+
done
|
|
1672
|
+
|
|
1673
|
+
# And nothing in scripts/ may still reference something that was deleted — not
|
|
1674
|
+
# in a call, and not in a comment either. A comment pointing at a script that is
|
|
1675
|
+
# gone is a wrong answer to somebody reading the file to learn how this works.
|
|
1676
|
+
#
|
|
1677
|
+
# Two rounds of demolition are listed here: the test layer, and then the parts
|
|
1678
|
+
# that were not the debugger — the studio window, screen streaming, video.
|
|
1679
|
+
#
|
|
1680
|
+
# This file is excluded because the list below names every one of them.
|
|
1681
|
+
# run.sh is NOT on this list any more. The name belonged to the deleted test
|
|
1682
|
+
# runner and has been reclaimed by the batch verb, which is a different thing
|
|
1683
|
+
# that happens to be the obvious word for it.
|
|
1684
|
+
for gone in report.sh repair.sh resolve.awk coverage.sh audit.sh \
|
|
1685
|
+
explore.sh quickstart.sh fleet.sh diagnose.sh record.sh \
|
|
1686
|
+
studio.sh stream.sh capture.sh video.sh inspect.sh \
|
|
1687
|
+
mjpeg-oneshot.js StudioApp.swift; do
|
|
1688
|
+
# -F, because these names contain a dot. Without it `stream.sh` matches the
|
|
1689
|
+
# phrase "Upstream ships", and the check reports a dangling reference that is
|
|
1690
|
+
# nothing of the kind — a false alarm in a demolition check is how a real one
|
|
1691
|
+
# gets ignored.
|
|
1692
|
+
hits="$(grep -rlF "$gone" "$DT_HOME/scripts" "$DT_HOME/bin" 2>/dev/null \
|
|
1693
|
+
| grep -v '/selftest\.sh$' || true)"
|
|
1694
|
+
if [ -z "$hits" ]; then
|
|
1695
|
+
ok "nothing references $gone"
|
|
1696
|
+
else
|
|
1697
|
+
bad "nothing references $gone" "still named in: $(printf '%s' "$hits" | tr '\n' ' ')"
|
|
1698
|
+
fi
|
|
1699
|
+
done
|
|
1700
|
+
|
|
1701
|
+
# --- verdict ------------------------------------------------------------------
|
|
1702
|
+
|
|
1703
|
+
DT_REACHED_END=1
|
|
1704
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
1705
|
+
printf 'RESULT PASS neutral-layer selftest\n'
|
|
1706
|
+
exit 0
|
|
1707
|
+
fi
|
|
1708
|
+
printf 'RESULT FAIL %d check(s)\n' "$FAILED"
|
|
1709
|
+
exit 5
|