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