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,170 @@
1
+ #!/usr/bin/env bash
2
+ # measure.sh — one element, and everything that can be said about where it is.
3
+ #
4
+ # measure.sh <uid>
5
+ #
6
+ # snapshot warns about what it can prove is wrong, and groups those warnings so
7
+ # a list of six identical rows does not report the same fault six times. This is
8
+ # the other half of that trade: when you want to know which sibling something
9
+ # overlaps, or how far it sits from the thing above it, you ask here.
10
+ #
11
+ # [4] Button "Đăng nhập" (24,356) 327x50
12
+ # parent ScrollView (0,88) 375x724 — contained
13
+ # above [3] SecureTextField, gap 24pt
14
+ # below [5] StaticText, gap 34pt
15
+ # text "Đăng nhập" — not truncated
16
+ # overlaps none
17
+ # hit area 327x50 — meets the 44pt minimum
18
+ #
19
+ # The uid comes from the last snapshot and is checked before anything is
20
+ # described. Describing an element that has moved is describing a screen that no
21
+ # longer exists.
22
+ #
23
+ # exit 0 described
24
+ # exit 1 usage error
25
+ # exit 3 the driver is unreachable — run doctor.sh
26
+ # exit 4 no such uid, or it is stale
27
+
28
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
29
+
30
+ case "${1-}" in
31
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
32
+ "") die "measure.sh needs a uid (usage: measure.sh <uid>)" 1 ;;
33
+ *[!0-9]*) die "measure.sh takes a uid, which is a number — got '$1'" 1 ;;
34
+ esac
35
+ [ $# -eq 1 ] || die "too many arguments (usage: measure.sh <uid>)" 1
36
+ WANT="$1"
37
+
38
+ load_config
39
+
40
+ case "$(platform)" in
41
+ android) MINHIT=48 ;;
42
+ *) MINHIT=44 ;;
43
+ esac
44
+
45
+ STORE="$(uid_store)"
46
+ [ -f "$STORE" ] || die "no uid is in play — run snapshot.sh first" 4
47
+
48
+ # uid_resolve is the staleness check, and it is the same one tap uses. Its
49
+ # answer is discarded here; what matters is that it did not die.
50
+ uid_resolve "$WANT" >/dev/null
51
+
52
+ rows="$(snapshot_rows)"
53
+ [ -n "$rows" ] || die "the driver returned an empty tree — run $(as_cmd doctor)" 3
54
+
55
+ printf '%s\n' "$rows" | awk -F'|' -v U="$WANT" -v MINHIT="$MINHIT" -v STORE="$STORE" '
56
+ # The store is the authority on what a uid means. It is read first so that
57
+ # every neighbour this prints can be named by the number the agent already has
58
+ # in front of it, rather than by a description it would have to go and match.
59
+ BEGIN {
60
+ while ((getline line < STORE) > 0) {
61
+ k = split(line, f, "|")
62
+ if (k < 10) continue
63
+ su[++ns] = f[1]; stype[ns] = f[3]; sid[ns] = f[4]; slabel[ns] = f[5]
64
+ sval[ns] = f[6]; sx[ns] = f[7] + 0; sy[ns] = f[8] + 0
65
+ sw[ns] = f[9] + 0; sh[ns] = f[10] + 0
66
+ if (f[1] + 0 == U + 0) {
67
+ TT = f[3]; TI = f[4]; TL = f[5]; TV = f[6]
68
+ TX = f[7] + 0; TY = f[8] + 0; TW = f[9] + 0; TH = f[10] + 0
69
+ }
70
+ }
71
+ close(STORE)
72
+ }
73
+
74
+ $1 == "WINDOW" { WW = $2 + 0; WH = $3 + 0; next }
75
+ $0 == "" { next }
76
+ {
77
+ n++
78
+ parent[n] = $3 + 0; type[n] = $4; id[n] = $5; label[n] = $6; value[n] = $7
79
+ x[n] = $8 + 0; y[n] = $9 + 0; w[n] = $10 + 0; h[n] = $11 + 0
80
+ byserial[$1 + 0] = n
81
+ if ($4 == TT && $5 == TI && $6 == TL && $7 == TV && near($8, TX) && near($9, TY) &&
82
+ near($10, TW) && near($11, TH)) me = n
83
+ }
84
+
85
+ # The same two points of slack uid_resolve allows, for the same reason: the
86
+ # drivers report floats and this pipeline floors them.
87
+ function near(a, b, d) { d = (a + 0) - (b + 0); return (d < 0 ? -d : d) <= 2 }
88
+ function best(i) { return label[i] != "" ? label[i] : (value[i] != "" ? value[i] : id[i]) }
89
+ function span(a1, a2, b1, b2, lo, hi) {
90
+ lo = (a1 > b1) ? a1 : b1; hi = (a2 < b2) ? a2 : b2; return hi - lo
91
+ }
92
+
93
+ # Which uid, if any, the last snapshot gave this element. Elements the display
94
+ # filter hid have none, and are named by type instead.
95
+ function uidat(i, k) {
96
+ for (k = 1; k <= ns; k++)
97
+ if (stype[k] == type[i] && sid[k] == id[i] && slabel[k] == label[i] &&
98
+ sval[k] == value[i] && near(sx[k], x[i]) && near(sy[k], y[i]) &&
99
+ near(sw[k], w[i]) && near(sh[k], h[i])) return su[k]
100
+ return ""
101
+ }
102
+ function name(i, u) {
103
+ u = uidat(i)
104
+ return (u != "" ? sprintf("[%s] %s", u, type[i]) : sprintf("%s", type[i]))
105
+ }
106
+
107
+ END {
108
+ if (!me) {
109
+ print "the element moved between the check and the read — run snapshot.sh again" > "/dev/stderr"
110
+ exit 4
111
+ }
112
+
113
+ lbl = best(me)
114
+ printf "[%s] %s%s (%d,%d) %dx%d\n", U, type[me],
115
+ (lbl != "" ? sprintf(" \"%s\"", lbl) : ""), x[me], y[me], w[me], h[me]
116
+
117
+ p = byserial[parent[me]]
118
+ if (p) {
119
+ inside = (x[me] >= x[p] - 2 && y[me] >= y[p] - 2 &&
120
+ x[me] + w[me] <= x[p] + w[p] + 2 && y[me] + h[me] <= y[p] + h[p] + 2)
121
+ printf " parent %s (%d,%d) %dx%d — %s\n", type[p], x[p], y[p], w[p], h[p],
122
+ (inside ? "contained" : "THIS ELEMENT ESCAPES IT")
123
+ } else {
124
+ print " parent none — this is the root"
125
+ }
126
+
127
+ # Nearest sibling above and below, by edge-to-edge gap.
128
+ bestUp = -1; bestDn = -1
129
+ for (i = 1; i <= n; i++) {
130
+ if (i == me || parent[i] != parent[me]) continue
131
+ if (y[i] + h[i] <= y[me]) {
132
+ g = y[me] - (y[i] + h[i]); if (bestUp < 0 || g < bestUp) { bestUp = g; upi = i }
133
+ }
134
+ if (y[i] >= y[me] + h[me]) {
135
+ g = y[i] - (y[me] + h[me]); if (bestDn < 0 || g < bestDn) { bestDn = g; dni = i }
136
+ }
137
+ }
138
+ if (bestUp >= 0) printf " above %s, gap %dpt\n", name(upi), bestUp
139
+ else print " above nothing at this level"
140
+ if (bestDn >= 0) printf " below %s, gap %dpt\n", name(dni), bestDn
141
+ else print " below nothing at this level"
142
+
143
+ if (lbl != "")
144
+ printf " text \"%s\" — %s\n", lbl,
145
+ ((lbl ~ /…$/ || lbl ~ /\.\.\.$/) ? "TRUNCATED BY THE OS" : "not truncated")
146
+
147
+ ov = ""
148
+ for (i = 1; i <= n; i++) {
149
+ if (i == me || parent[i] != parent[me]) continue
150
+ ox = span(x[me], x[me] + w[me], x[i], x[i] + w[i])
151
+ oy = span(y[me], y[me] + h[me], y[i], y[i] + h[i])
152
+ if (ox > 2 && oy > 2)
153
+ ov = ov sprintf("%s%s by %dpt", (ov == "" ? "" : ", "), name(i), (ox < oy ? ox : oy))
154
+ }
155
+ printf " overlaps %s\n", (ov != "" ? ov : "none")
156
+
157
+ if (WW > 0 && WH > 0) {
158
+ off = 0
159
+ if (x[me] + w[me] > WW) off = x[me] + w[me] - WW
160
+ if (y[me] + h[me] > WH && y[me] + h[me] - WH > off) off = y[me] + h[me] - WH
161
+ if (x[me] < 0 && -x[me] > off) off = -x[me]
162
+ if (y[me] < 0 && -y[me] > off) off = -y[me]
163
+ printf " screen %dx%d — %s\n", WW, WH,
164
+ (off > 2 ? sprintf("THIS ELEMENT HANGS %dpt OFF IT", off) : "fully on it")
165
+ }
166
+
167
+ printf " hit area %dx%d — %s the %dpt minimum\n", w[me], h[me],
168
+ ((w[me] < MINHIT || h[me] < MINHIT) ? "BELOW" : "meets"), MINHIT
169
+ }
170
+ '
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # open.sh — hand a URL to the device and say where it landed.
3
+ #
4
+ # open.sh <url> [--expect-app <id>] [--timeout N]
5
+ #
6
+ # This is how deep links are tested: `open.sh myapp://transfer/123` asks the
7
+ # system to route the link exactly as it would if the user had tapped it in a
8
+ # message, which is the thing under test. An http(s) URL works too and lands in
9
+ # the browser.
10
+ #
11
+ # WHY IT VERIFIES, AND WHAT IT CAN HONESTLY VERIFY
12
+ #
13
+ # Handing over a URL always "succeeds": the system accepts it and the caller is
14
+ # told nothing about whether anything opened. A link with a typo'd scheme is
15
+ # accepted and silently does nothing, so a bare open would report success for
16
+ # the exact failure a deep-link test exists to catch.
17
+ #
18
+ # So this waits for the screen to change, and reports which app ended up in
19
+ # front. `--expect-app` turns that report into a check: the step fails unless
20
+ # the named app is the one that answered.
21
+ #
22
+ # open.sh myapp://pay/9 --expect-app com.example.myapp
23
+ #
24
+ # It cannot verify that the app routed the link to the *right screen*. Assert
25
+ # that yourself with `wait` on something only that screen has.
26
+ #
27
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
28
+ # read that would have been the next call is folded into this one. In a measured
29
+ # session — log in, open Settings — twelve calls did the work and six of them
30
+ # were snapshots taken only to find out what the previous action produced.
31
+ #
32
+ # exit 0 the URL was accepted and the screen changed
33
+ # exit 1 usage error
34
+ # exit 3 the device driver is unreachable — run doctor.sh
35
+ # exit 4 nothing changed, or a different app answered than --expect-app named
36
+
37
+ # SHARED VERB — one copy, reached from scripts/ios/ and scripts/android/ by
38
+ # symlink. The symlink's own directory decides which lib.sh the line below
39
+ # resolves to, which is the whole mechanism.
40
+ #
41
+ # It qualifies as shared because it touches the screen only through the driver
42
+ # primitives (drv_open_url, drv_foreground_app, drv_fingerprint) and the lookup
43
+ # helpers every adapter implements. Nothing here may learn that WebDriverAgent
44
+ # or UiAutomator2 exists.
45
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
46
+
47
+ URL=""
48
+ EXPECT=""
49
+ TIMEOUT=""
50
+
51
+ while [ $# -gt 0 ]; do
52
+ case "$1" in
53
+ --expect-app)
54
+ [ $# -ge 2 ] || die "--expect-app needs a bundle id or package name" 1
55
+ EXPECT="$2"; shift 2 ;;
56
+ --timeout)
57
+ [ $# -ge 2 ] || die "--timeout needs a number of seconds" 1
58
+ case "$2" in (*[!0-9]*|"") die "--timeout needs a positive integer, got: $2" 1 ;; esac
59
+ TIMEOUT="$2"; shift 2 ;;
60
+ --snapshot) act_snapshot_on; shift ;;
61
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
62
+ -*) die "unknown argument: $1 (usage: open.sh <url> [--expect-app <id>] [--timeout N])" 1 ;;
63
+ *)
64
+ [ -z "$URL" ] || die "only one url is supported, got '$URL' and '$1'" 1
65
+ URL="$1"; shift ;;
66
+ esac
67
+ done
68
+
69
+ [ -n "$URL" ] || die "missing url (usage: open.sh <url> [--expect-app <id>])" 1
70
+
71
+ # A URL with no scheme is the commonest way to write one that silently does
72
+ # nothing, so it is refused here rather than handed over to be ignored.
73
+ case "$URL" in
74
+ *://*) ;;
75
+ *:*) ;;
76
+ *) die "'$URL' has no scheme — a deep link needs one, e.g. myapp://path or https://example.com" 1 ;;
77
+ esac
78
+
79
+ load_config
80
+ [ -n "$TIMEOUT" ] || TIMEOUT="$(cfg '.timeouts.wait_seconds' '15')"
81
+ POLL_MS="$(cfg '.timeouts.poll_ms' '400')"
82
+
83
+ src="$(fetch_source)"
84
+ before="$(drv_fingerprint "$src")"
85
+ before_app="$(drv_foreground_app || true)"
86
+
87
+ # EXIT 4, NOT 3, AND THE DIFFERENCE NOW MATTERS.
88
+ #
89
+ # A refusal here means the system had no handler for the scheme — a finding, and
90
+ # usually the finding the test was written to make. It is not a connectivity
91
+ # problem. Connectivity problems never reach this line: the adapters raise those
92
+ # as exit 3 from inside the request itself.
93
+ #
94
+ # Getting this wrong was worse than untidy once callers began retrying exit 3
95
+ # automatically. A typo'd scheme would have been reported as a dropped
96
+ # connection, sent the runner through a full driver restart, and then failed
97
+ # anyway — burning a recovery cycle on a link that was never going to open.
98
+ err="$(drv_open_url "$URL" || true)"
99
+ [ -z "$err" ] || die "the system would not open $URL — $err" 4
100
+
101
+ deadline=$(( $(date +%s) + TIMEOUT ))
102
+ changed=0
103
+ while [ "$(date +%s)" -lt "$deadline" ]; do
104
+ now_src="$(try_fetch_source || true)"
105
+ if [ -n "$now_src" ]; then
106
+ [ "$(drv_fingerprint "$now_src")" != "$before" ] && { changed=1; break; }
107
+ fi
108
+ sleep "$(awk -v ms="$POLL_MS" 'BEGIN { printf "%.2f", ms / 1000 }')"
109
+ done
110
+
111
+ after_app="$(drv_foreground_app || true)"
112
+
113
+ if [ -n "$EXPECT" ]; then
114
+ [ "$after_app" = "$EXPECT" ] \
115
+ || die "opened $URL but $EXPECT is not in front — ${after_app:-nothing identifiable} is. The link may have no handler, or another app claims that scheme" 4
116
+ printf 'OK opened %s — %s came to the front\n' "$URL" "$after_app"
117
+ act_snapshot_now
118
+ exit 0
119
+ fi
120
+
121
+ if [ "$changed" -eq 0 ]; then
122
+ die "opened $URL but nothing changed within ${TIMEOUT}s — the scheme probably has no handler installed${after_app:+ (still in $after_app)}" 4
123
+ fi
124
+
125
+
126
+ if [ -n "$after_app" ] && [ "$after_app" != "$before_app" ]; then
127
+ printf 'OK opened %s — %s came to the front\n' "$URL" "$after_app"
128
+ else
129
+ printf 'OK opened %s — the screen changed%s\n' "$URL" "${after_app:+, still in $after_app}"
130
+ fi
131
+ act_snapshot_now
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # screenshot.sh — the screen as an image, for the things a tree cannot say.
3
+ #
4
+ # screenshot.sh [name] [--scale N] [--full]
5
+ #
6
+ # Prints the path and nothing else, so the output can be used as an argument.
7
+ #
8
+ # READ snapshot.sh FIRST. It costs roughly a third as much, it can be measured,
9
+ # and it answers most questions. This verb is for the ones it cannot: a wrong
10
+ # icon, a blurry asset, a colour, a contrast, a gradient. Neither XCUITest nor
11
+ # UiAutomator reports any of those, so the only way to know is to look.
12
+ #
13
+ # --scale N longest edge, in pixels. Default 768, which is roughly 360
14
+ # tokens and still shows the layout. --scale 0 leaves it alone.
15
+ # --full the same as --scale 0. A phone screen at full size costs about
16
+ # 1,500 tokens after the model downscales it anyway.
17
+ #
18
+ # CAPTURE-PROTECTED FIELDS RENDER BLANK. Apps that mark secure inputs render
19
+ # them as empty regions in any captured image; the pixels are genuinely absent.
20
+ # A blank region here is not evidence the field failed to draw — read it with
21
+ # snapshot.sh. This script says so whenever a text input is on screen.
22
+ #
23
+ # exit 0 file written
24
+ # exit 1 usage error
25
+ # exit 2 could not write the file
26
+ # exit 3 the driver is unreachable — run doctor.sh
27
+
28
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
29
+
30
+ NAME=""
31
+ SCALE=768
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --scale)
35
+ [ $# -ge 2 ] || die "--scale needs a number" 1
36
+ case "$2" in *[!0-9]*|"") die "--scale needs a non-negative integer, got: $2" 1 ;; esac
37
+ SCALE="$2"; shift 2 ;;
38
+ --full) SCALE=0; shift ;;
39
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
40
+ -*) die "unknown argument: $1 (usage: screenshot.sh [name] [--scale N] [--full])" 1 ;;
41
+ *)
42
+ [ -z "$NAME" ] || die "too many arguments (usage: screenshot.sh [name] [--scale N])" 1
43
+ NAME="$1"; shift ;;
44
+ esac
45
+ done
46
+
47
+ load_config
48
+ dir="$(run_dir)"
49
+
50
+ if [ -n "$NAME" ]; then
51
+ # A name is used to build a path, so it must not be able to escape the run
52
+ # directory or collide with shell parsing.
53
+ case "$NAME" in
54
+ */*|..*) die "name must not contain '/' or start with '..': $NAME" 1 ;;
55
+ esac
56
+ NAME="${NAME%.png}"
57
+ out="$dir/$NAME.png"
58
+ else
59
+ n=1
60
+ while [ -e "$(printf '%s/screen-%03d.png' "$dir" "$n")" ]; do n=$((n + 1)); done
61
+ out="$(printf '%s/screen-%03d.png' "$dir" "$n")"
62
+ fi
63
+
64
+ capture_png "$out" || rc=$?
65
+ case "${rc:-0}" in
66
+ 0) ;;
67
+ 2) die "could not write the screenshot into $out" 2 ;;
68
+ *) die "the driver returned no usable image — run $(as_cmd doctor)" 3 ;;
69
+ esac
70
+
71
+ # sips ships with macOS, so downscaling costs no dependency. -Z fits the longest
72
+ # edge and preserves the aspect ratio, which matters more than it sounds: a
73
+ # squashed screenshot is a layout bug that is not in the application.
74
+ #
75
+ # A resize that fails is a warning, not a failure. The image is already on disk
76
+ # and is still the truth about the screen; refusing to hand it over because it
77
+ # is larger than asked for would lose the thing the caller wanted.
78
+ if [ "$SCALE" -gt 0 ]; then
79
+ sips -Z "$SCALE" "$out" >/dev/null 2>&1 \
80
+ || printf 'warning: could not resize %s to %spx, so it is at full size\n' "$out" "$SCALE" >&2
81
+ fi
82
+
83
+ # Which elements are at risk cannot be decided from the element type. On the app
84
+ # this was built against, the password field reports as a plain TextField with
85
+ # no secure marker of any kind, yet does not appear in the captured image. So
86
+ # any text input is treated as suspect: a false warning costs one line, a missed
87
+ # one costs a wrong conclusion about the screen.
88
+ if rows="$(snapshot_rows 2>/dev/null)"; then
89
+ inputs="$(printf '%s\n' "$rows" | awk -F'|' '
90
+ $1 == "WINDOW" { next }
91
+ $4 ~ /TextField|SecureTextField|EditText|TextView|SearchField/ && $10 + 0 > 0 && $11 + 0 > 0 {
92
+ printf " %s label:%s value:%s at %s,%s %sx%s\n", $4, $6, $7, $8, $9, $10, $11 }')"
93
+ if [ -n "$inputs" ]; then
94
+ printf 'warning: %s text input(s) on screen; capture-protected inputs render blank here even though they are on the device. Read them with snapshot.sh — a blank region is not evidence the field failed to render\n' \
95
+ "$(printf '%s\n' "$inputs" | wc -l | tr -d ' ')" >&2
96
+ printf '%s\n' "$inputs" >&2
97
+ fi
98
+ else
99
+ printf 'warning: the tree could not be read, so capture-protected inputs were not checked for — treat blank regions in this image as unknown, not absent\n' >&2
100
+ fi
101
+
102
+ printf '%s\n' "$out"
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ # scroll.sh — bring content into view.
3
+ #
4
+ # scroll.sh <up|down|left|right> [--times N]
5
+ # scroll.sh --until <selector> [--direction down] [--max N] [--index N]
6
+ #
7
+ # DIRECTION MEANS WHERE THE CONTENT IS, NOT WHERE THE FINGER GOES
8
+ #
9
+ # `scroll.sh down` reveals what is further down the page. The finger travels
10
+ # upwards to do that. This is the opposite of `swipe.sh --direction up`, which
11
+ # describes the gesture, and the two are separated on purpose: mixing the
12
+ # physical and the intentional reading into one word is how everyone ends up
13
+ # scrolling the wrong way for their first hour with a tool.
14
+ #
15
+ # scroll = where you want to go (intent)
16
+ # swipe = where your thumb moves (gesture)
17
+ #
18
+ # --until polls with the same exact matching tap.sh uses, so "scrolled to it"
19
+ # and "can tap it" cannot disagree. It stops as soon as the selector matches, or
20
+ # when the screen stops changing — a list that has hit its end is reported as
21
+ # such rather than scrolled at forever.
22
+ #
23
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
24
+ # read that would have been the next call is folded into this one. In a measured
25
+ # session — log in, open Settings — twelve calls did the work and six of them
26
+ # were snapshots taken only to find out what the previous action produced.
27
+ #
28
+ # exit 0 scrolled, and for --until the selector is now on screen
29
+ # exit 1 usage error
30
+ # exit 3 the device driver is unreachable — run doctor.sh
31
+ # exit 4 --until never found it
32
+
33
+ # SHARED VERB — one copy, reached from scripts/ios/ and scripts/android/ by
34
+ # symlink. The symlink's own directory decides which lib.sh the line below
35
+ # resolves to, which is the whole mechanism.
36
+ #
37
+ # It qualifies as shared because it touches the screen only through the driver
38
+ # primitives (drv_tap, drv_drag, drv_fingerprint*) and the lookup helpers every
39
+ # adapter implements. Nothing here may learn that WebDriverAgent or UiAutomator2
40
+ # exists; the moment a verb needs to, it stops being shared and moves back into
41
+ # the adapter that needs it.
42
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
43
+
44
+ DIRECTION=""
45
+ TIMES=1
46
+ UNTIL=""
47
+ MAX=""
48
+ INDEX=""
49
+
50
+ while [ $# -gt 0 ]; do
51
+ case "$1" in
52
+ up|down|left|right)
53
+ [ -z "$DIRECTION" ] || die "direction given twice: '$DIRECTION' and '$1'" 1
54
+ DIRECTION="$1"; shift ;;
55
+ --direction)
56
+ [ $# -ge 2 ] || die "--direction needs up, down, left or right" 1
57
+ case "$2" in up|down|left|right) ;; *) die "--direction must be up, down, left or right, got: $2" 1 ;; esac
58
+ DIRECTION="$2"; shift 2 ;;
59
+ --until)
60
+ [ $# -ge 2 ] || die "--until needs a selector" 1
61
+ UNTIL="$2"; shift 2 ;;
62
+ --times)
63
+ [ $# -ge 2 ] || die "--times needs a number" 1
64
+ case "$2" in (*[!0-9]*|"") die "--times needs a positive integer, got: $2" 1 ;; esac
65
+ [ "$2" -gt 0 ] || die "--times must be greater than zero" 1
66
+ TIMES="$2"; shift 2 ;;
67
+ --max)
68
+ [ $# -ge 2 ] || die "--max needs a number" 1
69
+ case "$2" in (*[!0-9]*|"") die "--max needs a positive integer, got: $2" 1 ;; esac
70
+ [ "$2" -gt 0 ] || die "--max must be greater than zero" 1
71
+ MAX="$2"; shift 2 ;;
72
+ --index)
73
+ [ $# -ge 2 ] || die "--index needs a number" 1
74
+ INDEX="$2"; shift 2 ;;
75
+ --snapshot) act_snapshot_on; shift ;;
76
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
77
+ *) die "unknown argument: $1 (usage: scroll.sh <up|down|left|right> [--times N] | scroll.sh --until <selector>)" 1 ;;
78
+ esac
79
+ done
80
+
81
+ [ -n "$DIRECTION" ] || [ -n "$UNTIL" ] \
82
+ || die "missing direction (usage: scroll.sh <up|down|left|right> [--times N] | scroll.sh --until <selector>)" 1
83
+ [ -n "$DIRECTION" ] || DIRECTION=down
84
+
85
+ load_config
86
+ [ -n "$MAX" ] || MAX="$(cfg '.scroll.max_swipes' '15')"
87
+
88
+ read -r WIN_W WIN_H <<< "$(window_size)"
89
+
90
+ # A swipe over the middle 60% of the screen. The margins matter: starting at the
91
+ # very edge triggers the system's back gesture on iOS, and ending at the very
92
+ # top or bottom lands in the status bar or the home indicator.
93
+ CX=$(( WIN_W / 2 ))
94
+ CY=$(( WIN_H / 2 ))
95
+ NEAR_Y=$(( WIN_H * 70 / 100 ))
96
+ FAR_Y=$(( WIN_H * 30 / 100 ))
97
+ NEAR_X=$(( WIN_W * 80 / 100 ))
98
+ FAR_X=$(( WIN_W * 20 / 100 ))
99
+
100
+ case "$DIRECTION" in
101
+ down) FX=$CX; FY=$NEAR_Y; TX=$CX; TY=$FAR_Y ;;
102
+ up) FX=$CX; FY=$FAR_Y; TX=$CX; TY=$NEAR_Y ;;
103
+ right) FX=$NEAR_X; FY=$CY; TX=$FAR_X; TY=$CY ;;
104
+ left) FX=$FAR_X; FY=$CY; TX=$NEAR_X; TY=$CY ;;
105
+ esac
106
+
107
+ one_swipe() {
108
+ local err
109
+ err="$(drv_drag "$FX" "$FY" "$TX" "$TY" 200 || true)"
110
+ [ -z "$err" ] || die "scroll failed — $err" 3
111
+ }
112
+
113
+ # --- plain scroll -------------------------------------------------------------
114
+
115
+ if [ -z "$UNTIL" ]; then
116
+ act_before
117
+ i=0
118
+ while [ "$i" -lt "$TIMES" ]; do
119
+ one_swipe
120
+ i=$((i + 1))
121
+ done
122
+ act_report SCROLL "$DIRECTION — $TIMES time(s)"
123
+ journal_append scroll "$DIRECTION $TIMES" "" "" "" "" 0 0 0 0 "$DT_ACT_HASH"
124
+ exit 0
125
+ fi
126
+
127
+ # --- scroll until visible -----------------------------------------------------
128
+
129
+ parse_selector "$UNTIL"
130
+ [ "$SEL_KIND" != xy ] \
131
+ || die "--until needs id:, label: or text: — a point is always 'visible'" 1
132
+
133
+ found_now() {
134
+ local src matches
135
+ src="$(fetch_source)"
136
+ matches="$(match_elements "$src" "$SEL_KIND" "$SEL_VALUE" || true)"
137
+ [ -n "$matches" ] || return 1
138
+ printf '%s\n' "$matches" | awk -F'|' 'NR == 1 { printf "%s at %s,%s,%s,%s", $5, $1, $2, $3, $4 }'
139
+ }
140
+
141
+ # Recorded even though nothing has been swiped yet: this branch performs no
142
+ # action, and act_report needs a before-state or it says the screen could not be
143
+ # read — which would be a stranger answer than "unchanged".
144
+ act_before
145
+ if hit="$(found_now)"; then
146
+ act_report SCROLL "$DIRECTION — $UNTIL already visible ($hit), 0 swipes"
147
+ exit 0
148
+ fi
149
+
150
+ prev=""
151
+ n=0
152
+ while [ "$n" -lt "$MAX" ]; do
153
+ one_swipe
154
+ n=$((n + 1))
155
+
156
+ if hit="$(found_now)"; then
157
+ act_report SCROLL "$DIRECTION — found $UNTIL after $n swipe(s) ($hit)"
158
+ exit 0
159
+ fi
160
+
161
+ # A fingerprint of the screen. When two consecutive swipes leave it unchanged
162
+ # the list has hit its end, and continuing to swipe would burn the whole --max
163
+ # budget to report the same failure later.
164
+ #
165
+ # Values are excluded: on an idle screen a value-sensitive fingerprint changed
166
+ # on every sample, because carousels and page indicators animate themselves.
167
+ # Positions are kept, because a scroll that moves nothing is exactly the
168
+ # condition being detected.
169
+ src_now="$(fetch_source)"
170
+ now="$(drv_fingerprint_pos "$src_now")"
171
+ if [ "$now" = "$prev" ]; then
172
+ die "scrolled $DIRECTION $n time(s) and reached the end without finding $UNTIL" 4
173
+ fi
174
+ prev="$now"
175
+ done
176
+
177
+ die "scrolled $DIRECTION $MAX time(s) without finding $UNTIL — raise --max if the list is longer" 4
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # snapshot.sh — what is on the screen, with the geometry, numbered.
3
+ #
4
+ # snapshot.sh [--all] [--grep S]
5
+ #
6
+ # This is the default way to read a screen and the cheapest one. A dense screen
7
+ # comes back as 25–50 lines; the same screen as an image costs roughly three
8
+ # times as many tokens and cannot be measured.
9
+ #
10
+ # [7] Button #btn_login "Đăng nhập" (24,612) 327x50 enabled
11
+ #
12
+ # ⚠ hit area below the 44pt minimum — [3] [7] [19]
13
+ #
14
+ # #btn_login is the accessibility identifier, printed when the app supplies one.
15
+ # It is the only selector that survives rewritten copy and a second language, so
16
+ # it is worth preferring over the label; a warning names the controls that have
17
+ # none, which is a fault in the app rather than on the screen.
18
+ #
19
+ # The number in brackets is a uid, and it is only valid until the next snapshot.
20
+ # `tap 7` re-reads the screen and requires the element still to be exactly what
21
+ # was numbered — same type, same text, same rectangle. If it is not, the tap is
22
+ # refused rather than aimed at wherever that element used to be.
23
+ #
24
+ # Warnings are grouped rather than printed beside each element: a list of six
25
+ # identical rows otherwise reports the same fault six times, and an agent that
26
+ # reads fifty warnings on every screen stops reading warnings. Which sibling an
27
+ # element overlaps is a question for measure.sh, which exists for it.
28
+ #
29
+ # --all every element, no filter
30
+ # --grep S case-insensitive substring of type, id, label or value. Searches
31
+ # the whole tree, including what the filter hides, so it can find
32
+ # something that is present but off screen.
33
+ #
34
+ # exit 0 the screen was read
35
+ # exit 1 usage error
36
+ # exit 3 the driver is unreachable — run doctor.sh
37
+ # exit 4 nothing matched
38
+
39
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
40
+
41
+ ALL=0
42
+ GREP=""
43
+ while [ $# -gt 0 ]; do
44
+ case "$1" in
45
+ --all) ALL=1; shift ;;
46
+ --grep)
47
+ [ $# -ge 2 ] || die "--grep needs a string" 1
48
+ GREP="$2"; shift 2 ;;
49
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
50
+ *) die "unknown argument: $1 (usage: snapshot.sh [--all] [--grep S])" 1 ;;
51
+ esac
52
+ done
53
+
54
+ load_config
55
+
56
+ rows="$(snapshot_rows)"
57
+ [ -n "$rows" ] || die "the driver returned an empty tree — run $(as_cmd doctor)" 3
58
+
59
+ # render_rows and write_uid_store live in lib.sh, because an action asked for
60
+ # `--snapshot` produces exactly this and must number it exactly this way. Two
61
+ # copies of the numbering rule is how `tap 7` comes to mean two different things
62
+ # depending on which verb printed the 7.
63
+ out="$(render_rows "$rows" "$ALL" "$GREP")" \
64
+ || die "nothing on this screen matched${GREP:+ --grep '$GREP'}" 4
65
+
66
+ write_uid_store "$rows" "$ALL" "$GREP" \
67
+ || die "could not write the uid store at $(uid_store)" 2
68
+
69
+ printf '%s\n' "$out"