device-devtools-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- package/tests/fixtures/ios-contacts-list.rows +140 -0
|
@@ -0,0 +1,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(/</, "<", s)
|
|
36
|
+
gsub(/>/, ">", s)
|
|
37
|
+
gsub(/"/, "\"", s)
|
|
38
|
+
gsub(/'/, "'", s)
|
|
39
|
+
gsub(/ /, " ", s)
|
|
40
|
+
gsub(/ /, " ", s)
|
|
41
|
+
gsub(/	/, " ", s)
|
|
42
|
+
# Last, and never earlier: decoding & first would turn the literal text
|
|
43
|
+
# "<" into "<". In awk's replacement string "&" means the matched text, so
|
|
44
|
+
# a literal ampersand has to be escaped.
|
|
45
|
+
gsub(/&/, "\\&", 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à & "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
|