device-devtools-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,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