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,166 @@
1
+ # tree.awk — UiAutomator XML hierarchy to DeviceTools's line table.
2
+ #
3
+ # WHY THIS EXISTS RATHER THAN A CALL TO yq
4
+ #
5
+ # The obvious route is `yq -p=xml -o=json` and then jq, mirroring the iOS
6
+ # adapter. It works: measured 0.058s on a 36 KB dump. It also adds a dependency
7
+ # that every user must install before the tool runs at all, to do one job on one
8
+ # input, and that job is 60 lines of awk.
9
+ #
10
+ # The input is not general XML and does not need a general parser. UiAutomator's
11
+ # serialiser emits: no CDATA, no text nodes, no namespaces, no processing
12
+ # instructions beyond the declaration, and attribute values always in double
13
+ # quotes with `< > & " '` escaped as entities. Those properties are guaranteed by
14
+ # the serialiser, not by luck, and they are what make a 60-line parser correct
15
+ # instead of merely lucky.
16
+ #
17
+ # Output, one line per element, in document order:
18
+ #
19
+ # depth|class|id|label|text|x|y|w|h|visible|enabled|password|clickable|hint
20
+ #
21
+ # `hint` is 1 when the `text` column is a placeholder rather than the field's
22
+ # contents. Android reports this and iOS does not, and it is worth carrying:
23
+ # without it, an empty field whose placeholder reads "Mật khẩu" is
24
+ # indistinguishable from a field someone typed "Mật khẩu" into.
25
+ #
26
+ # The root <hierarchy> element is not a UI element and is not emitted. Its width
27
+ # and height are the window size, printed first as:
28
+ #
29
+ # WINDOW|<w>|<h>
30
+ #
31
+ # so that callers needing the screen size do not pay for a second request.
32
+
33
+ function unesc(s) {
34
+ if (index(s, "&") == 0) return s
35
+ gsub(/&lt;/, "<", s)
36
+ gsub(/&gt;/, ">", s)
37
+ gsub(/&quot;/, "\"", s)
38
+ gsub(/&apos;/, "'", s)
39
+ gsub(/&#10;/, " ", s)
40
+ gsub(/&#13;/, " ", s)
41
+ gsub(/&#9;/, " ", s)
42
+ # Last, and never earlier: decoding &amp; first would turn the literal text
43
+ # "&lt;" into "<". In awk's replacement string "&" means the matched text, so
44
+ # a literal ampersand has to be escaped.
45
+ gsub(/&amp;/, "\\&", s)
46
+ return s
47
+ }
48
+
49
+ # The same normalisation the iOS adapter applies in jq. Labels come from
50
+ # application copy and can hold newlines and pipes, both of which would break
51
+ # the line-oriented contract, so they are neutralised where the tree is read.
52
+ function clean(s) {
53
+ gsub(/[\n\r\t]+/, " ", s)
54
+ gsub(/\|/, "/", s)
55
+ gsub(/ +/, " ", s)
56
+ sub(/^ +/, "", s)
57
+ sub(/ +$/, "", s)
58
+ return s
59
+ }
60
+
61
+ # attr(rec, name) — the value of one attribute, or "".
62
+ #
63
+ # The leading space in the needle is what keeps `text` from matching
64
+ # `tooltip-text` and `id` from matching `window-id`.
65
+ #
66
+ # BOTH QUOTE CHARACTERS ARE LOAD-BEARING. UiAutomator does not escape a double
67
+ # quote inside a value; it switches the delimiter instead. Typing
68
+ # Đặng Thu Hà & "ghi chú"
69
+ # into a field serialises as
70
+ # text='Đặng Thu Hà &amp; "ghi chú"'
71
+ # with `&` escaped and `"` not. A parser that only knows double quotes reads
72
+ # that attribute as absent and reports an empty field — which is how a passing
73
+ # test can be built on a value that was never checked.
74
+ function attr(rec, name, i, dq, sq, q, s, j) {
75
+ dq = index(rec, " " name "=\"")
76
+ sq = index(rec, " " name "='")
77
+ if (dq == 0 && sq == 0) return ""
78
+ # Whichever appears first is this attribute; the other, if present, belongs to
79
+ # a different attribute whose name ends with the same characters.
80
+ if (sq == 0 || (dq != 0 && dq < sq)) { i = dq; q = "\"" } else { i = sq; q = "'" }
81
+ s = substr(rec, i + length(name) + 3)
82
+ j = index(s, q)
83
+ if (j == 0) return ""
84
+ return unesc(substr(s, 1, j - 1))
85
+ }
86
+
87
+ BEGIN {
88
+ RS = "<"
89
+ # <hierarchy> is a synthetic wrapper, not a widget, so it is not emitted and
90
+ # not counted: the first real element comes out at depth 0, which is where the
91
+ # iOS adapter puts the root element too.
92
+ depth = -2
93
+ win_w = 0; win_h = 0
94
+ }
95
+
96
+ # Everything before the first "<" is the empty leading record.
97
+ NR == 1 { next }
98
+
99
+ {
100
+ rec = $0
101
+ c = substr(rec, 1, 1)
102
+
103
+ if (c == "?" || c == "!") next # declaration, comment, doctype
104
+ if (c == "/") { depth--; next } # closing tag
105
+
106
+ # Self-closing has to be decided on a skeleton with attribute values removed:
107
+ # a value may legitimately end in "/" (a path, a date), and testing the raw
108
+ # record would then call an opening tag self-closing and corrupt every depth
109
+ # below it.
110
+ skel = rec
111
+ gsub(/"[^"]*"/, "", skel)
112
+ gsub(/'[^']*'/, "", skel)
113
+ selfclose = (skel ~ /\/[ \t\r\n]*>[ \t\r\n]*$/)
114
+
115
+ depth++
116
+
117
+ cls = attr(rec, "class")
118
+
119
+ if (cls == "hierarchy") {
120
+ win_w = attr(rec, "width") + 0
121
+ win_h = attr(rec, "height") + 0
122
+ print "WINDOW|" win_w "|" win_h
123
+ if (selfclose) depth--
124
+ next
125
+ }
126
+
127
+ # bounds="[x1,y1][x2,y2]"
128
+ b = attr(rec, "bounds")
129
+ x = 0; y = 0; w = 0; h = 0
130
+ if (b != "") {
131
+ n = split(b, p, /[^0-9-]+/)
132
+ # split on the separators leaves an empty first field from the leading "["
133
+ if (n >= 5) {
134
+ x1 = p[2] + 0; y1 = p[3] + 0; x2 = p[4] + 0; y2 = p[5] + 0
135
+ x = x1; y = y1; w = x2 - x1; h = y2 - y1
136
+ }
137
+ }
138
+
139
+ # The last dotted segment is enough to identify a widget and costs a fraction
140
+ # of the tokens: android.widget.TextView is TextView on every screen.
141
+ short = cls
142
+ sub(/^.*\./, "", short)
143
+
144
+ # resource-id arrives as "com.example.app:id/login_button". The package prefix
145
+ # is the same for every element of the app under test, so it is pure noise in
146
+ # the common case. It is stripped here; lib.sh matches a selector against both
147
+ # the stripped and the full form, so either spelling works and neither is
148
+ # silently wrong.
149
+ rid = attr(rec, "resource-id")
150
+ sub(/^[^ ]*:id\//, "", rid)
151
+
152
+ printf "%d|%s|%s|%s|%s|%d|%d|%d|%d|%s|%s|%s|%s|%s\n",
153
+ depth,
154
+ clean(short),
155
+ clean(rid),
156
+ clean(attr(rec, "content-desc")),
157
+ clean(attr(rec, "text")),
158
+ x, y, w, h,
159
+ (attr(rec, "displayed") == "true" ? 1 : 0),
160
+ (attr(rec, "enabled") == "true" ? 1 : 0),
161
+ (attr(rec, "password") == "true" ? 1 : 0),
162
+ (attr(rec, "clickable") == "true" ? 1 : 0),
163
+ (attr(rec, "showing-hint") == "true" ? 1 : 0)
164
+
165
+ if (selfclose) depth--
166
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # tree.sh — the accessibility tree, reduced to something an agent can afford to read.
3
+ #
4
+ # tree.sh [--all] [--depth N] [--grep S]
5
+ #
6
+ # One line per element, identical in shape to the iOS adapter's:
7
+ #
8
+ # depth | type | id | label | value | x,y,w,h
9
+ #
10
+ # On Android `id` is the resource-id with its package prefix removed, `label` is
11
+ # the content description and `value` is the element's text.
12
+ #
13
+ # The raw XML hierarchy for one screen measured 46 KB (~11.6k tokens) on the
14
+ # device this was built against. Raw XML must never reach the agent, so this
15
+ # script is the only supported way to read the tree.
16
+ #
17
+ # By default an element is shown when it is visible AND it either carries an
18
+ # identifier, a label or a value, or is marked clickable. An invisible element
19
+ # with a label is noise; an unlabelled control is not, because it can still be
20
+ # reached by coordinate or by structure.
21
+ #
22
+ # --all every element, no filter and no cap
23
+ # --full do not truncate strings at 48 characters
24
+ # --depth N only elements at depth <= N
25
+ # --grep S case-insensitive substring of id, label, value or type.
26
+ # Searches the whole tree, including elements the default filter
27
+ # hides, so it can find something that is present but off-screen.
28
+ #
29
+ # Output is capped at tree.max_lines from the config and always ends with
30
+ # … N more elements (--all)
31
+ # when truncated, so a partial tree can never be mistaken for a complete one.
32
+ #
33
+ # exit 0 at least one element printed
34
+ # exit 1 usage error
35
+ # exit 3 UiAutomator2 unreachable — run doctor.sh
36
+ # exit 4 nothing matched
37
+
38
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
39
+
40
+ ALL=0
41
+ FULL=0
42
+ DEPTH=-1
43
+ GREP=""
44
+
45
+ while [ $# -gt 0 ]; do
46
+ case "$1" in
47
+ --all) ALL=1; shift ;;
48
+ # Repair needs exact strings. A label cut at 48 characters cannot be turned
49
+ # into a selector that matches, so a caller capturing a failure dump asks for
50
+ # this while everything an agent reads interactively keeps the cap.
51
+ --full) FULL=1; shift ;;
52
+ --depth)
53
+ [ $# -ge 2 ] || die "--depth needs a number" 1
54
+ case "$2" in (*[!0-9]*|"") die "--depth needs a non-negative integer, got: $2" 1 ;; esac
55
+ DEPTH="$2"; shift 2 ;;
56
+ --grep)
57
+ [ $# -ge 2 ] || die "--grep needs a string" 1
58
+ GREP="$2"; shift 2 ;;
59
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
60
+ *) die "unknown argument: $1 (usage: tree.sh [--all] [--full] [--depth N] [--grep S])" 1 ;;
61
+ esac
62
+ done
63
+
64
+ load_config
65
+ MAXLINES="$(cfg '.tree.max_lines' '60')"
66
+
67
+ src="$(fetch_source)"
68
+
69
+ # One awk pass: filter, format, truncate.
70
+ #
71
+ # TRUNCATION COUNTS CHARACTERS, NOT BYTES, and that is not pedantry. macOS ships
72
+ # awk 20200816, whose length() and substr() are byte-oriented: cutting "Lê"
73
+ # at six bytes yields "Nguy\341\273", an incomplete UTF-8 sequence that renders
74
+ # as a replacement character and can never be matched by a selector. Since every
75
+ # label in the app this was built against is Vietnamese, a byte cut would
76
+ # corrupt most of the output. So character boundaries are found by skipping
77
+ # UTF-8 continuation bytes, which awk compares correctly even though it cannot
78
+ # express them as a regex range.
79
+ rows="$(printf '%s' "$src" | awk -F'|' -v all="$ALL" -v depth="$DEPTH" -v want="$GREP" -v full="$FULL" '
80
+ BEGIN { for (i = 128; i <= 191; i++) CONT = CONT sprintf("%c", i) }
81
+
82
+ function cutn(s, n, i, chars, b, len) {
83
+ if (full == 1) return s
84
+ len = length(s); chars = 0
85
+ for (i = 1; i <= len; i++) {
86
+ b = substr(s, i, 1)
87
+ if (index(CONT, b) > 0) continue # continuation byte: same character
88
+ chars++
89
+ if (chars == n + 1) return substr(s, 1, i - 1) "…"
90
+ }
91
+ return s
92
+ }
93
+
94
+ $1 == "WINDOW" || $1 == "" { next }
95
+ depth >= 0 && $1 + 0 > depth { next }
96
+ {
97
+ d = $1; t = $2; id = $3; lb = $4; v = $5
98
+ vis = ($10 == 1)
99
+ if (want == "") {
100
+ # $13 is clickable. An unlabelled control is still a control, and hiding
101
+ # it tells the reader the screen has nothing to tap — which is worse than
102
+ # the noise the filter exists to remove.
103
+ if (all != 1 && !(vis && (id != "" || lb != "" || v != "" || $13 == 1))) next
104
+ } else {
105
+ hay = tolower(id " " lb " " v " " t)
106
+ if (index(hay, tolower(want)) == 0) next
107
+ }
108
+ printf "%s | %s | %s | %s | %s | %s,%s,%s,%s\n",
109
+ d, t, cutn(id, 48), cutn(lb, 48), cutn(v, 48), $6, $7, $8, $9
110
+ }')"
111
+
112
+ if [ -z "$rows" ]; then
113
+ total="$(printf '%s' "$src" | grep -vc '^WINDOW|' || true)"
114
+ if [ -n "$GREP" ]; then
115
+ die "no elements matched --grep '$GREP' ($total elements in tree)" 4
116
+ fi
117
+ die "no elements matched (tree has $total elements, try --all)" 4
118
+ fi
119
+
120
+ n="$(printf '%s\n' "$rows" | wc -l | tr -d ' ')"
121
+
122
+ if [ "$ALL" -eq 1 ] || [ "$n" -le "$MAXLINES" ]; then
123
+ printf '%s\n' "$rows"
124
+ else
125
+ printf '%s\n' "$rows" | take_lines "$MAXLINES"
126
+ printf '… %s more elements (--all)\n' "$(( n - MAXLINES ))"
127
+ fi
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # type.sh — type into whatever currently has keyboard focus, then prove it.
3
+ #
4
+ # type.sh <text>
5
+ #
6
+ # Verification is the point of this script. Sending keystrokes is one HTTP call;
7
+ # knowing they landed is what stops an agent from building a chain of reasoning
8
+ # on a field that never received the text.
9
+ #
10
+ # WHY NOT `adb shell input text`
11
+ #
12
+ # It cannot type Vietnamese. Four encodings were tried against a real device and
13
+ # each returned "Exception occurred while executing 'text'", while the same call
14
+ # with ASCII returned 0. Every field this tool exists to fill is in Vietnamese,
15
+ # so the whole Android adapter is built on the UiAutomator2 server instead.
16
+ #
17
+ # Verification is tiered, because a masked field cannot echo back what you typed:
18
+ #
19
+ # verified the field now reads exactly what was typed
20
+ # verified, appended the field ends with what was typed
21
+ # verified by change the field changed but reads something else — a password
22
+ # field, or an input mask reformatting as you go
23
+ # nothing changed exit 4; the keystrokes went nowhere
24
+ #
25
+ # Android reports `password` on the element itself, so unlike iOS this does not
26
+ # have to guess why a field failed to echo: the verdict names the reason.
27
+ #
28
+ # What was typed is reported as a length, not as text, because this runs against
29
+ # a production app and stdout ends up in CI logs. The field's own contents are
30
+ # echoed only when they differ from what was typed, which is the case where a
31
+ # human debugging a failure genuinely needs to see them.
32
+ #
33
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
34
+ # read that would have been the next call is folded into this one. In a measured
35
+ # session — log in, open Settings — twelve calls did the work and six of them
36
+ # were snapshots taken only to find out what the previous action produced.
37
+ #
38
+ # exit 0 typed, and something changed to prove it
39
+ # exit 1 usage error
40
+ # exit 3 UiAutomator2 unreachable — run doctor.sh
41
+ # exit 4 nothing focused, or typing changed nothing
42
+
43
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
44
+
45
+ TEXT=""
46
+ HAVE_TEXT=0
47
+ while [ $# -gt 0 ]; do
48
+ case "$1" in
49
+ --snapshot) act_snapshot_on; shift ;;
50
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
51
+ # See the iOS adapter: `--` is how text that begins with a dash is told
52
+ # apart from a mistyped flag, and a mistyped flag must not be keyed into a
53
+ # password field.
54
+ --) shift
55
+ [ $# -ge 1 ] || die "-- must be followed by the text to type" 1
56
+ [ "$HAVE_TEXT" -eq 0 ] || die "type.sh takes one text argument; quote text containing spaces" 1
57
+ TEXT="$1"; HAVE_TEXT=1; shift ;;
58
+ -*) die "unknown argument: $1 (usage: type.sh <text> [--snapshot]; use -- before text starting with a dash)" 1 ;;
59
+ *)
60
+ [ "$HAVE_TEXT" -eq 0 ] || die "type.sh takes one text argument; quote text containing spaces" 1
61
+ TEXT="$1"; HAVE_TEXT=1; shift ;;
62
+ esac
63
+ done
64
+ [ "$HAVE_TEXT" -eq 1 ] || die "missing text (usage: type.sh <text> [--snapshot])" 1
65
+
66
+ load_config
67
+
68
+ # key -> value, one line per text input: key, rect, type, value, password.
69
+ #
70
+ # THE KEY IS THE RESOURCE ID WHEN THERE IS ONE, AND THE RECT ONLY OTHERWISE.
71
+ # Keying on the rect alone, as the iOS adapter must, has a measured failure
72
+ # mode: typing into a password field moved it from y=808 to y=751 because the
73
+ # keyboard resized the layout. Under a rect key that is not "this field
74
+ # changed", it is "a field I have never seen appeared" — and a field that merely
75
+ # moved would then be reported as proof that typing worked. Android publishes a
76
+ # resource id, which survives the move, so it is used where present.
77
+ #
78
+ # A field showing its placeholder is recorded as EMPTY, not as the placeholder
79
+ # text. Android sets `showing-hint` for exactly this, and without honouring it
80
+ # a field whose hint reads "Mật khẩu" would appear to already contain that
81
+ # string, so typing it would register as "nothing changed".
82
+ snapshot_inputs() {
83
+ printf '%s' "$1" | awk -F'|' '
84
+ $1 == "WINDOW" || $1 == "" { next }
85
+ $10 != 1 || $8 <= 0 || $9 <= 0 { next }
86
+ ($2 !~ /Edit/ && $12 != 1) { next }
87
+ { val = ($14 == 1 ? "" : $5)
88
+ rect = $6 "," $7 "," $8 "," $9
89
+ key = ($3 != "" ? "#" $3 : rect)
90
+ printf "%s\t%s\t%s\t%s\t%s\n", key, rect, $2, val, $12 }'
91
+ }
92
+
93
+ # Fetches are assigned to their own variable rather than nested inside another
94
+ # substitution. A failure in `f "$(g)"` is discarded — the exit status seen is
95
+ # f's, not g's — so nesting a call that can fail hides the failure.
96
+ src="$(fetch_source)"
97
+
98
+ # FOCUS IS THE PRECONDITION. A TEXT INPUT IN THE TREE IS NOT.
99
+ #
100
+ # UiAutomator2 types into a named element, so focus has to be resolved rather
101
+ # than assumed. This is also the cheap pre-check: with nothing focused the
102
+ # server answers immediately instead of hanging until the HTTP timeout.
103
+ #
104
+ # It used to be preceded by a guard that refused unless the tree held something
105
+ # recognisable as a text input. See the iOS adapter for why that is wrong: an
106
+ # OTP field is routinely invisible to accessibility while holding focus, so the
107
+ # guard refused a screen that would have accepted every keystroke.
108
+ resp="$(session_get '/element/active')"
109
+ EID="$(printf '%s' "$resp" | jq -r '.value.ELEMENT // empty' 2>/dev/null)"
110
+ [ -n "$EID" ] || die "nothing has keyboard focus — tap a field to focus it before typing" 4
111
+
112
+ before="$(snapshot_inputs "$src")"
113
+ before_digest="$(content_digest "$src")"
114
+
115
+ act_before
116
+ payload="$(jq -nc --arg t "$TEXT" '{text: $t}')"
117
+ resp="$(session_post "/element/$EID/value" "$payload")"
118
+ err="$(session_error "$resp")"
119
+ [ -z "$err" ] || die "typing failed — $err" 3
120
+
121
+ src="$(fetch_source)"
122
+ after="$(snapshot_inputs "$src")"
123
+ after_digest="$(content_digest "$src")"
124
+
125
+ # Which input changed, if any, strongest evidence first.
126
+ #
127
+ # Rank matters because not all evidence is equal. A field that kept its identity
128
+ # and changed its value is proof. A field that appeared where none was tracked
129
+ # is only proof if it now holds what was typed; otherwise it may simply be a
130
+ # field that moved, and reporting it as success would confirm a keystroke that
131
+ # never landed.
132
+ #
133
+ # The blank-line guard is load bearing: `printf '%s\n' "$empty"` emits one empty
134
+ # line, which awk counts as a record. Without it, an empty "after" manufactures
135
+ # a phantom changed input and reports success.
136
+ changed="$(awk -F'\t' -v OFS='\t' -v typed="$TEXT" '
137
+ $1 == "" { next }
138
+ NR == FNR { was[$1] = $4; seen[$1] = 1; next }
139
+ {
140
+ if (seen[$1]) {
141
+ if (was[$1] == $4) next
142
+ rank = 0 # same field, new value
143
+ } else if (typed != "" && index($4, typed) > 0) {
144
+ rank = 1 # new field, holds what we typed
145
+ } else {
146
+ rank = 2 # new field, unexplained
147
+ }
148
+ print rank, $2, $3, $4, $5
149
+ }
150
+ ' <(printf '%s\n' "$before") <(printf '%s\n' "$after") | sort -n -k1,1)"
151
+
152
+ # VERIFICATION DROPS A TIER, IT DOES NOT REFUSE. See the iOS adapter.
153
+ if [ -z "$changed" ]; then
154
+ if [ "$before_digest" != "$after_digest" ]; then
155
+ act_report TYPE "${#TEXT} char(s) — no text input in the tree, and the screen changed. Typical of an OTP or PIN field: the value cannot be read back from here, so this is evidence the keystrokes landed, not proof of what they were"
156
+ journal_append type "$TEXT" "" "" "" "" 0 0 0 0 "$DT_ACT_HASH"
157
+ exit 0
158
+ fi
159
+ # The length, not the text — see the note on the same line in the iOS adapter.
160
+ die "typed ${#TEXT} char(s) and nothing on screen changed — focus was held but the keystrokes went nowhere; tap the field again" 4
161
+ fi
162
+
163
+ n="$(printf '%s\n' "$changed" | wc -l | tr -d ' ')"
164
+ line="$(first_line "$changed")"
165
+ rect="$(printf '%s' "$line" | cut -f2)"
166
+ # NOT `type`: it is a shell builtin, and on the iOS side an unset one made the
167
+ # error read `type: unbound variable`, which looks like a missing command.
168
+ field_type="$(printf '%s' "$line" | cut -f3)"
169
+ now="$(printf '%s' "$line" | cut -f4)"
170
+ pw="$(printf '%s' "$line" | cut -f5)"
171
+
172
+ if [ "$now" = "$TEXT" ]; then
173
+ verdict="verified"
174
+ else
175
+ case "$now" in
176
+ *"$TEXT") verdict="verified, appended" ;;
177
+ *)
178
+ if [ "$pw" = 1 ]; then
179
+ verdict="verified by change only — password field, contents are not readable"
180
+ else
181
+ verdict="verified by change only — field reads '$now', which is expected for a reformatting input"
182
+ fi ;;
183
+ esac
184
+ fi
185
+
186
+ if [ "$n" -gt 1 ]; then
187
+ verdict="$verdict ($n inputs changed, reporting the first)"
188
+ fi
189
+
190
+ act_report TYPE "${#TEXT} char(s) — $field_type at $rect, $verdict"
191
+ journal_append type "$TEXT" "${field_type-}" "" "" "" 0 0 0 0 "$DT_ACT_HASH"
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # find.sh — resolve a selector and report what it matched.
3
+ #
4
+ # find.sh <selector> [--index N]
5
+ #
6
+ # One line per match, in tree.sh's column order minus the depth:
7
+ #
8
+ # type | id | label | text | x,y,w,h
9
+ #
10
+ # Unlike tap.sh this does not insist on a single match — reading is not acting,
11
+ # and "how many of these are there" is a question worth being able to ask. It
12
+ # still matches exactly, so what it reports is what tap.sh would act on.
13
+ #
14
+ # This is the lookup primitive assertions are built from. Without it the runner
15
+ # would have to re-implement matching, which would put knowledge of the
16
+ # platform's tree above the line that is supposed to hide it.
17
+ #
18
+ # exit 0 at least one match
19
+ # exit 1 usage error
20
+ # exit 3 the device driver is unreachable — run doctor.sh
21
+ # exit 4 no match
22
+
23
+ # SHARED VERB — one copy, reached from scripts/ios/ and scripts/android/ by
24
+ # symlink. The symlink's own directory decides which lib.sh the line below
25
+ # resolves to, which is the whole mechanism.
26
+ #
27
+ # It qualifies as shared because it touches the screen only through the driver
28
+ # primitives (drv_tap, drv_drag, drv_fingerprint*) and the lookup helpers every
29
+ # adapter implements. Nothing here may learn that WebDriverAgent or UiAutomator2
30
+ # exists; the moment a verb needs to, it stops being shared and moves back into
31
+ # the adapter that needs it.
32
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
33
+
34
+ SELECTOR=""
35
+ INDEX=""
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --index)
39
+ [ $# -ge 2 ] || die "--index needs a number" 1
40
+ case "$2" in (*[!0-9]*|"") die "--index needs a non-negative integer, got: $2" 1 ;; esac
41
+ INDEX="$2"; shift 2 ;;
42
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
43
+ -*) die "unknown argument: $1 (usage: find.sh <selector> [--index N])" 1 ;;
44
+ *)
45
+ [ -z "$SELECTOR" ] || die "only one selector is supported, got '$SELECTOR' and '$1'" 1
46
+ SELECTOR="$1"; shift ;;
47
+ esac
48
+ done
49
+ [ -n "$SELECTOR" ] || die "missing selector (usage: find.sh <selector> [--index N])" 1
50
+
51
+ load_config
52
+ parse_selector "$SELECTOR"
53
+
54
+ [ "$SEL_KIND" != xy ] \
55
+ || die "xy selectors cannot be looked up — a point always exists; use id:, label: or text:" 1
56
+
57
+ src="$(fetch_source)"
58
+ # What the screen says right now. Nothing is consulted except the screen.
59
+ matches="$(match_or_holding "$src" "$SEL_KIND" "$SEL_VALUE" || true)"
60
+ resolve_state
61
+
62
+ if [ -z "$matches" ]; then
63
+ note="$(invisible_note "$src" "$SEL_KIND" "$SEL_VALUE" || true)"
64
+ [ -n "$note" ] && die "$SEL_DESC matches nothing that can be acted on — $note" 4
65
+
66
+ cands="$(candidate_elements "$src" "$SEL_VALUE" 5 | join_lines '; ' || true)"
67
+ if [ -z "$cands" ] && [ "$SEL_KIND" = text ]; then
68
+ note="$(holding_absence "$src" "$SEL_VALUE")"
69
+ [ -n "$note" ] && die "no control matched '$SEL_VALUE' — $note" 4
70
+ fi
71
+ # candidate_elements searches identifiers, labels and values, so for a type it
72
+ # has nothing to offer. The types that ARE here is the answer to that question.
73
+ if [ "$SEL_KIND" = kind ]; then
74
+ die "no visible $SEL_VALUE on this screen. What is here: $(kinds_on_screen "$src")" 4
75
+ fi
76
+ [ -z "$cands" ] \
77
+ && die "nothing matches $SEL_DESC and nothing on screen contains it" 4
78
+ die "nothing matches $SEL_DESC — a selector matches whole, and these merely contain it: $cands" 4
79
+ fi
80
+
81
+ if [ -n "$INDEX" ]; then
82
+ n="$(printf '%s\n' "$matches" | wc -l | tr -d ' ')"
83
+ [ "$INDEX" -lt "$n" ] \
84
+ || die "--index $INDEX is out of range: $SEL_DESC matches $n element(s), so the last index is $((n - 1))" 4
85
+ matches="$(printf '%s\n' "$matches" | sed -n "$((INDEX + 1))p")"
86
+ fi
87
+
88
+ printf '%s\n' "$matches" \
89
+ | awk -F'|' '{ printf "%s | %s | %s | %s | %s,%s,%s,%s\n", $5, $6, $7, $8, $1, $2, $3, $4 }'
90
+
91
+ # The note, if there was one, on its own last line. The element lines keep the
92
+ # shape every caller parses — five columns split on " | " — because a note
93
+ # appended to one of them would land in a column and be read as data.
94
+ [ "$RESOLVE_HOW" = holding ] && printf 'resolved by the control holding that text\n'
95
+ exit 0
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # key.sh — put the on-screen keyboard away.
3
+ #
4
+ # key.sh hide
5
+ #
6
+ # This exists because `tap` refuses when the keyboard stands between it and the
7
+ # control, and told people to dismiss it without giving them any way to. An
8
+ # error message that names an action the tool cannot perform is a dead end.
9
+ #
10
+ # It is deliberately not automatic. A keyboard that disappears by itself between
11
+ # steps changes the screen the next step reasons about, and a test that reads
12
+ # `tap { label: Đăng nhập }` should not also mean "and close the keyboard if it
13
+ # is awkward". The step is written down or it does not happen.
14
+ #
15
+ # Verified rather than assumed: the driver answers the dismiss request before
16
+ # the animation has finished, so this waits for the keyboard to actually leave,
17
+ # and fails if it does not — some fields keep it up on purpose.
18
+ #
19
+ # --snapshot prints the screen this left behind and renumbers the uids, so the
20
+ # read that would have been the next call is folded into this one. In a measured
21
+ # session — log in, open Settings — twelve calls did the work and six of them
22
+ # were snapshots taken only to find out what the previous action produced.
23
+ #
24
+ # exit 0 the keyboard is gone, or was never up
25
+ # exit 1 usage error
26
+ # exit 3 the device driver is unreachable — run doctor.sh
27
+ # exit 4 it was asked to go and stayed
28
+
29
+ # SHARED VERB — one copy, reached from scripts/ios/ and scripts/android/ by
30
+ # symlink. The two drivers put a keyboard away differently, and that difference
31
+ # is the whole of what `drv_keyboard_dismiss` hides.
32
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
33
+
34
+ CMD=""
35
+ while [ $# -gt 0 ]; do
36
+ case "$1" in
37
+ hide) [ -z "$CMD" ] || die "key.sh takes one verb (usage: key.sh hide [--snapshot])" 1
38
+ CMD=hide; shift ;;
39
+ --snapshot) act_snapshot_on; shift ;;
40
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
41
+ *) die "unknown verb '$1' (usage: key.sh hide [--snapshot])" 1 ;;
42
+ esac
43
+ done
44
+ [ -n "$CMD" ] || die "missing verb (usage: key.sh hide [--snapshot])" 1
45
+
46
+ load_config
47
+
48
+ up() { [ -n "$(keyboard_rect "$(fetch_source)")" ]; }
49
+
50
+ if ! up; then
51
+ printf 'OK keyboard — already down\n'
52
+ act_snapshot_now
53
+ exit 0
54
+ fi
55
+
56
+ drv_keyboard_dismiss || true
57
+
58
+ n=0
59
+ while [ "$n" -lt 10 ]; do
60
+ up || { printf 'OK keyboard — dismissed\n'; act_snapshot_now; exit 0; }
61
+ n=$((n + 1))
62
+ sleep 0.3
63
+ done
64
+
65
+ die "asked the keyboard to go and it is still up — some fields hold it open; tap elsewhere, or act on a control the keyboard does not cover" 4
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Not a library — a signpost.
3
+ #
4
+ # The verbs in this directory are meant to be reached through the symlinks in
5
+ # scripts/ios/ and scripts/android/, so that `dirname "${BASH_SOURCE[0]}"`
6
+ # resolves to an adapter and picks up that adapter's lib.sh. Running one from
7
+ # here directly finds this file instead.
8
+ #
9
+ # Without it the failure is `scripts/common/lib.sh: No such file or directory`,
10
+ # which says nothing about what the caller did wrong.
11
+
12
+ printf 'scripts/common/ holds shared verb implementations and cannot be run from here — use scripts/%s (it selects the platform from your config)\n' \
13
+ "$(basename "${BASH_SOURCE[1]:-<verb>.sh}")" >&2
14
+ exit 2