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,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"
|