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
package/scripts/lib.sh ADDED
@@ -0,0 +1,1032 @@
1
+ #!/usr/bin/env bash
2
+ # DeviceTools — shared, platform-neutral helpers.
3
+ #
4
+ # Sourced, never executed. Nothing in this file may know that WebDriverAgent,
5
+ # adb or any other driver exists: that is what makes a second platform a matter
6
+ # of adding a directory rather than editing everything.
7
+ #
8
+ # Exit codes, uniform across every script and every platform:
9
+ # 0 success
10
+ # 1 usage error (bad arguments)
11
+ # 2 environment or configuration problem
12
+ # 3 device or driver connectivity lost — caller must run doctor
13
+ # 4 the request was well-formed but the screen did not satisfy it
14
+ # (zero matches, several matches, a condition that never held)
15
+
16
+ set -euo pipefail
17
+
18
+ # --- where the tool is, and where the work is ---------------------------------
19
+ #
20
+ # These are two different directories and conflating them is what stops this
21
+ # being installable. DT_HOME is the checkout: scripts, adapters, AGENTS.md —
22
+ # read-only, one copy per machine, upgraded by pulling. DT_ROOT is the
23
+ # project: config.json, environments/, .state/, .runs/ — the customer's,
24
+ # versioned in the customer's repository.
25
+ #
26
+ # Every relative path in a config resolves against DT_ROOT. Nothing
27
+ # user-authored ever resolves into DT_HOME.
28
+ DT_HOME="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
29
+ export DT_HOME
30
+
31
+ # The project is found by walking up from the working directory looking for a
32
+ # .devicetools marker — the same rule git uses, for the same reason: you should be
33
+ # able to run this from a subdirectory.
34
+ #
35
+ # The marker is an explicit file, not "the directory containing config.json".
36
+ # config.json is gitignored, so a fresh clone of a test repository has none, and
37
+ # guessing on a filename that common would sooner or later adopt an unrelated
38
+ # directory as the project and read a config that was never meant for us. A
39
+ # wrong project root is the same class of failure as a wrong tap.
40
+ #
41
+ # With no marker anywhere above, the checkout is the project. That is this
42
+ # repository's own layout, and it keeps every existing invocation working.
43
+ dt_find_project() {
44
+ local d="$PWD"
45
+ while [ -n "$d" ] && [ "$d" != / ]; do
46
+ [ -f "$d/.devicetools" ] && { printf '%s' "$d"; return 0; }
47
+ d="$(dirname "$d")"
48
+ done
49
+ [ -f /.devicetools ] && { printf '%s' /; return 0; }
50
+ printf '%s' "$DT_HOME"
51
+ }
52
+
53
+ # --project <dir> — SAY WHICH PROJECT, BECAUSE MCP HAS NO WORKING DIRECTORY.
54
+ #
55
+ # dt_find_project walks up from $PWD, which is the rule git uses and the right
56
+ # one for a shell. An MCP server has no such thing: the host sets its working
57
+ # directory when it spawns the process and it never changes, so every walk-up
58
+ # lands in the DeviceTools checkout no matter which repository the person
59
+ # holding the conversation is standing in. Reported after `flow save` wrote into
60
+ # the checkout, told the caller to run `init` in their repository, and then had
61
+ # nothing that could make the result take effect — an agent following its own
62
+ # advice into a dead end.
63
+ #
64
+ # So the project can be named per call, on ANY verb. Stripped out of the
65
+ # argument list here rather than parsed by each verb: sourcing this file happens
66
+ # at the top level of every script, so `set --` below reaches the caller's own
67
+ # positional parameters and nothing downstream has to know the flag exists.
68
+ #
69
+ # The project's config.json wins when it has one, and is exported, so that a
70
+ # child this verb spawns — run.sh calling tap.sh, flow.sh calling run.sh —
71
+ # resolves to the same place rather than half of each.
72
+ #
73
+ # `--` ends option processing, so `type -- --project` types the words.
74
+ _dt_argv=()
75
+ _dt_endopts=0
76
+ while [ $# -gt 0 ]; do
77
+ case "$1" in
78
+ --) _dt_endopts=1; _dt_argv+=("$1"); shift ;;
79
+ --project)
80
+ if [ "$_dt_endopts" -eq 1 ] || [ $# -lt 2 ]; then _dt_argv+=("$1"); shift; continue; fi
81
+ DEVICETOOLS_PROJECT="$2"; export DEVICETOOLS_PROJECT
82
+ DT_PROJECT_EXPLICIT=1
83
+ shift 2 ;;
84
+ *) _dt_argv+=("$1"); shift ;;
85
+ esac
86
+ done
87
+ set -- ${_dt_argv[@]+"${_dt_argv[@]}"}
88
+ unset _dt_argv _dt_endopts
89
+
90
+ if [ -n "${DEVICETOOLS_PROJECT:-}" ]; then
91
+ DT_ROOT="$(cd "$DEVICETOOLS_PROJECT" 2>/dev/null && pwd)" \
92
+ || { printf 'no such directory: DEVICETOOLS_PROJECT=%s\n' "$DEVICETOOLS_PROJECT" >&2; exit 2; }
93
+ else
94
+ DT_ROOT="$(dt_find_project)"
95
+ fi
96
+ export DT_ROOT
97
+
98
+ # Named explicitly, and it has a config: that config is the config, ahead of an
99
+ # inherited DEVICETOOLS_CONFIG. A flag on this call is more specific than an
100
+ # environment variable set when the server started.
101
+ if [ -n "${DT_PROJECT_EXPLICIT:-}" ] && [ -f "$DT_ROOT/config.json" ]; then
102
+ DEVICETOOLS_CONFIG="$DT_ROOT/config.json"
103
+ export DEVICETOOLS_CONFIG
104
+ fi
105
+
106
+ # devicetools_version — the release, and the commit when the checkout is a git one.
107
+ # A report or a bug report that cannot name the build it came from is a bug
108
+ # report about an unknown program.
109
+ devicetools_version() {
110
+ local v c
111
+ v="$(cat "$DT_HOME/VERSION" 2>/dev/null || printf 'unknown')"
112
+ c="$(git -C "$DT_HOME" rev-parse --short HEAD 2>/dev/null || true)"
113
+ [ -n "$c" ] && printf '%s+%s' "$v" "$c" || printf '%s' "$v"
114
+ }
115
+
116
+ # die <message> [exit_code]
117
+ # Exactly one line on stderr. Never a stack trace, never a wall of text.
118
+ #
119
+ # WHY THIS IS MORE THAN `exit`
120
+ #
121
+ # A plain `exit` inside $( ) leaves only the subshell. `set -e` does not reliably
122
+ # carry that failure out to the caller — measured, not assumed:
123
+ #
124
+ # inner() { b="$(false)" || die "failed"; }
125
+ # outer() { r="$(inner)"; }
126
+ # x="$(outer)" # prints "failed", then carries on with x='' and exit 0
127
+ #
128
+ # That turns "the device is unreachable" into "OK, verified", which is the exact
129
+ # class of lie this project exists to prevent. So a die from any depth records
130
+ # its exit code and signals the top-level shell, which then exits with it.
131
+ # The signal is sent unconditionally rather than only from subshells. macOS
132
+ # ships bash 3.2, which has no BASHPID, so a script cannot cheaply tell whether
133
+ # it is currently inside a subshell — and guarding on `$$` is worse than useless
134
+ # there, because `$$` stays the parent's pid inside one. Signalling from the top
135
+ # level too is harmless: the trap exits with the same code the plain exit would.
136
+ DT_TOP=$$
137
+
138
+ # --- one EXIT trap, and a list -------------------------------------------------
139
+ #
140
+ # A SECOND `trap … EXIT` REPLACES THE FIRST. IT DOES NOT ADD TO IT.
141
+ #
142
+ # This file used to set one here for the return-code file and another, three
143
+ # hundred lines below, for the resolver's state file. Only the second ever ran,
144
+ # so every invocation of every script left a file behind — 401 of them in
145
+ # TMPDIR on this machine when it was noticed, from one day's work. Nothing
146
+ # broke, which is why it lasted: a leak that only fills a directory produces no
147
+ # symptom until something else does.
148
+ #
149
+ # So there is one trap, and things register with it. Anything holding a path
150
+ # that must not outlive the process — a temp file, or an expanded
151
+ # step list, which carries substituted secrets and would otherwise survive a run
152
+ # that died before its own cleanup — calls dt_cleanup_add.
153
+ DT_CLEANUP=""
154
+ dt_cleanup_add() { DT_CLEANUP="$DT_CLEANUP$1"$'\n'; }
155
+
156
+ # Some things that need releasing are not files. The studio leaves a listener
157
+ # holding a port, and a script that installed its own `trap … EXIT` to take it
158
+ # down would REPLACE the one above rather than run alongside it — which is the
159
+ # bug that leaked a temp file per run until it was found. So hooks register here
160
+ # and the single trap runs them.
161
+ DT_CLEANUP_HOOKS=""
162
+ dt_cleanup_add_hook() { DT_CLEANUP_HOOKS="$DT_CLEANUP_HOOKS$1"$'\n'; }
163
+
164
+ # EVERY STEP HERE ENDS IN `|| true`, AND `rm` TAKES -r.
165
+ #
166
+ # Both halves of that are load-bearing, and both were found the same way. This
167
+ # file runs under `set -e`, and `[ -n "$p" ] && rm -f "$p"` is an AND-list whose
168
+ # last command `set -e` does not exempt. So a single failing removal aborted the
169
+ # whole function before `return 0` — which meant two things at once, and the
170
+ # quieter one is the worse one:
171
+ #
172
+ # * the shell exited with the status of the failed rm, so a script that had
173
+ # succeeded reported failure. A passing self-test exited 1.
174
+ # * every path registered AFTER the failing one was never removed. That is
175
+ # precisely the leak this list was introduced to stop.
176
+ #
177
+ # What failed was `rm -f` on a directory, which returns non-zero on macOS. The
178
+ # registered paths are all created by this tool — a temp file, a temp
179
+ # directory — so -r is what "remove it" means here.
180
+ dt_cleanup() {
181
+ local p h
182
+ while IFS= read -r h; do
183
+ [ -z "$h" ] && continue
184
+ "$h" 2>/dev/null || true
185
+ done <<< "$DT_CLEANUP_HOOKS"
186
+ while IFS= read -r p; do
187
+ [ -z "$p" ] && continue
188
+ rm -rf "$p" 2>/dev/null || true
189
+ done <<< "$DT_CLEANUP"
190
+ return 0
191
+ }
192
+ trap dt_cleanup EXIT
193
+ # Ctrl-C does not run an EXIT trap in every shell that matters here, and the
194
+ # studio's listener must go either way.
195
+ trap 'dt_cleanup; exit 130' INT
196
+ trap 'dt_cleanup; exit 143' TERM
197
+
198
+ DT_RCFILE="${TMPDIR:-/tmp}/devicetools.rc.$$"
199
+ dt_cleanup_add "$DT_RCFILE"
200
+ trap 'exit "$(cat "$DT_RCFILE" 2>/dev/null || echo 1)"' USR1
201
+
202
+ die() {
203
+ local rc="${2:-1}"
204
+ printf '%s\n' "$1" >&2
205
+ printf '%s' "$rc" > "$DT_RCFILE" 2>/dev/null || true
206
+ kill -USR1 "$DT_TOP" 2>/dev/null || true
207
+ exit "$rc"
208
+ }
209
+
210
+ need_cmd() {
211
+ command -v "$1" >/dev/null 2>&1 || die "missing required command: $1 — see README.md setup" 2
212
+ }
213
+
214
+ # Absolute path with leading ~ expanded. Relative paths resolve against DT_ROOT.
215
+ expand_path() {
216
+ local p="$1"
217
+ case "$p" in
218
+ "~") p="$HOME" ;;
219
+ "~/"*) p="$HOME/${p#\~/}" ;;
220
+ /*) ;;
221
+ *) p="$DT_ROOT/$p" ;;
222
+ esac
223
+ printf '%s' "$p"
224
+ }
225
+
226
+ # --- colour -------------------------------------------------------------------
227
+ #
228
+ # THE TERMINAL'S PALETTE IS THE USER'S, NOT OURS.
229
+ #
230
+ # The HTML report names hex values because it is read off this machine — in a
231
+ # ticket, in a mail, on paper — and has to look the same wherever it lands. A
232
+ # terminal is the opposite: the person reading it already chose a colour scheme,
233
+ # their background may be white or black, and a hex tuned for one is unreadable
234
+ # on the other. So the CLI paints with ANSI's *semantic slots* — red, green,
235
+ # yellow, dim — and lets the terminal decide what those look like. Same design
236
+ # language, opposite mechanism, for the same reason: the reader's context wins.
237
+ #
238
+ # WHAT MUST NOT CHANGE, AND HOW THAT IS GUARANTEED
239
+ #
240
+ # stdout is a contract. It is parsed by CI, by agents, and by this project's own
241
+ # scripts, and a colour code that a parser has to strip is exactly the cost this
242
+ # project refuses everywhere else. So:
243
+ #
244
+ # - the text never changes; only SGR codes are wrapped around tokens that were
245
+ # already there. Every status is still a word, which is the rule the visual
246
+ # language is built on: word first, then glyph, then colour.
247
+ # - colour is off unless stdout is a terminal. Piped, redirected, or run by
248
+ # another script, the bytes are identical to what they were before this
249
+ # existed. That is the pipe-safe proof the design document asked for before
250
+ # any of this was allowed to be written.
251
+ # - NO_COLOR is honoured, and DEVICETOOLS_COLOR=never|auto|always overrides.
252
+ #
253
+ # The decision is made HERE, once, while lib.sh is being sourced — which is the
254
+ # only moment file descriptor 1 is still the script's own stdout. Asking `[ -t 1 ]`
255
+ # later from inside a command substitution tests the substitution's pipe and
256
+ # answers "not a terminal" every time, which is how this kind of check silently
257
+ # never fires.
258
+ C_ON=""
259
+ case "${DEVICETOOLS_COLOR:-auto}" in
260
+ always) C_ON=1 ;;
261
+ never) C_ON="" ;;
262
+ *)
263
+ if [ -z "${NO_COLOR+x}" ] && [ -t 1 ]; then
264
+ case "${TERM:-}" in ""|dumb) ;; *) C_ON=1 ;; esac
265
+ fi ;;
266
+ esac
267
+
268
+ if [ -n "$C_ON" ]; then
269
+ C_OFF=$'\033[0m'; C_DIM=$'\033[2m'
270
+ C_PASS=$'\033[32m'; C_FAIL=$'\033[31m'; C_NOTE=$'\033[33m'; C_SKIP=$'\033[2m'
271
+ else
272
+ C_OFF=""; C_DIM=""; C_PASS=""; C_FAIL=""; C_NOTE=""; C_SKIP=""
273
+ fi
274
+
275
+ # paint <line> — the same line, with the record type dimmed and the status word
276
+ # tinted. Returns the input untouched when colour is off.
277
+ #
278
+ # The record type is dimmed rather than coloured because it is scaffolding: RUN,
279
+ # STEP and RESULT are how the stream is parsed, not what it says. Pushing them
280
+ # back is what lets the eye land on the status and the selector, which is what a
281
+ # person is actually scanning for.
282
+ paint() {
283
+ [ -n "$C_ON" ] || { printf '%s' "$1"; return 0; }
284
+ local line="$1" tag rest col w
285
+ case "$line" in
286
+ [A-Z]*) ;;
287
+ *) printf '%s' "$line"; return 0 ;;
288
+ esac
289
+ tag="${line%%[[:space:]]*}"
290
+ rest="${line#"$tag"}"
291
+
292
+ # ONLY THE FIRST TOKEN AFTER THE RECORD TYPE CAN BE A STATUS.
293
+ #
294
+ # Searching the whole line for "OK" paints it inside a test name — `Dialog OK
295
+ # button works` — and inside a selector, `tap text:OK`. Both are words the
296
+ # product is quoting, not words it is asserting, and tinting them would make
297
+ # the line claim something it does not.
298
+ local lead first
299
+ lead="${rest%%[! ]*}"
300
+ first="${rest#"$lead"}"
301
+ first="${first%%[[:space:]]*}"
302
+ case "$first" in
303
+ PASS|OK) col="$C_PASS" ;;
304
+ FAIL) col="$C_FAIL" ;;
305
+ SKIP) col="$C_SKIP" ;;
306
+ RETRY|WARN) col="$C_NOTE" ;;
307
+ *) col="" ;;
308
+ esac
309
+ [ -n "$col" ] && rest="${lead}${col}${first}${C_OFF}${rest#"$lead$first"}"
310
+
311
+ # Drift is the state this product invented, and it is the one word in a green
312
+ # line that must not read as ordinary. It is marked wherever it appears.
313
+ case "$rest" in
314
+ *drifted*) rest="${rest/drifted/${C_NOTE}drifted${C_OFF}}" ;;
315
+ esac
316
+
317
+ printf '%s%s%s%s' "$C_DIM" "$tag" "$C_OFF" "$rest"
318
+ }
319
+
320
+ # WHERE THE CONFIG IS, AND WHY THERE IS A FOURTH PLACE NOW.
321
+ #
322
+ # Run from a git checkout, the config sits beside it and that is the end of it.
323
+ # Run through `npx`, the package is in a cache directory that npm may delete
324
+ # between invocations — writing a config there means filling in a UDID, a team
325
+ # and a bundle id, and losing them at the next release. So a per-user location
326
+ # is the last resort, and it is the one npx actually uses.
327
+ #
328
+ # In order, most specific first:
329
+ # 1. DEVICETOOLS_CONFIG named outright
330
+ # 2. $DEVICETOOLS_PROJECT/config.json the project named for this session
331
+ # 3. $DT_ROOT/config.json the project found by walking up, or the checkout
332
+ # 4. ~/.config/devicetools/config.json the per-user one (XDG_CONFIG_HOME wins)
333
+ #
334
+ # 3 is skipped when it does not exist, so that a checkout with no config still
335
+ # reaches 4 rather than reporting the file it happens to be standing next to.
336
+ dt_user_config() { printf '%s/devicetools/config.json' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
337
+
338
+ if [ -n "${DEVICETOOLS_CONFIG:-}" ]; then DT_CONFIG="$DEVICETOOLS_CONFIG"
339
+ elif [ -f "$DT_ROOT/config.json" ]; then DT_CONFIG="$DT_ROOT/config.json"
340
+ elif [ -f "$(dt_user_config)" ]; then DT_CONFIG="$(dt_user_config)"
341
+ else DT_CONFIG="$DT_ROOT/config.json"
342
+ fi
343
+
344
+ load_config() {
345
+ need_cmd jq
346
+ [ -f "$DT_CONFIG" ] || die "no config — looked in $DT_ROOT/config.json and $(dt_user_config). Make one with $(as_cmd init '<your app repository>'), or copy $DT_HOME/config.example.json to $(dt_user_config) and fill it in" 2
347
+ jq -e . "$DT_CONFIG" >/dev/null 2>&1 || die "config is not valid JSON: $DT_CONFIG" 2
348
+ }
349
+
350
+ # cfg <jq-path> [default]
351
+ # Prints the value, or the default when absent/null/empty. Fails when a value is
352
+ # required and missing, rather than silently substituting an empty string.
353
+ cfg() {
354
+ local path="$1" default="${2-}" v
355
+ v="$(jq -r "$path // empty" "$DT_CONFIG" 2>/dev/null || true)"
356
+ if [ -z "$v" ]; then
357
+ if [ $# -ge 2 ]; then
358
+ printf '%s' "$default"
359
+ return 0
360
+ fi
361
+ die "config key missing: $path (in $DT_CONFIG)" 2
362
+ fi
363
+ printf '%s' "$v"
364
+ }
365
+
366
+ # as_cmd <verb> [args] — how to spell a verb back to whoever is reading.
367
+ #
368
+ # bin/devicetools exports DT_CLI before exec'ing a verb and the MCP server
369
+ # exports DT_MCP, so the same remedy reads `devicetools doctor --recover` on the
370
+ # CLI, `doctor --recover` to a model that only knows tool names, and
371
+ # `scripts/doctor.sh --recover` to whoever ran the script. A model told to run
372
+ # `scripts/snapshot.sh` has been handed a path it cannot call.
373
+ as_cmd() {
374
+ local verb="$1"; shift
375
+ if [ -n "${DT_MCP:-}" ]; then printf '%s' "$verb"
376
+ elif [ -n "${DT_CLI:-}" ]; then printf 'devicetools %s' "$verb"
377
+ else printf 'scripts/%s.sh' "$verb"; fi
378
+ [ $# -gt 0 ] && printf ' %s' "$*"
379
+ return 0
380
+ }
381
+
382
+ # as_flag <name> <value> — how to spell a flag back to whoever is reading.
383
+ #
384
+ # The sibling of as_cmd, and needed for the same reason: `--project /x` is not
385
+ # something a model can send. Over MCP a flag is a key in the arguments object.
386
+ as_flag() {
387
+ if [ -n "${DT_MCP:-}" ]; then printf '%s=%s' "$1" "${2-}"
388
+ else printf -- '--%s %s' "$1" "${2-}"; fi
389
+ }
390
+
391
+ platform() { cfg '.platform' 'ios'; }
392
+
393
+ # target_kind — "auto", "device" or "simulator", as configured.
394
+ #
395
+ # The split is not iOS-specific: Android has real devices and emulators too, and
396
+ # an adapter that resolves "auto" for its own platform keeps the distinction in
397
+ # one vocabulary instead of two.
398
+ target_kind() { cfg '.device.kind' 'auto'; }
399
+
400
+ adapter_dir() {
401
+ local p d
402
+ p="$(platform)"
403
+ d="$DT_HOME/scripts/$p"
404
+ [ -d "$d" ] || die "no adapter for platform '$p' — expected $d" 2
405
+ printf '%s' "$d"
406
+ }
407
+
408
+ # flows_dir — where saved flows live.
409
+ #
410
+ # IN THE PROJECT, NOT IN THE TOOL'S CONFIG. A flow is knowledge about an
411
+ # application — that logging in means these six steps, on these screens — so it
412
+ # belongs beside the application, in its repository, reviewed like the code it
413
+ # describes. Putting it in config.json would make it per-machine and invisible
414
+ # to everyone else on the team, which is the opposite of the point.
415
+ #
416
+ # Not created here: reading a directory that does not exist is a fine answer,
417
+ # and only `flow save` has any business making one.
418
+ flows_dir() { expand_path "$(cfg '.paths.flows_dir' 'flows')"; }
419
+
420
+ state_dir() {
421
+ local d
422
+ d="$(expand_path "$(cfg '.paths.state_dir' '.state')")"
423
+ mkdir -p "$d"
424
+ printf '%s' "$d"
425
+ }
426
+
427
+ # --- uids ----------------------------------------------------------------------
428
+ #
429
+ # `snapshot` numbers what it shows and writes the numbering here; `tap 7` reads
430
+ # it back. The store is on disk rather than in the environment because every
431
+ # verb is a separate process started by whoever is driving — an exported
432
+ # variable would have to be threaded through every call site.
433
+ #
434
+ # There is exactly one store. A uid means "the seventh thing on the screen I was
435
+ # last shown", and keeping two of those around is how a tap lands on the wrong
436
+ # button.
437
+ uid_store() { printf '%s' "${DEVICETOOLS_UID_STORE:-$(state_dir)/uids.tsv}"; }
438
+
439
+ # --- the session journal -------------------------------------------------------
440
+ #
441
+ # Every action that lands is appended here. NOT a test case: there are no
442
+ # assertions in it, nothing runs it in CI, and nothing scores anything. It is
443
+ # the path back to where you were, which is what installing a new build takes
444
+ # away — the app returns to its first screen and a long flow is four screens
445
+ # deep.
446
+ #
447
+ # Failing to write it must never fail an action. The tap already happened on the
448
+ # phone; reporting failure afterwards would be a lie with a consequence, because
449
+ # the agent would do it again.
450
+ journal_path() { printf '%s' "${DEVICETOOLS_JOURNAL:-$(state_dir)/session.jsonl}"; }
451
+
452
+ # journal_append <verb> <args> <type> <id> <label> <value> <x> <y> <w> <h> <screen>
453
+ journal_append() {
454
+ local f
455
+ f="$(journal_path)"
456
+ mkdir -p "$(dirname "$f")" 2>/dev/null || return 0
457
+ jq -nc --arg verb "${1-}" --arg args "${2-}" --arg type "${3-}" --arg id "${4-}" \
458
+ --arg label "${5-}" --arg value "${6-}" \
459
+ --arg x "${7:-0}" --arg y "${8:-0}" --arg w "${9:-0}" --arg h "${10:-0}" \
460
+ --arg screen "${11-}" \
461
+ '{verb:$verb, args:$args, type:$type, id:$id, label:$label, value:$value,
462
+ x:($x|tonumber? // 0), y:($y|tonumber? // 0),
463
+ w:($w|tonumber? // 0), h:($h|tonumber? // 0), screen:$screen}' \
464
+ >> "$f" 2>/dev/null || true
465
+ return 0
466
+ }
467
+
468
+ # --- what happened -------------------------------------------------------------
469
+ #
470
+ # A person who taps a button sees the screen move, sees the app stall, sees the
471
+ # dialog appear. An agent that only gets "OK tapped" has to spend another call
472
+ # to learn any of it, and between the two calls it knows nothing.
473
+ #
474
+ # So an action reports three things: what it did, whether the screen moved, and
475
+ # what the app said while it was moving. NOT the new tree — attaching a snapshot
476
+ # to every action would triple the cost of the loop to answer a question that
477
+ # "changed" or "unchanged" already answers, and the agent calls snapshot when it
478
+ # wants the detail.
479
+
480
+ # screen_state — "<hash>\t<name>" for whatever is on screen now, or empty.
481
+ #
482
+ # One fetch for both, because two would be two chances for the screen to move
483
+ # between them and a report that mixed one screen's hash with another's name.
484
+ # TWO DIGESTS, BECAUSE "THE SAME SCREEN" AND "NOTHING HAPPENED" ARE NOT THE
485
+ # SAME CLAIM.
486
+ #
487
+ # The screen hash is over the type and label of every shown element: it answers
488
+ # "am I still on the same screen", and a field filling up is the same screen.
489
+ # The content digest also covers values, so it moves when the screen's contents
490
+ # do.
491
+ #
492
+ # Without the second one, tapping a keypad key on an OTP sheet reported SCREEN
493
+ # unchanged while the field went from "8 8" to "8 8 8" — and the agent that read
494
+ # it went and spent a snapshot proving the tap had in fact landed, which is the
495
+ # round trip this line exists to save. A false "nothing happened" is worse than
496
+ # no line at all.
497
+ #
498
+ # One read, both digests: the rows are fetched once and measured twice.
499
+ screen_state() { state_from_rows "$(snapshot_rows 2>/dev/null || true)"; }
500
+
501
+ # state_from_rows <rows> — the same answer, from rows already in hand.
502
+ #
503
+ # Split out so that an action asked for a snapshot pays for ONE fetch and gets
504
+ # both answers from it. Fetching twice would also be two chances for the screen
505
+ # to move in between, and a report that paired one screen's digest with another
506
+ # screen's tree would be worse than no report.
507
+ state_from_rows() {
508
+ local rows="$1" head content
509
+ [ -n "$rows" ] || return 0
510
+
511
+ head="$(printf '%s\n' "$rows" \
512
+ | awk -v ALL=0 -v GREP="" -v MINHIT=44 -f "$DT_HOME/scripts/snapshot.awk" 2>/dev/null \
513
+ | awk -F'"' '
514
+ NR == 1 {
515
+ name = (NF >= 2 ? $2 : "")
516
+ hash = ""
517
+ n = split($0, w, " ")
518
+ for (i = 1; i <= n; i++) if (w[i] ~ /^#/) hash = substr(w[i], 2)
519
+ printf "%s\t%s", hash, name
520
+ exit
521
+ }')"
522
+ [ -n "$head" ] || return 0
523
+
524
+ # n|depth|parent|type|id|label|value|x|y|w|h|flags
525
+ content="$(printf '%s\n' "$rows" \
526
+ | awk -F'|' '$1 != "WINDOW" && $1 != "" { print $4 "\037" $6 "\037" $7 }' \
527
+ | cksum | awk '{ print $1 }')"
528
+
529
+ printf '%s\t%s' "$head" "$content"
530
+ }
531
+
532
+ # --- rendering a screen, from rows -------------------------------------------
533
+ #
534
+ # snapshot.sh used to own all of this. It now shares it with act_report, because
535
+ # an action that can hand back the new screen is the difference between two
536
+ # calls and one, and the read–act–read cycle was half the round trips in a
537
+ # measured session: twelve calls to log in and open Settings, six of them
538
+ # snapshots taken only to find out what the last action produced.
539
+
540
+ # min_hit — the minimum comfortable hit area, in points, for this platform.
541
+ # Apple says 44, Google says 48, and reporting one platform's number on the
542
+ # other is a warning nobody should act on.
543
+ min_hit() {
544
+ case "$(platform)" in
545
+ android) printf '48' ;;
546
+ *) printf '44' ;;
547
+ esac
548
+ }
549
+
550
+ # render_rows <rows> [all] [grep] — the screen as an agent reads it.
551
+ render_rows() {
552
+ printf '%s\n' "$1" \
553
+ | awk -v ALL="${2:-0}" -v GREP="${3-}" -v MINHIT="$(min_hit)" \
554
+ -f "$DT_HOME/scripts/snapshot.awk"
555
+ }
556
+
557
+ # write_uid_store <rows> [all] [grep] — renumber, so the uids just printed work.
558
+ #
559
+ # Written to a temporary file and moved into place. A half-written store read by
560
+ # the next verb would resolve a uid to whatever happened to be on that line,
561
+ # which is the one failure this whole mechanism exists to prevent.
562
+ write_uid_store() {
563
+ local store tmp
564
+ store="$(uid_store)"
565
+ tmp="$store.new"
566
+ mkdir -p "$(dirname "$store")" 2>/dev/null || true
567
+ printf '%s\n' "$1" \
568
+ | awk -v ALL="${2:-0}" -v GREP="${3-}" -v MINHIT="$(min_hit)" -v MODE=store \
569
+ -f "$DT_HOME/scripts/snapshot.awk" > "$tmp" \
570
+ || { rm -f "$tmp"; return 1; }
571
+ mv "$tmp" "$store" || { rm -f "$tmp"; return 1; }
572
+ return 0
573
+ }
574
+
575
+ DT_ACT_HASH=""
576
+ DT_ACT_NAME=""
577
+ DT_ACT_CONTENT=""
578
+ DT_ACT_AT=""
579
+
580
+ # Whether an action hands back the screen it produced. Off by default: attaching
581
+ # a tree to every action would triple the cost of a loop that mostly does not
582
+ # need it, and "changed" or "unchanged" already answers the common question.
583
+ # The caller asks with --snapshot, and asks when it is about to look anyway.
584
+ DT_ACT_SNAPSHOT=0
585
+ act_snapshot_on() { DT_ACT_SNAPSHOT=1; }
586
+
587
+ # act_before — remember the screen, and the moment, before acting.
588
+ # split_state <state> — sets ST_HASH, ST_NAME, ST_CONTENT from one screen_state
589
+ # line. One splitter, because two copies of "the second tab is the name" is how
590
+ # a third field silently ends up inside the second.
591
+ split_state() {
592
+ local s="$1" rest
593
+ ST_HASH="${s%% *}"
594
+ rest="${s#* }"
595
+ if [ "$rest" = "$s" ]; then ST_NAME=""; ST_CONTENT=""; return 0; fi
596
+ ST_NAME="${rest%% *}"
597
+ ST_CONTENT="${rest#* }"
598
+ [ "$ST_CONTENT" = "$rest" ] && ST_CONTENT=""
599
+ return 0
600
+ }
601
+
602
+ act_before() {
603
+ local s
604
+ s="$(screen_state || true)"
605
+ split_state "$s"
606
+ DT_ACT_HASH="$ST_HASH"
607
+ DT_ACT_NAME="$ST_NAME"
608
+ DT_ACT_CONTENT="$ST_CONTENT"
609
+ DT_ACT_AT="$(date +%s)"
610
+ return 0
611
+ }
612
+
613
+ # act_report <VERB> <what> — the three lines.
614
+ act_report() {
615
+ local verb="$1" what="$2" rows s hash2 name2 content2 lines n tree
616
+ printf '%-6s %s\n' "$verb" "$what"
617
+
618
+ # ONE FETCH. The digests below and the tree at the bottom are two readings of
619
+ # the same rows, not two reads of the same phone.
620
+ rows="$(snapshot_rows 2>/dev/null || true)"
621
+ s="$(state_from_rows "$rows" || true)"
622
+ split_state "$s"
623
+ hash2="$ST_HASH"; name2="$ST_NAME"; content2="$ST_CONTENT"
624
+
625
+ if [ -z "$DT_ACT_HASH" ] || [ -z "$hash2" ]; then
626
+ printf 'SCREEN unknown — the tree could not be read on both sides of this\n'
627
+ elif [ "$hash2" != "$DT_ACT_HASH" ]; then
628
+ printf 'SCREEN changed — %s#%s → %s#%s\n' \
629
+ "${DT_ACT_NAME:+\"$DT_ACT_NAME\" }" "$DT_ACT_HASH" "${name2:+\"$name2\" }" "$hash2"
630
+ elif [ -n "$DT_ACT_CONTENT" ] && [ -n "$content2" ] && [ "$content2" != "$DT_ACT_CONTENT" ]; then
631
+ # Same controls, different contents: a field filling up, a counter moving, a
632
+ # code being entered. Not a new screen, and emphatically not "nothing
633
+ # happened".
634
+ printf 'SCREEN same — %s#%s, contents changed\n' "${name2:+\"$name2\" }" "$hash2"
635
+ else
636
+ printf 'SCREEN unchanged — %s#%s\n' "${name2:+\"$name2\" }" "$hash2"
637
+ fi
638
+
639
+ # No collector, no LOG section. See logs_since in each adapter: an action must
640
+ # not start a background process, and `logs start` is how an agent asks for
641
+ # this.
642
+ lines="$(logs_since "${DT_ACT_AT:-0}" 2>/dev/null || true)"
643
+ if [ -n "$lines" ]; then
644
+ n="$(printf '%s\n' "$lines" | wc -l | tr -d ' ')"
645
+ printf 'LOG %s line%s\n' "$n" "$( [ "$n" = 1 ] || printf s )"
646
+ printf '%s\n' "$lines" | sed 's/^/ /'
647
+ fi
648
+
649
+ # THE NEW SCREEN, WHEN IT WAS ASKED FOR — AND THE UIDS RENUMBERED TO MATCH.
650
+ #
651
+ # Printing the tree without rewriting the store would be worse than not
652
+ # printing it: the numbers would be the previous screen's, and every one of
653
+ # them would resolve to something that is no longer there. So the store is
654
+ # rewritten from these rows, and `tap 7` means the seventh line below.
655
+ #
656
+ # Neither failure here may fail the action. The tap already happened on the
657
+ # phone; reporting failure afterwards is a lie the agent would act on by
658
+ # doing it again.
659
+ if [ "$DT_ACT_SNAPSHOT" -eq 1 ]; then emit_tree "$rows"; fi
660
+ return 0
661
+ }
662
+
663
+ # emit_tree <rows> — print the screen and renumber the store, or say why not.
664
+ emit_tree() {
665
+ local rows="${1-}" tree
666
+ if [ -z "$rows" ]; then
667
+ printf 'TREE unavailable — the screen could not be read after the action\n'
668
+ return 0
669
+ fi
670
+ if ! tree="$(render_rows "$rows" 2>/dev/null)" || [ -z "$tree" ]; then
671
+ printf 'TREE unavailable — nothing on the screen survived the filter\n'
672
+ return 0
673
+ fi
674
+ write_uid_store "$rows" \
675
+ || printf 'TREE the uid store could not be written — run %s before acting on these numbers\n' "$(as_cmd snapshot)"
676
+ printf '\n%s\n' "$tree"
677
+ return 0
678
+ }
679
+
680
+ # act_snapshot_now — the same, for a verb that does not report through
681
+ # act_report: app launch, key hide, open. Costs one fetch and only when asked.
682
+ act_snapshot_now() {
683
+ [ "$DT_ACT_SNAPSHOT" -eq 1 ] || return 0
684
+ emit_tree "$(snapshot_rows 2>/dev/null || true)"
685
+ return 0
686
+ }
687
+
688
+ # run_dir — where screenshots and trees for the current run are written.
689
+ # A caller may export DEVICETOOLS_RUN_ID; without it everything lands in one "adhoc"
690
+ # directory so a single script still works on its own.
691
+ run_dir() {
692
+ local base d
693
+ base="$(expand_path "$(cfg '.paths.runs_dir' '.runs')")"
694
+ d="$base/${DEVICETOOLS_RUN_ID:-adhoc}"
695
+ mkdir -p "$d"
696
+ printf '%s' "$d"
697
+ }
698
+
699
+ # port_open <port> — true when something is listening on 127.0.0.1:<port>
700
+ port_open() {
701
+ nc -z 127.0.0.1 "$1" >/dev/null 2>&1
702
+ }
703
+
704
+ # take_lines <n> — the first n lines of stdin, WITHOUT LEAVING EARLY.
705
+ #
706
+ # `head` closes the pipe the moment it has enough, and whatever is upstream —
707
+ # usually this shell's own printf — is still writing. Bash reports that as
708
+ # `printf: write error: Broken pipe` on stderr, which looks like a fault and is
709
+ # not one. It only shows when the input is bigger than the pipe buffer, so it
710
+ # hides in every test with a small fixture and appears the first time somebody
711
+ # points this at a real screen.
712
+ #
713
+ # Reading to the end costs nothing here: the inputs are trees and match lists
714
+ # already sitting in memory.
715
+ take_lines() { awk -v n="$1" 'NR <= n'; }
716
+
717
+ # first_line <string> — no pipe at all, which is the cheapest way to be sure.
718
+ first_line() { printf '%s' "${1%%$'\n'*}"; }
719
+
720
+ # strip_log_block — drop `LOG n lines` and the lines it introduces.
721
+ #
722
+ # act_report writes the log block as a LOG header followed by lines indented by
723
+ # seven spaces, so both halves are recognisable without knowing what is in them.
724
+ # Everything else — the action's own line, SCREEN, and a --snapshot tree, which
725
+ # is separated by a blank line and not indented — passes through untouched.
726
+ strip_log_block() {
727
+ awk '
728
+ /^LOG +[0-9]/ { inlog = 1; next }
729
+ inlog && /^ / { next }
730
+ { inlog = 0; print }'
731
+ }
732
+
733
+ # join_lines <separator> — collapse stdin to one line. Error messages are a
734
+ # single line by contract, and `paste -sd` only honours one delimiter character.
735
+ join_lines() {
736
+ awk -v sep="$1" 'NR > 1 { printf "%s", sep } { printf "%s", $0 } END { print "" }'
737
+ }
738
+
739
+ # --- selectors ---------------------------------------------------------------
740
+ #
741
+ # The selector grammar is platform-neutral. Each adapter maps it onto whatever
742
+ # its driver actually calls these things:
743
+ #
744
+ # id:<s> iOS accessibilityIdentifier Android resource-id
745
+ # label:<s> iOS accessibility label Android content-desc
746
+ # text:<s> iOS value Android text
747
+ # kind:<s> iOS XCUIElement type Android class name — the word
748
+ # snapshot prints first on the line, for a control that carries
749
+ # no identifier and no label at all
750
+ # xy:<x>,<y> raw point, in the coordinate space tree.sh prints
751
+ #
752
+ # Android genuinely distinguishes content-desc from text, so the grammar has to
753
+ # carry both; mapping iOS `value` onto text: follows from that, and is what
754
+ # makes fields with neither an identifier nor a label addressable at all.
755
+ #
756
+ # And two spellings that name no attribute at all:
757
+ #
758
+ # <n> a uid — the nth thing the last snapshot showed
759
+ # <anything> a plain string, matched against id, label and value in turn
760
+ #
761
+ # uid_resolve <n> — the point to act on, or a refusal.
762
+ #
763
+ # A uid is a promise about a specific element on a specific screen. Honouring it
764
+ # means proving the element is still there: same type, same identifier, same
765
+ # label, same value, same rectangle. Anything less is a guess, and a guess that
766
+ # lands a tap in somebody's live session is the failure this whole tool is
767
+ # arranged to avoid.
768
+ #
769
+ # Note what is NOT here: no scoring, no runner-up, no threshold. When the screen
770
+ # has moved on, the answer is "read it again", which costs one cheap call.
771
+ uid_resolve() {
772
+ local want="$1" store row rtype rid rlabel rvalue rx ry rw rh now hit
773
+ store="$(uid_store)"
774
+ [ -f "$store" ] \
775
+ || die "no uid is in play — run $(as_cmd snapshot) first, then use the numbers it prints" 4
776
+
777
+ row="$(awk -F'|' -v u="$want" '$1 + 0 == u + 0 { print; exit }' "$store")"
778
+ [ -n "$row" ] \
779
+ || die "uid $want was never assigned — the last snapshot printed $(wc -l < "$store" | tr -d ' ') element(s)" 4
780
+
781
+ # '|' and not a tab: see the note in snapshot.awk's store mode. An empty
782
+ # identifier next to an empty value would collapse under a whitespace IFS and
783
+ # every field after them would shift left.
784
+ IFS='|' read -r _ _ rtype rid rlabel rvalue rx ry rw rh <<< "$row"
785
+
786
+ # Re-read the screen and require the same element to still be on it. Matched
787
+ # on the attributes, not on the serial: a serial is a position in a tree that
788
+ # the driver rebuilds on every fetch.
789
+ #
790
+ # THE STRINGS MUST BE EXACT; THE RECTANGLE GETS TWO POINTS. Both drivers
791
+ # report rectangles as floats and this pipeline floors them, so an element
792
+ # that has not moved can still be reported one point away on a later fetch.
793
+ # Demanding an exact rectangle would make every uid stale at random, and the
794
+ # agent would learn to re-snapshot before every action — which is the cost
795
+ # this mechanism exists to avoid. Two points is the arithmetic; a control that
796
+ # has actually moved has moved by more than that. The same number, for the
797
+ # same reason, as the tolerance in snapshot.awk.
798
+ now="$(snapshot_rows)" || die "could not re-read the screen — run $(as_cmd doctor)" 3
799
+ hit="$(printf '%s\n' "$now" | awk -F'|' \
800
+ -v t="$rtype" -v i="$rid" -v l="$rlabel" -v v="$rvalue" \
801
+ -v x="$rx" -v y="$ry" -v w="$rw" -v h="$rh" '
802
+ function near(a, b) { d = a - b; return (d < 0 ? -d : d) <= 2 }
803
+ $1 == "WINDOW" { next }
804
+ $4 == t && $5 == i && $6 == l && $7 == v &&
805
+ near($8 + 0, x + 0) && near($9 + 0, y + 0) &&
806
+ near($10 + 0, w + 0) && near($11 + 0, h + 0) { c++ }
807
+ END { print c + 0 }')"
808
+
809
+ if [ "$hit" -eq 0 ]; then
810
+ die "uid $want is stale — '$rlabel' is no longer a $rtype at ${rx},${ry} ${rw}x${rh}. Run $(as_cmd snapshot) again" 4
811
+ fi
812
+ if [ "$hit" -gt 1 ]; then
813
+ die "uid $want now matches $hit identical elements — the screen changed under it. Run $(as_cmd snapshot) again" 4
814
+ fi
815
+
816
+ printf '%s,%s' "$(( rx + rw / 2 ))" "$(( ry + rh / 2 ))"
817
+ }
818
+
819
+ # parse_selector <selector> — sets SEL_KIND, SEL_VALUE and SEL_DESC.
820
+ parse_selector() {
821
+ [ -n "$1" ] || die "empty selector" 1
822
+ case "$1" in
823
+ id:*) SEL_KIND=id; SEL_VALUE="${1#id:}" ;;
824
+ label:*) SEL_KIND=label; SEL_VALUE="${1#label:}" ;;
825
+ text:*) SEL_KIND=text; SEL_VALUE="${1#text:}" ;;
826
+ # kind:<Type> — THE ELEMENT'S CLASS, WHEN IT HAS NOTHING ELSE.
827
+ #
828
+ # Added because snapshot was warning "no identifier" about a
829
+ # SecureTextField with no label either, and then leaving nothing but a raw
830
+ # coordinate to reach it with — the one spelling this tool tells people to
831
+ # avoid, and rightly: after the keyboard went down, the sheet holding that
832
+ # field moved from y=507 to y=774 and the recorded tap missed entirely.
833
+ #
834
+ # A type is not unique on a screen, so this is usually written with --index,
835
+ # and it refuses exactly as every other selector does when it is not.
836
+ kind:*) SEL_KIND=kind; SEL_VALUE="${1#kind:}" ;;
837
+ xy:*) SEL_KIND=xy; SEL_VALUE="${1#xy:}" ;;
838
+ any:*) SEL_KIND=any; SEL_VALUE="${1#any:}" ;;
839
+ uid:*)
840
+ SEL_KIND=xy; SEL_VALUE="$(uid_resolve "${1#uid:}")"
841
+ SEL_DESC="uid ${1#uid:}"
842
+ return 0 ;;
843
+ # Anything with a non-digit in it is a plain string. This arm exists only to
844
+ # stop such a string reaching the uid arm below it; `case` takes the first
845
+ # match, so order is the whole mechanism here.
846
+ *[!0-9]*) SEL_KIND=any; SEL_VALUE="$1" ;;
847
+ # All digits: a uid. Nothing else in the grammar is a bare number, and
848
+ # `tap 7` is what anybody reaches for after reading a numbered screen.
849
+ *)
850
+ SEL_KIND=xy; SEL_VALUE="$(uid_resolve "$1")"
851
+ SEL_DESC="uid $1"
852
+ return 0 ;;
853
+ esac
854
+ [ -n "$SEL_VALUE" ] || die "empty selector value in '$1'" 1
855
+ # How the selector is named in a message. Somebody who wrote `tap Đóng` never
856
+ # chose an attribute, so telling them "no element with any exactly 'Đóng'" is
857
+ # answering a question they did not ask in a vocabulary they did not use.
858
+ if [ "$SEL_KIND" = any ]; then SEL_DESC="'$SEL_VALUE'"
859
+ else SEL_DESC="$SEL_KIND '$SEL_VALUE'"; fi
860
+ }
861
+
862
+ DT_RESOLVE_STATE="${TMPDIR:-/tmp}/devicetools.resolve.$$"
863
+ dt_cleanup_add "$DT_RESOLVE_STATE"
864
+
865
+ # resolve_state — load RESOLVE_HOW and RESOLVE_NOTE from the last call.
866
+ resolve_state() {
867
+ RESOLVE_HOW=""; RESOLVE_NOTE=""
868
+ [ -f "$DT_RESOLVE_STATE" ] || return 0
869
+ IFS=$'\t' read -r RESOLVE_HOW RESOLVE_NOTE < "$DT_RESOLVE_STATE" || true
870
+ return 0
871
+ }
872
+
873
+ resolve_note_out() {
874
+ printf '%s\t%s' "$1" "${2-}" > "$DT_RESOLVE_STATE" 2>/dev/null || true
875
+ }
876
+
877
+ # match_or_holding <source> <kind> <value> — what the screen says right now.
878
+ #
879
+ # THE CAPTION IS NOT ALWAYS INSIDE THE CONTROL, AND NOBODY SHOULD HAVE TO CARE.
880
+ #
881
+ # Applications put a caption beside the control it names as often as within it,
882
+ # and XCUITest then reports the caption as invisible because the control takes
883
+ # its hit test. A person writing the test sees words on a card and writes the
884
+ # words; whether this app nests them one way and that app another is not their
885
+ # subject, and making it their subject was the mistake here — this began as its
886
+ # own `holding:` selector, which is an implementation detail promoted to grammar.
887
+ #
888
+ # So when the written text matches nothing directly, the smallest control whose
889
+ # bounds contain that text is used. One rule, reported on the line, and still
890
+ # refused when two controls hold the same words: this widens what resolves,
891
+ # never what is guessed at.
892
+ #
893
+ # This is the whole resolver. There was once a second layer above it that
894
+ # scored a recorded fingerprint against the screen when the written selector
895
+ # stopped matching; it was deleted along with the test runner it existed for.
896
+ # An agent re-reads the screen before every action, so it never holds a selector
897
+ # old enough to need rescuing.
898
+ # collapse_nested — a control and its own caption are one thing, not two.
899
+ #
900
+ # Nearly every real screen carries the same words twice: on the Button, and on
901
+ # the StaticText the Button draws inside itself. Reported as two matches, that
902
+ # forced an --index onto the commonest selector anyone writes, and an index is a
903
+ # position in a list nobody can see. There is no ambiguity to protect here — the
904
+ # outer element occupies the inner one's whole area, so both taps land on the
905
+ # same control.
906
+ #
907
+ # Only a strictly larger container absorbs a match. Two elements that merely
908
+ # overlap, or sit side by side, stay two matches and the caller still refuses.
909
+ collapse_nested() {
910
+ awk -F'|' '
911
+ { X[NR] = $1; Y[NR] = $2; W[NR] = $3; H[NR] = $4; L[NR] = $0 }
912
+ END {
913
+ for (i = 1; i <= NR; i++) {
914
+ drop = 0
915
+ for (j = 1; j <= NR && !drop; j++) {
916
+ if (i == j) continue
917
+ if (X[j] <= X[i] && Y[j] <= Y[i] \
918
+ && X[j] + W[j] >= X[i] + W[i] && Y[j] + H[j] >= Y[i] + H[i] \
919
+ && W[j] * H[j] > W[i] * H[i]) drop = 1
920
+ # Identical bounds: keep the one that came first in the tree, which is
921
+ # the container, since a child is always emitted after its parent.
922
+ else if (X[j] == X[i] && Y[j] == Y[i] && W[j] == W[i] && H[j] == H[i] \
923
+ && j < i) drop = 1
924
+ }
925
+ if (!drop) print L[i]
926
+ }
927
+ }'
928
+ }
929
+
930
+ match_or_holding() {
931
+ local m k
932
+ rm -f "$DT_RESOLVE_STATE" 2>/dev/null || true
933
+
934
+ # A PLAIN STRING IS THE DEFAULT WAY TO NAME A CONTROL.
935
+ #
936
+ # `tap: Đóng` is what somebody testing the app writes, because it is what they
937
+ # can see. `tap: { label: Đóng, index: 0 }` is a data structure, and requiring
938
+ # it makes the person who knows the product least able to describe it.
939
+ #
940
+ # THE FIRST KIND THAT MATCHES WINS — not the union of all three. An id that
941
+ # matches exactly is a better answer than two labels that also do, and merging
942
+ # them would manufacture an ambiguity the screen does not have and then refuse
943
+ # over it. Ambiguity WITHIN one kind is still real and is still refused.
944
+ if [ "$2" = any ]; then
945
+ for k in id label text; do
946
+ m="$(match_or_holding "$1" "$k" "$3" || true)"
947
+ [ -n "$m" ] || continue
948
+ printf '%s\n' "$m"
949
+ return 0
950
+ done
951
+ return 1
952
+ fi
953
+ m="$(match_elements "$1" "$2" "$3" | collapse_nested || true)"
954
+ if [ -n "$m" ]; then
955
+ resolve_note_out exact
956
+ printf '%s\n' "$m"
957
+ return 0
958
+ fi
959
+ [ "$2" = text ] || return 1
960
+ command -v holding_elements >/dev/null 2>&1 || return 1
961
+ m="$(holding_elements "$1" "$3" || true)"
962
+ [ -n "$m" ] || return 1
963
+ resolve_note_out holding "the text is inside the control rather than on it"
964
+ printf '%s\n' "$m"
965
+ }
966
+ # parse_xy <value> — sets XY_X and XY_Y from an "x,y" string.
967
+ parse_xy() {
968
+ case "$1" in
969
+ *,*) ;;
970
+ *) die "xy selector needs two numbers: xy:<x>,<y> — got 'xy:$1'" 1 ;;
971
+ esac
972
+ XY_X="${1%%,*}"
973
+ XY_Y="${1##*,}"
974
+ case "$XY_X$XY_Y" in
975
+ (*[!0-9]*|"") die "xy coordinates must be non-negative integers — got 'xy:$1'" 1 ;;
976
+ esac
977
+ }
978
+
979
+ # holding_absence <source> <text> — the sentence explaining why a holding:
980
+ # selector matched nothing, or nothing at all if the text is simply not there.
981
+ #
982
+ # Two failures wear the same face otherwise: a screen that has not arrived, and
983
+ # a screen that has arrived where the caption sits outside the control's bounds.
984
+ # The first is fixed by waiting, the second by writing a different selector, and
985
+ # sending someone to the wrong one costs a whole debugging session.
986
+ holding_absence() {
987
+ local rows
988
+ command -v text_anywhere >/dev/null 2>&1 || return 0
989
+ rows="$(text_anywhere "$1" "$2" 2>/dev/null || true)"
990
+ [ -n "$rows" ] || return 0
991
+ # `| head -1 |` in the middle of this closed the pipe under the shell's own
992
+ # printf and put `printf: write error: Broken pipe` on stderr. awk takes the
993
+ # first record itself, so there is no early-exiting reader to write into.
994
+ printf 'the text is on screen at %s, but no control a user can act on contains it — the caption may sit outside the control bounds, so name the control another way' \
995
+ "$(printf '%s\n' "$rows" | awk -F'|' 'NR == 1 { printf "%s,%s,%s,%s", $1, $2, $3, $4; exit }')"
996
+ }
997
+
998
+ # invisible_note <source> <kind> <value> — the sentence for a selector that names
999
+ # something really in the tree, which the driver reports as not visible.
1000
+ #
1001
+ # THE MESSAGE THIS REPLACES WAS FALSE, AND FALSE IN THE DIRECTION THAT COSTS
1002
+ # MOST. `tap label:Cài đặt` answered "nothing on screen contains it" while
1003
+ # `snapshot --grep Cài đặt` was listing it two lines above — so the caller is
1004
+ # told the screen is wrong when the screen is right and the control is merely
1005
+ # underneath something. Reported from a Control Wheel whose four buttons share
1006
+ # one rectangle exactly: three of the four are covered, and the driver marks
1007
+ # them invisible, which is true and is not "not there".
1008
+ #
1009
+ # The count of elements sharing that exact rectangle is the evidence for the
1010
+ # diagnosis, so it is printed rather than asserted.
1011
+ invisible_note() {
1012
+ local row why x y w h type same vis
1013
+ command -v named_anywhere >/dev/null 2>&1 || return 0
1014
+ row="$(named_anywhere "$1" "$2" "$3" 2>/dev/null | awk 'NR == 1 { print; exit }')"
1015
+ [ -n "$row" ] || return 0
1016
+ IFS='|' read -r x y w h type same vis <<< "$row"
1017
+
1018
+ # Two different faults wear the same absence, and the remedy for one is not
1019
+ # the remedy for the other.
1020
+ if [ "${w:-0}" -le 0 ] || [ "${h:-0}" -le 0 ]; then
1021
+ why="it measures ${w}x${h}, so there is no point to tap"
1022
+ elif [ "${same:-1}" -gt 1 ]; then
1023
+ why="the driver reports it as not visible, and $same elements share that rectangle exactly — it is underneath one of them, so clear what covers it or scroll it into view"
1024
+ elif [ "${vis:-0}" = 0 ]; then
1025
+ why="the driver reports it as not visible — clear whatever covers it, or scroll it into view"
1026
+ else
1027
+ return 0
1028
+ fi
1029
+ printf 'it is in the tree as a %s at %s,%s %sx%s, but %s. Reading finds it and acting does not, on purpose: a tap only lands on what a user could have touched' \
1030
+ "$type" "$x" "$y" "$w" "$h" "$why"
1031
+ }
1032
+