summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 19:17:27 +0000
committerClaude <noreply@anthropic.com>2026-09-21 19:17:27 +0000
commit8f94e3caa0b1b477bacbbbd8954f2cea743a9337 (patch)
tree75bcbfdb34235dbdad029a63f695b03fa3cd3df0
parentb51ba30f1c77bcadfbf96b2eb7b4917c20faa7b0 (diff)
Add gradle-report, android-ui and publish-apk
Three more wrappers, each from a failure that actually happened while building Listen rather than an imagined one. gradle-report: detekt rejects an invalid config property before analysing anything, exits non-zero, and leaves the previous run's XML in place. That file was read as current findings three times in one session; the tell was line numbers that never moved after a refactor. It now names the rejected property and refuses any report older than the run. Both real shapes of the message are covered -- a misspelled property and structurally invalid YAML say different things. android-ui: a blind `input tap 400 300` landed in FeeDreader's alarm settings, and the screenshot taken immediately after was reported as Listen. Every command verifies the foreground package before acting, and taps resolve by label rather than coordinate. A label matching several elements refuses rather than taking the first -- in a list where every row has the same Play button, the first match is rarely the one meant. The node parsing is a separate file on purpose. The first version inlined it as python3 -c inside a bash case block and the nested quoting mangled the regexes silently: the dump reported one element and a tap landed somewhere else entirely. Shell-quoted parsers are their own trap. publish-apk: publishing is where a silent mistake costs most -- a stale published APK once sat for eight weeks and installing it threw a Room downgrade. It checks the host, proves the file is a real APK rather than an HTML error page, byte-compares what landed against what was tested, and confirms the server serves those same bytes. Test suite is 25 cases, still weighted toward the negative ones: a wrapper that only ever reports PASS is worse than no wrapper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-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 ]