summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md56
-rwxr-xr-xbin/android-ui98
-rwxr-xr-xbin/gradle-report110
-rwxr-xr-xbin/publish-apk91
-rwxr-xr-xlib/uinodes.py88
-rwxr-xr-xtest/run-tests.sh99
6 files changed, 542 insertions, 0 deletions
diff --git a/README.md b/README.md
index e3b96c6..a41b312 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,10 @@ Corollaries, each learned the hard way:
| `set -e` + a command that legitimately fails | `pid=$(pidof x)` aborts the whole script when the process is gone — i.e. exactly when you are trying to detect a crash | `|| true` on any substitution whose non-zero status is meaningful data |
| Discarded command output | `adb install ... >/dev/null` hides "filename doesn't end .apk"; the old build stays installed and is silently what gets tested | `android-launch-check --apk` checks the install output for `Success` |
| `monkey` as a launch check | Reports a healthy app dead and a dead app healthy; lands on whatever activity is foreground | `android-launch-check` uses `am start -W` against a resolved activity and samples the pid repeatedly |
+| Stale **analysis** reports | An invalid detekt config aborts the run *before analysing anything*, leaving the previous XML in place. The findings look current and the line numbers never move | `gradle-report` names the rejected config and refuses any report older than the run |
+| Blind `input tap X Y` | Coordinates are a guess about a layout. One tap landed in a different app's settings; the screenshot afterwards was reported as the app under test | `android-ui` verifies the foreground package before acting, and taps by label rather than coordinate |
+| Tapping the first of several matches | Every row in a list carries the same "Play" button; the first match is rarely the one meant | `android-ui tap` refuses when a label matches more than one place |
+| Publishing without verifying | A published APK went stale for eight weeks; installing it threw `A migration from 11 to 6 was required but not found` | `publish-apk` checks the host, proves the file is really an APK, byte-compares what landed, and confirms what the server serves |
---
@@ -86,3 +90,55 @@ Exit codes: `0` launched and stayed up · `1` crashed or died · `2` operator er
12 cases, mostly negative — each drives a script into one failure mode and
asserts it is caught. A wrapper that only ever reports PASS is worse than no
wrapper, so the negative cases are the point.
+
+### `gradle-report <project-dir> <detekt|lint> [-- <gradle args>]`
+
+Runs a static-analysis task and reports from its XML, refusing to speak if the
+report is older than the run.
+
+The failure this exists for is subtle: detekt rejects an invalid config property
+*before* analysing anything, exits non-zero, and leaves the previous run's XML
+untouched. Read that file and you get a plausible list of findings describing
+code that no longer exists — which is exactly what happened three times in one
+session, the giveaway being line numbers that never moved after a refactor.
+
+```
+gradle-report /workspace/listen detekt
+```
+
+Prints `CONFIG INVALID` and names the offending property when the config is
+rejected; prints `STALE` when the report predates the run; otherwise lists the
+findings and passes only on zero.
+
+### `android-ui <package> <dump|tap|shot|foreground> [args]`
+
+Drives an app on a device without guessing at coordinates.
+
+```
+android-ui org.terst.listen foreground # is the right app even in front?
+android-ui org.terst.listen dump # what is on screen, with tap points
+android-ui org.terst.listen tap "Settings" # tap by label, not by coordinate
+android-ui org.terst.listen shot /tmp/x.png # screenshot, foreground verified
+```
+
+Every command verifies the foreground package first. `tap` refuses when a label
+matches more than one element, because in a list where every row has the same
+button the first match is rarely the one meant, and prefers a clickable node
+when a label and its button are separate nodes.
+
+The parsing lives in `lib/uinodes.py` rather than inline in the shell script:
+the first version embedded it as `python3 -c` inside a bash `case` block, and
+the nested quoting silently mangled the regexes — the dump reported one element
+and a tap landed on the wrong one.
+
+### `publish-apk <apk> <published-name> [--host H] [--dir D]`
+
+Publishes a built APK to the static site, checking each thing that has gone
+wrong before: that this is the deploy host and not a box resembling it, that the
+file is genuinely an APK rather than an HTML error page, that what landed is
+byte-identical to what was tested, and that the web server serves those same
+bytes. Backs up any existing file first.
+
+```
+publish-apk app/build/outputs/apk/debug/app-debug.apk listen-debug.apk
+```
diff --git a/bin/android-ui b/bin/android-ui
new file mode 100755
index 0000000..25257f1
--- /dev/null
+++ b/bin/android-ui
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+# android-ui <package> <command> [args]
+#
+# dump list the on-screen text and content-descriptions
+# tap <description> tap the element whose content-desc or text matches, exactly once
+# shot <file> screenshot, but only after confirming <package> is foreground
+# foreground print the foreground package and exit non-zero if it isn't <package>
+#
+# Exists because a blind `adb shell input tap X Y` during this project landed on a completely
+# different app's settings screen, and the screenshot taken straight afterwards was reported as
+# the app under test. Coordinates are guesses about a layout; element descriptions are what the
+# app actually published. Every command here verifies the foreground package first, so a tap can
+# never silently drive something else.
+set -uo pipefail
+
+ADB=${ADB:-/opt/android-sdk/platform-tools/adb}
+LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)"
+die() { echo "android-ui: $1" >&2; exit 2; }
+
+[ -x "$ADB" ] || die "adb not found at $ADB (set ADB=...)"
+[ -x "$LIB/uinodes.py" ] || die "missing $LIB/uinodes.py"
+[ $# -ge 2 ] || die "usage: android-ui <package> <dump|tap|shot|foreground> [args]"
+
+PKG="$1"; CMD="$2"; shift 2
+
+foreground_pkg() {
+ "$ADB" shell dumpsys activity activities 2>/dev/null \
+ | grep -oE 'topResumedActivity=ActivityRecord\{[^}]*' \
+ | head -1 \
+ | grep -oE '[a-zA-Z0-9_.]+/[a-zA-Z0-9_.]+' \
+ | head -1 \
+ | cut -d/ -f1
+}
+
+require_foreground() {
+ local actual
+ actual=$(foreground_pkg)
+ if [ "$actual" != "$PKG" ]; then
+ echo "android-ui: foreground is '${actual:-unknown}', expected '$PKG'" >&2
+ echo " Nothing was done. Bring the app forward first; a tap here would have driven" >&2
+ echo " whatever else is on screen." >&2
+ exit 1
+ fi
+}
+
+dump_xml() {
+ "$ADB" shell uiautomator dump /sdcard/android-ui.xml >/dev/null 2>&1 \
+ || die "uiautomator dump failed"
+ "$ADB" shell cat /sdcard/android-ui.xml 2>/dev/null
+}
+
+case "$CMD" in
+ foreground)
+ actual=$(foreground_pkg)
+ echo "foreground: ${actual:-unknown}"
+ echo "expected : $PKG"
+ [ "$actual" = "$PKG" ] && { echo "result : MATCH"; exit 0; }
+ echo "result : MISMATCH"; exit 1
+ ;;
+
+ dump)
+ require_foreground
+ dump_xml | "$LIB/uinodes.py" list
+ ;;
+
+ tap)
+ [ $# -ge 1 ] || die "tap needs a label"
+ require_foreground
+ TARGET="$1"
+ COORDS=$(dump_xml | "$LIB/uinodes.py" find "$TARGET")
+
+ case "$COORDS" in
+ NONE)
+ echo "android-ui: nothing on screen is labelled '$TARGET'" >&2
+ echo " Run: android-ui $PKG dump" >&2
+ exit 1 ;;
+ AMBIGUOUS*)
+ # Tapping the first of several is how you act on the wrong row.
+ echo "android-ui: '$TARGET' matches ${COORDS#AMBIGUOUS } places; refusing to guess" >&2
+ echo " Run: android-ui $PKG dump" >&2
+ exit 1 ;;
+ esac
+
+ "$ADB" shell input tap $COORDS >/dev/null 2>&1 || die "tap failed"
+ echo "tapped '$TARGET' at ($COORDS)"
+ ;;
+
+ shot)
+ [ $# -ge 1 ] || die "shot needs an output path"
+ require_foreground
+ "$ADB" exec-out screencap -p > "$1" || die "screencap failed"
+ SIZE=$(stat -c%s "$1" 2>/dev/null || echo 0)
+ [ "$SIZE" -gt 1000 ] || die "screenshot is $SIZE bytes; capture failed"
+ echo "captured $1 ($SIZE bytes) with $PKG in the foreground"
+ ;;
+
+ *) die "unknown command '$CMD'" ;;
+esac
diff --git a/bin/gradle-report b/bin/gradle-report
new file mode 100755
index 0000000..c59945a
--- /dev/null
+++ b/bin/gradle-report
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# gradle-report <project-dir> <detekt|lint> -- [gradle args]
+#
+# Runs a static-analysis task and reports from its XML report, refusing to speak if the report
+# is older than the run.
+#
+# Exists because detekt has a failure mode that looks exactly like findings: an invalid config
+# property makes it abort BEFORE analysing anything, leaving the previous run's XML on disk.
+# Three times in one session that stale file was read as the current result, and the line
+# numbers never moved because the analysis never happened. The only honest signal was the exit
+# code, which said "failed" while the report said "here are your 8 findings".
+set -uo pipefail
+
+die() { echo "gradle-report: $1" >&2; exit 2; }
+
+[ $# -ge 2 ] || die "usage: gradle-report <project-dir> <detekt|lint> [-- <gradle args>]"
+
+PROJECT_DIR=$(cd "$1" 2>/dev/null && pwd) || die "no such project directory: $1"
+KIND="$2"
+shift 2
+[ "${1:-}" = "--" ] && shift
+EXTRA=("$@")
+
+case "$KIND" in
+ detekt) TASK=":app:detekt"; REPORT="$PROJECT_DIR/app/build/reports/detekt/detekt.xml" ;;
+ lint) TASK=":app:lintDebug"; REPORT="$PROJECT_DIR/app/build/lint-results-debug.xml" ;;
+ *) die "kind must be 'detekt' or 'lint', got '$KIND'" ;;
+esac
+
+[ -x "$PROJECT_DIR/gradlew" ] || die "no gradlew in $PROJECT_DIR"
+
+# Marker predates the run, so any report not newer than it is stale.
+MARKER=$(mktemp)
+trap 'rm -f "$MARKER"' EXIT
+sleep 1
+
+LOG=$(mktemp)
+( cd "$PROJECT_DIR" && ./gradlew "$TASK" "${EXTRA[@]}" ) >"$LOG" 2>&1
+STATUS=$?
+
+echo "=== gradle-report ($KIND) ==="
+echo "project : $PROJECT_DIR"
+echo "task : $TASK"
+echo "exit : $STATUS"
+
+# An invalid config aborts detekt before it analyses anything. Name it, because the report on
+# disk will be the previous run's and will look perfectly reasonable.
+if grep -qiE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG"; then
+ echo
+ echo "CONFIG INVALID — analysis never ran. Offending properties:"
+ grep -iE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG" | sed 's/^/ /' | sort -u
+ echo
+ echo "result : FAIL (config rejected; any report on disk is from an earlier run)"
+ rm -f "$LOG"
+ exit 1
+fi
+
+if [ ! -f "$REPORT" ]; then
+ echo "report : MISSING ($REPORT)"
+ echo "result : $([ $STATUS -eq 0 ] && echo 'PASS (no report produced, nothing to read)' || echo 'FAIL')"
+ rm -f "$LOG"
+ exit $STATUS
+fi
+
+if [ "$REPORT" -ot "$MARKER" ]; then
+ echo "report : STALE — $REPORT predates this run"
+ echo "result : FAIL — refusing to report numbers that describe older code"
+ rm -f "$LOG"
+ exit 1
+fi
+
+FINDINGS=$(python3 - "$REPORT" "$KIND" <<'PY'
+import sys, xml.etree.ElementTree as ET
+path, kind = sys.argv[1], sys.argv[2]
+try:
+ root = ET.parse(path).getroot()
+except Exception as e:
+ print(f"UNREADABLE {e}")
+ raise SystemExit
+rows = []
+if kind == "detekt":
+ for f in root.iter("file"):
+ for err in f.iter("error"):
+ rows.append(f"{f.get('name','').split('/')[-1]}:{err.get('line')} "
+ f"[{(err.get('source') or '').split('.')[-1]}] {(err.get('message') or '')[:90]}")
+else:
+ for issue in root.iter("issue"):
+ if issue.get("severity", "").lower() not in ("error", "fatal"):
+ continue
+ loc = next(issue.iter("location"), None)
+ where = f"{(loc.get('file','') if loc is not None else '').split('/')[-1]}:{loc.get('line') if loc is not None else '?'}"
+ rows.append(f"{where} [{issue.get('id')}] {(issue.get('message') or '')[:90]}")
+print(f"COUNT {len(rows)}")
+for r in rows:
+ print(f" {r}")
+PY
+)
+
+echo "report : fresh"
+echo "$FINDINGS" | head -1 | sed 's/^COUNT /findings: /'
+echo "$FINDINGS" | tail -n +2
+
+COUNT=$(echo "$FINDINGS" | head -1 | awk '{print $2}')
+if [ "$COUNT" = "0" ] && [ $STATUS -eq 0 ]; then
+ echo "result : PASS (0 findings, gradle exit 0)"
+else
+ echo "result : FAIL (${COUNT:-?} findings, gradle exit $STATUS)"
+fi
+rm -f "$LOG"
+[ $STATUS -eq 0 ] && [ "$COUNT" = "0" ]
diff --git a/bin/publish-apk b/bin/publish-apk
new file mode 100755
index 0000000..91b4705
--- /dev/null
+++ b/bin/publish-apk
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# publish-apk <apk> <published-name> [--host tungsten] [--dir /site/static.terst.org/public/files]
+#
+# Publishes a built APK to the static site, verifying every step that has gone wrong before:
+# the host, that the file is really an APK, that what landed is byte-identical to what was
+# tested, and that the web server actually serves it.
+#
+# Exists because publishing is where a silent mistake is most expensive. A stale published APK
+# once sat for eight weeks while the repo moved on; installing it over current data threw
+# "A migration from 11 to 6 was required but not found" and the app refused to start. The cost
+# wasn't the bug, it was that nothing in the publish step had ever checked.
+set -uo pipefail
+
+die() { echo "publish-apk: $1" >&2; exit 2; }
+
+EXPECT_HOST="tungsten"
+DEST_DIR="/site/static.terst.org/public/files"
+
+[ $# -ge 2 ] || die "usage: publish-apk <apk> <published-name> [--host H] [--dir D]"
+APK="$1"; NAME="$2"; shift 2
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --host) EXPECT_HOST="$2"; shift 2 ;;
+ --dir) DEST_DIR="$2"; shift 2 ;;
+ *) die "unknown option '$1'" ;;
+ esac
+done
+
+case "$NAME" in
+ */*) die "published name must be a bare filename, got '$NAME'" ;;
+ *.apk) ;;
+ *) die "published name should end in .apk, got '$NAME'" ;;
+esac
+
+# Publishing from a box that merely resembles the deploy host is how a previous session
+# "deployed" to a sandbox and had to unwind it.
+ACTUAL_HOST=$(hostname)
+[ "$ACTUAL_HOST" = "$EXPECT_HOST" ] \
+ || die "host is '$ACTUAL_HOST', expected '$EXPECT_HOST'. Build and test here, publish there."
+
+[ -f "$APK" ] || die "no such file: $APK"
+# An .apk is a zip; an HTML error page or a truncated copy is not.
+head -c 2 "$APK" | grep -q "PK" || die "$APK is not a zip archive — not a real APK"
+unzip -l "$APK" 2>/dev/null | grep -q "AndroidManifest.xml" \
+ || die "$APK has no AndroidManifest.xml — not an APK"
+
+[ -d "$DEST_DIR" ] || die "no such directory: $DEST_DIR"
+DEST="$DEST_DIR/$NAME"
+
+echo "=== publish-apk ==="
+echo "host : $ACTUAL_HOST"
+echo "source : $APK ($(stat -c%s "$APK") bytes)"
+echo "dest : $DEST"
+
+if [ -f "$DEST" ]; then
+ BACKUP="$DEST.$(date +%Y%m%d-%H%M%S).bak"
+ cp "$DEST" "$BACKUP" || die "could not back up the existing file"
+ echo "backup : $BACKUP"
+else
+ echo "backup : none needed (new file)"
+fi
+
+cp "$APK" "$DEST" || die "copy failed"
+chmod 644 "$DEST" || die "chmod failed"
+chown root:root "$DEST" 2>/dev/null || true
+
+# The whole point: what is served must be what was tested.
+if cmp -s "$APK" "$DEST"; then
+ echo "identity: published copy is byte-identical to the source"
+else
+ die "published copy differs from the source — do not trust it"
+fi
+
+URL="https://static.terst.org/files/$NAME"
+REMOTE_LEN=$(curl -sI "$URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1)=="content-length:"{print $2}')
+LOCAL_LEN=$(stat -c%s "$DEST")
+
+if [ -z "$REMOTE_LEN" ]; then
+ echo "serving : COULD NOT VERIFY (no content-length from $URL)"
+ echo "result : PARTIAL — the file is in place but the web server did not confirm it"
+ exit 1
+fi
+
+echo "url : $URL"
+echo "serving : $REMOTE_LEN bytes"
+if [ "$REMOTE_LEN" = "$LOCAL_LEN" ]; then
+ echo "result : PASS — served bytes match the tested build"
+else
+ echo "result : FAIL — server reports $REMOTE_LEN, on disk $LOCAL_LEN"
+ exit 1
+fi
diff --git a/lib/uinodes.py b/lib/uinodes.py
new file mode 100755
index 0000000..5e87a3f
--- /dev/null
+++ b/lib/uinodes.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Parse a uiautomator dump into labelled, tappable nodes.
+
+Kept in its own file rather than inlined into the shell script: the first version was a
+python -c embedded in a bash case block, and the nested quoting silently mangled the regexes
+so the dump reported one element and a tap landed on the wrong one.
+
+Usage:
+ uinodes.py list < dump.xml
+ uinodes.py find <label> < dump.xml
+"""
+import re
+import sys
+
+NODE = re.compile(r"<node\b[^>]*>")
+DESC = re.compile(r'content-desc="([^"]*)"')
+TEXT = re.compile(r'text="([^"]*)"')
+BOUNDS = re.compile(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"')
+CLICKABLE = re.compile(r'clickable="true"')
+
+
+def nodes(raw):
+ for match in NODE.findall(raw):
+ bounds = BOUNDS.search(match)
+ if not bounds:
+ continue
+ x1, y1, x2, y2 = map(int, bounds.groups())
+ if x2 <= x1 or y2 <= y1:
+ continue
+ desc = (DESC.search(match) or [None, ""])[1] if DESC.search(match) else ""
+ text = (TEXT.search(match) or [None, ""])[1] if TEXT.search(match) else ""
+ label = desc.strip() or text.strip()
+ if not label:
+ continue
+ yield {
+ "label": label,
+ "kind": "desc" if desc.strip() else "text",
+ "x": (x1 + x2) // 2,
+ "y": (y1 + y2) // 2,
+ "clickable": bool(CLICKABLE.search(match)),
+ }
+
+
+def main():
+ if len(sys.argv) < 2:
+ print("usage: uinodes.py list|find <label>", file=sys.stderr)
+ return 2
+ raw = sys.stdin.read()
+ found = list(nodes(raw))
+
+ if sys.argv[1] == "list":
+ if not found:
+ print(" (no labelled nodes found -- is anything on screen?)")
+ seen = set()
+ for node in found:
+ key = (node["label"], node["x"], node["y"])
+ if key in seen:
+ continue
+ seen.add(key)
+ tap = "tappable" if node["clickable"] else " "
+ print(f" [{node['kind']:4}] {tap} {node['label']!r} @ ({node['x']},{node['y']})")
+ return 0
+
+ if sys.argv[1] == "find":
+ target = sys.argv[2]
+ hits = [n for n in found if n["label"] == target]
+ # Prefer a clickable node: a label and the button carrying it are often separate nodes
+ # at different coordinates, and tapping the label does nothing.
+ clickable = [n for n in hits if n["clickable"]]
+ if clickable:
+ hits = clickable
+ # Collapse nodes that resolve to the same point.
+ unique = {(n["x"], n["y"]) for n in hits}
+ if not unique:
+ print("NONE")
+ elif len(unique) > 1:
+ print(f"AMBIGUOUS {len(unique)}")
+ else:
+ x, y = unique.pop()
+ print(f"{x} {y}")
+ return 0
+
+ print(f"unknown command {sys.argv[1]}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/test/run-tests.sh b/test/run-tests.sh
index 3a8117d..6e8ae43 100755
--- a/test/run-tests.sh
+++ b/test/run-tests.sh
@@ -157,5 +157,104 @@ else
bad "usage check" "rc=$rc out=$out"
fi
+printf '=== gradle-report ===\n'
+
+# 13. A config detekt rejects means the analysis never ran; the XML on disk is a previous
+# run's and looks entirely plausible. This is the trap that was read as real findings
+# three times in one session.
+d="$tmproot/report-badconfig"
+make_project "$d" 1 'echo "> Provided configuration file is invalid: Structure must be from type Map<String,Any>!" >&2'
+mkdir -p "$d/app/build/reports/detekt"
+cat > "$d/app/build/reports/detekt/detekt.xml" <<'XML'
+<?xml version="1.0"?><checkstyle><file name="/x/Old.kt"><error line="1" source="detekt.Stale" message="from an earlier run"/></file></checkstyle>
+XML
+out=$("$BIN/gradle-report" "$d" detekt 2>&1); rc=$?
+if [ $rc -ne 0 ] && printf '%s' "$out" | grep -qi "CONFIG INVALID"; then
+ ok "names an invalid config instead of reading the stale report"
+else
+ bad "invalid config detection" "rc=$rc out=$out"
+fi
+if printf '%s' "$out" | grep -q "Stale"; then
+ bad "stale findings leaked" "the previous run's findings were printed as current"
+else
+ ok "does not print findings from an earlier run"
+fi
+
+# 14. A report older than the run is refused even when the config is fine.
+d="$tmproot/report-stale"
+make_project "$d" 0
+mkdir -p "$d/app/build/reports/detekt"
+cat > "$d/app/build/reports/detekt/detekt.xml" <<'XML'
+<?xml version="1.0"?><checkstyle></checkstyle>
+XML
+touch -d '2020-01-01' "$d/app/build/reports/detekt/detekt.xml"
+out=$("$BIN/gradle-report" "$d" detekt 2>&1); rc=$?
+if [ $rc -ne 0 ] && printf '%s' "$out" | grep -qi "STALE"; then
+ ok "refuses a report that predates the run"
+else
+ bad "stale report detection" "rc=$rc out=$out"
+fi
+
+# 15. A missing project directory is fatal, never a silent fallback to cwd.
+out=$("$BIN/gradle-report" "$tmproot/nope" detekt 2>&1); rc=$?
+if [ $rc -eq 2 ]; then ok "missing project dir is fatal"; else bad "missing dir" "rc=$rc"; fi
+
+printf '=== android-ui ===\n'
+
+# 16. Usage validation without a device.
+out=$("$BIN/android-ui" 2>&1); rc=$?
+if [ $rc -eq 2 ] && printf '%s' "$out" | grep -qi "usage"; then
+ ok "requires package and command"
+else
+ bad "usage check" "rc=$rc out=$out"
+fi
+
+# 17. The node parser is where a blind tap goes wrong, so it is tested directly.
+LIB=$(cd "$BIN/../lib" && pwd)
+dump='<hierarchy><node text="" content-desc="Play" bounds="[0,0][100,100]" clickable="true"/><node text="Settings" content-desc="" bounds="[200,0][300,100]" clickable="true"/></hierarchy>'
+out=$(printf '%s' "$dump" | "$LIB/uinodes.py" find "Play")
+if [ "$out" = "50 50" ]; then ok "finds a node by content-desc"; else bad "find by desc" "got '$out'"; fi
+out=$(printf '%s' "$dump" | "$LIB/uinodes.py" find "Settings")
+if [ "$out" = "250 50" ]; then ok "finds a node by text"; else bad "find by text" "got '$out'"; fi
+out=$(printf '%s' "$dump" | "$LIB/uinodes.py" find "Nope")
+if [ "$out" = "NONE" ]; then ok "reports a missing label"; else bad "missing label" "got '$out'"; fi
+
+# 18. Several matches must refuse rather than tap the first -- that is how you act on the
+# wrong row in a list where every entry has the same button.
+dup='<hierarchy><node content-desc="Play" bounds="[0,0][100,100]" clickable="true"/><node content-desc="Play" bounds="[0,200][100,300]" clickable="true"/></hierarchy>'
+out=$(printf '%s' "$dup" | "$LIB/uinodes.py" find "Play")
+case "$out" in AMBIGUOUS*) ok "refuses to guess between duplicate labels" ;;
+ *) bad "ambiguity" "got '$out'" ;; esac
+
+# 19. A label and the button carrying it are often separate nodes; tapping the label does
+# nothing, so a clickable node wins.
+pair='<hierarchy><node text="Play" bounds="[0,0][100,100]" clickable="false"/><node content-desc="Play" bounds="[500,500][600,600]" clickable="true"/></hierarchy>'
+out=$(printf '%s' "$pair" | "$LIB/uinodes.py" find "Play")
+if [ "$out" = "550 550" ]; then ok "prefers the clickable node"; else bad "clickable preference" "got '$out'"; fi
+
+printf '=== publish-apk ===\n'
+
+# 20. Publishing from the wrong host is how a sandbox once got "deployed" to.
+out=$(HOSTNAME=x "$BIN/publish-apk" /dev/null thing.apk --host definitely-not-this-host 2>&1); rc=$?
+if [ $rc -eq 2 ] && printf '%s' "$out" | grep -qi "expected"; then
+ ok "refuses to publish from the wrong host"
+else
+ bad "host guard" "rc=$rc out=$out"
+fi
+
+# 21. A published name with a path would write outside the intended directory.
+out=$("$BIN/publish-apk" /dev/null ../escape.apk --host "$(hostname)" 2>&1); rc=$?
+if [ $rc -eq 2 ]; then ok "rejects a path in the published name"; else bad "name guard" "rc=$rc"; fi
+
+# 22. An HTML error page saved as .apk is not an APK; publishing it would serve a file that
+# installs as nothing.
+notapk="$tmproot/not.apk"; printf '<!DOCTYPE html><html>nope</html>' > "$notapk"
+out=$("$BIN/publish-apk" "$notapk" thing.apk --host "$(hostname)" 2>&1); rc=$?
+if [ $rc -eq 2 ] && printf '%s' "$out" | grep -qi "not a zip"; then
+ ok "rejects a non-APK"
+else
+ bad "apk validation" "rc=$rc out=$out"
+fi
+
printf '\n=== summary: %d passed, %d failed ===\n' "$pass" "$fail"
[ "$fail" -eq 0 ]