aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile9
-rwxr-xr-xdocker/scripts/build-app.sh14
-rwxr-xr-xdocker/scripts/ciq-env.sh29
-rwxr-xr-xdocker/scripts/screenshots.sh31
-rw-r--r--test/golden/fenix7/about.pngbin0 -> 3828 bytes
-rw-r--r--test/golden/fenix7/list.pngbin0 -> 4654 bytes
-rw-r--r--test/golden/fenix7/menu.pngbin0 -> 2732 bytes
-rw-r--r--test/golden/fenix7/otp.pngbin0 -> 4714 bytes
-rw-r--r--test/golden/fenix7/otp_hotp.pngbin0 -> 3261 bytes
-rw-r--r--test/golden/fenix7/otp_hotp_next.pngbin0 -> 3143 bytes
-rw-r--r--test/golden/fenix7/otp_locked.pngbin0 -> 4509 bytes
-rw-r--r--test/golden/fenix7/pin.pngbin0 -> 2918 bytes
-rw-r--r--test/golden/fenix7/pin_error.pngbin0 -> 3292 bytes
-rw-r--r--test/golden/fenix7/pin_wait.pngbin0 -> 3279 bytes
-rw-r--r--test/golden/fr255s/about.pngbin0 -> 2532 bytes
-rw-r--r--test/golden/fr255s/list.pngbin0 -> 3160 bytes
-rw-r--r--test/golden/fr255s/menu.pngbin0 -> 1716 bytes
-rw-r--r--test/golden/fr255s/otp.pngbin0 -> 3531 bytes
-rw-r--r--test/golden/fr255s/otp_hotp.pngbin0 -> 2394 bytes
-rw-r--r--test/golden/fr255s/otp_hotp_next.pngbin0 -> 2338 bytes
-rw-r--r--test/golden/fr255s/otp_locked.pngbin0 -> 3374 bytes
-rw-r--r--test/golden/fr255s/pin.pngbin0 -> 1352 bytes
-rw-r--r--test/golden/fr255s/pin_error.pngbin0 -> 1720 bytes
-rw-r--r--test/golden/fr255s/pin_wait.pngbin0 -> 2058 bytes
-rw-r--r--test/golden/fr70/about.pngbin0 -> 15633 bytes
-rw-r--r--test/golden/fr70/list.pngbin0 -> 21003 bytes
-rw-r--r--test/golden/fr70/menu.pngbin0 -> 37900 bytes
-rw-r--r--test/golden/fr70/otp.pngbin0 -> 18705 bytes
-rw-r--r--test/golden/fr70/otp_hotp.pngbin0 -> 13568 bytes
-rw-r--r--test/golden/fr70/otp_hotp_next.pngbin0 -> 13361 bytes
-rw-r--r--test/golden/fr70/otp_locked.pngbin0 -> 17406 bytes
-rw-r--r--test/golden/fr70/pin.pngbin0 -> 8869 bytes
-rw-r--r--test/golden/fr70/pin_error.pngbin0 -> 10833 bytes
-rw-r--r--test/golden/fr70/pin_wait.pngbin0 -> 12737 bytes
-rw-r--r--test/golden/fr965/about.pngbin0 -> 17081 bytes
-rw-r--r--test/golden/fr965/list.pngbin0 -> 21792 bytes
-rw-r--r--test/golden/fr965/menu.pngbin0 -> 42767 bytes
-rw-r--r--test/golden/fr965/otp.pngbin0 -> 23628 bytes
-rw-r--r--test/golden/fr965/otp_hotp.pngbin0 -> 15712 bytes
-rw-r--r--test/golden/fr965/otp_hotp_next.pngbin0 -> 15343 bytes
-rw-r--r--test/golden/fr965/otp_locked.pngbin0 -> 22483 bytes
-rw-r--r--test/golden/fr965/pin.pngbin0 -> 11461 bytes
-rw-r--r--test/golden/fr965/pin_error.pngbin0 -> 13315 bytes
-rw-r--r--test/golden/fr965/pin_wait.pngbin0 -> 15898 bytes
-rw-r--r--test/golden/instinct3amoled45mm/about.pngbin0 -> 13211 bytes
-rw-r--r--test/golden/instinct3amoled45mm/list.pngbin0 -> 15614 bytes
-rw-r--r--test/golden/instinct3amoled45mm/menu.pngbin0 -> 41632 bytes
-rw-r--r--test/golden/instinct3amoled45mm/otp.pngbin0 -> 14674 bytes
-rw-r--r--test/golden/instinct3amoled45mm/otp_hotp.pngbin0 -> 12460 bytes
-rw-r--r--test/golden/instinct3amoled45mm/otp_hotp_next.pngbin0 -> 12478 bytes
-rw-r--r--test/golden/instinct3amoled45mm/otp_locked.pngbin0 -> 14194 bytes
-rw-r--r--test/golden/instinct3amoled45mm/pin.pngbin0 -> 9569 bytes
-rw-r--r--test/golden/instinct3amoled45mm/pin_error.pngbin0 -> 10508 bytes
-rw-r--r--test/golden/instinct3amoled45mm/pin_wait.pngbin0 -> 11459 bytes
-rw-r--r--test/golden/venusq2/list.pngbin0 -> 5160 bytes
-rw-r--r--test/golden/venusq2/menu.pngbin0 -> 3126 bytes
-rw-r--r--test/golden/venusq2/otp.pngbin0 -> 4456 bytes
-rw-r--r--test/golden/venusq2/otp_hotp.pngbin0 -> 4291 bytes
-rw-r--r--test/golden/venusq2/otp_hotp_next.pngbin0 -> 4130 bytes
-rw-r--r--test/golden/venusq2/otp_locked.pngbin0 -> 4337 bytes
-rw-r--r--test/golden/venusq2/pin.pngbin0 -> 2734 bytes
-rw-r--r--test/golden/venusq2/pin_error.pngbin0 -> 3152 bytes
-rw-r--r--test/golden/venusq2/pin_wait.pngbin0 -> 3632 bytes
-rw-r--r--test/golden/venux1/list.pngbin0 -> 26735 bytes
-rw-r--r--test/golden/venux1/menu.pngbin0 -> 49066 bytes
-rw-r--r--test/golden/venux1/otp.pngbin0 -> 16178 bytes
-rw-r--r--test/golden/venux1/otp_hotp.pngbin0 -> 15547 bytes
-rw-r--r--test/golden/venux1/otp_hotp_next.pngbin0 -> 15221 bytes
-rw-r--r--test/golden/venux1/otp_locked.pngbin0 -> 15040 bytes
-rw-r--r--test/golden/venux1/pin.pngbin0 -> 11861 bytes
-rw-r--r--test/golden/venux1/pin_error.pngbin0 -> 13551 bytes
-rw-r--r--test/golden/venux1/pin_wait.pngbin0 -> 15922 bytes
-rw-r--r--test/storage/PinTest.mc13
-rw-r--r--test/ui/UiLogicTest.mc92
-rw-r--r--tools/budgets.json8
-rw-r--r--tools/gen_fixture_wrap.py73
-rw-r--r--tools/tests/test_gen_fixture_wrap.py33
-rw-r--r--tools/tests/test_ui_flow.py73
-rw-r--r--tools/ui_flow.py465
79 files changed, 808 insertions, 32 deletions
diff --git a/Makefile b/Makefile
index 5ffb028..b66408d 100644
--- a/Makefile
+++ b/Makefile
@@ -37,6 +37,9 @@ DEVICE ?= fr255s
# Simulator test devices: weakest round MIP (buttons only), rectangular AMOLED,
# your Forerunner 70, and a large round AMOLED.
TEST_DEVICES ?= fr255s venusq2 fr70 fr965
+# Extra screenshot-only devices (README 14): MIP with touch and buttons,
+# buttons-only AMOLED, the largest rectangle.
+SPOT_DEVICES ?= fenix7 instinct3amoled45mm venux1
# `make lock` only uses package releases published before this moment.
LOCK_CUTOFF ?= 2026-10-01T00:00:00Z
# Set to `nuke` to skip the confirmation prompt of `make nuke`.
@@ -154,11 +157,11 @@ test: test-tools ## Python tool tests + Monkey C unit tests on TEST_DEVICES (hea
test-tools: dirs need-image
$(DEV_OFFLINE) python3 -m pytest -q tools/tests
-screenshots: dirs need-image ## Screenshot the app on TEST_DEVICES and compare with test/golden
- $(DEV_OFFLINE) screenshots.sh $(TEST_DEVICES)
+screenshots: dirs need-image ## Scripted UI flow on TEST_DEVICES + SPOT_DEVICES, every screen compared with test/golden
+ $(DEV_OFFLINE) screenshots.sh $(TEST_DEVICES) $(SPOT_DEVICES)
screenshots-approve: dirs need-image ## Save the current screens as the new golden images (review them first!)
- $(DOCKER) run $(RUN_FLAGS) -e APPROVE=1 --network none $(DEV_IMAGE) screenshots.sh $(TEST_DEVICES)
+ $(DOCKER) run $(RUN_FLAGS) -e APPROVE=1 --network none $(DEV_IMAGE) screenshots.sh $(TEST_DEVICES) $(SPOT_DEVICES)
perf: dirs need-image ## Start-up time on TEST_DEVICES against tools/budgets.json
$(DEV_OFFLINE) app-diag.sh perf $(TEST_DEVICES)
diff --git a/docker/scripts/build-app.sh b/docker/scripts/build-app.sh
index 74dd042..1a7d59a 100755
--- a/docker/scripts/build-app.sh
+++ b/docker/scripts/build-app.sh
@@ -1,20 +1,21 @@
#!/usr/bin/env bash
# Author: cirakg & claude ai
-# Summary: Compile the watch app for one device: debug, release or unit-test build.
+# Summary: Compile the watch app for one device: debug, release, unit-test or UI-test build.
#
-# Usage: build-app.sh debug|release|test DEVICE
+# Usage: build-app.sh debug|release|test|uitest DEVICE
# Output: build/<mode>/<DEVICE>.prg, and the compiler log next to it.
#
# Policy (README section 17.7): strict type checking (-l 3), and any compiler
-# warning fails the build. Test builds add test.jungle, so test code never
+# warning fails the build. UI-test builds add uitest.jungle (fixed clock,
+# fake entries; see source/util/TestMode.mc). Test builds add test.jungle, so test code never
# reaches debug or release builds; release builds (-r) also drop every
# (:debug) declaration, such as the diagnostics in source/util/Diag.mc.
set -euo pipefail
# shellcheck source=ciq-env.sh
source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
-mode="${1:?usage: build-app.sh debug|release|test DEVICE}"
-device="${2:?usage: build-app.sh debug|release|test DEVICE}"
+mode="${1:?usage: build-app.sh debug|release|test|uitest DEVICE}"
+device="${2:?usage: build-app.sh debug|release|test|uitest DEVICE}"
ciq_require_sdk
ciq_require_key
@@ -27,11 +28,12 @@ args=(-d "${device}" -y "${DEV_KEY}" -l 3 -w)
case "${mode}" in
debug) ;;
release) args+=(-r) ;;
+ uitest) jungles="${jungles};/work/uitest.jungle" ;;
test)
jungles="${jungles};/work/test.jungle"
args+=(-t)
;;
- *) die "unknown build mode '${mode}' (use debug, release or test)" ;;
+ *) die "unknown build mode '${mode}' (use debug, release, test or uitest)" ;;
esac
out_dir="${BUILD_DIR}/${mode}"
diff --git a/docker/scripts/ciq-env.sh b/docker/scripts/ciq-env.sh
index a52db78..6216321 100755
--- a/docker/scripts/ciq-env.sh
+++ b/docker/scripts/ciq-env.sh
@@ -87,6 +87,9 @@ ciq_start_simulator() {
# often reuses that process ID for something else, and the simulator then
# believes it is already running and never opens its port. Each container
# runs exactly one simulator, so the stale lock is safe to remove.
+ # A simulator this container already started is reused: a second one
+ # would open a second window, and input could go to the wrong one.
+ ciq_simulator_listening && return 0
rm -f "${HOME}"/Sim-*
simulator >/tmp/simulator.log 2>&1 &
local pid=$! _
@@ -109,19 +112,37 @@ ciq_simulator_window() {
# Longest wait for an app's first frame after launching it.
FIRST_FRAME_TIMEOUT_S=60
-# Launch the debug build of the app on DEVICE and wait for its first frame,
+# Launch the app on DEVICE (the debug build, or MODE's build: uitest) and
+# wait for its first frame,
# which the app reports with a "DIAG first_frame" console line. The console
# goes to LOG, followed by a "HARNESS first_frame wall_ms=N" line: the
# wall-clock time from launch to first frame, including the JVM start of
# monkeydo and the program load the app can't time itself. The app keeps
# running (for screenshots) until ciq_stop_app or the next launch.
-# Returns 1 if no first frame appeared in time.
+# Apps with a glance start in it; the glance is opened with START or a tap,
+# so the wall time includes that. Returns 1 if no first frame appeared in time.
ciq_launch_app() {
- local device="$1" app_log="$2" start _
+ local device="$1" app_log="$2" mode="${3:-debug}" start _
start="$(date +%s%3N)"
- monkeydo "${BUILD_DIR}/debug/${device}.prg" "${device}" >"${app_log}" 2>&1 &
+ # Both monkeydo and the HARNESS line below append, so neither overwrites
+ # the other's output.
+ : >"${app_log}"
+ monkeydo "${BUILD_DIR}/${mode}/${device}.prg" "${device}" >>"${app_log}" 2>&1 &
CIQ_MONKEYDO_PID=$!
+ local presses=0 glance_seen_at=0 tick=0
for _ in $(seq 1 $((FIRST_FRAME_TIMEOUT_S * 20))); do
+ tick=$((tick + 1))
+ # The simulator starts apps that have a glance in the glance. Open the
+ # app from it, as a user would: 2 s after the glance appears (earlier
+ # presses get lost), again every 8 s if the app hasn't opened.
+ if ((glance_seen_at == 0)) && grep -q "DIAG shown glance" "${app_log}"; then
+ glance_seen_at=${tick}
+ fi
+ if ((glance_seen_at > 0 && presses < 3 && tick - glance_seen_at == 40 + presses * 160)); then
+ presses=$((presses + 1))
+ python3 /work/tools/ui_flow.py --open-from-glance --device-dir "${CIQ_HOME}/Devices/${device}" \
+ --window "$(ciq_simulator_window)" --out "${BUILD_DIR}/ui-input" >&2 || return 1
+ fi
if grep -q "DIAG first_frame" "${app_log}"; then
echo "HARNESS first_frame wall_ms=$(($(date +%s%3N) - start))" >>"${app_log}"
return 0
diff --git a/docker/scripts/screenshots.sh b/docker/scripts/screenshots.sh
index 3fc52f5..414af52 100755
--- a/docker/scripts/screenshots.sh
+++ b/docker/scripts/screenshots.sh
@@ -1,23 +1,25 @@
#!/usr/bin/env bash
# Author: cirakg & claude ai
-# Summary: Screenshot the app on each device in the headless simulator and compare with the approved golden images.
+# Summary: Run the scripted UI flow on each device in the headless simulator and compare every screen with its golden image.
#
# Usage: screenshots.sh DEVICE [DEVICE ...] (APPROVE=1 saves new goldens)
-# Output: build/screenshots/<DEVICE>.png (the watch screen only), plus a
-# <DEVICE>-diff.png when it differs from test/golden/<DEVICE>.png.
+# Output: build/screenshots/<DEVICE>/<screen>.png (the watch screen only),
+# plus <screen>-diff.png where a screen differs from
+# test/golden/<DEVICE>/<screen>.png.
+#
+# Uses the UI-test build (uitest.jungle): fake entries, PIN 123456 and a
+# frozen clock, so every screenshot is reproducible. tools/ui_flow.py
+# drives the app and checks the screens.
set -euo pipefail
# shellcheck source=ciq-env.sh
source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
-# Pause after the first frame, so animations and late redraws settle.
-readonly SETTLE_S=1
-
(($# > 0)) || die "usage: screenshots.sh DEVICE [DEVICE ...]"
ciq_require_sdk
ciq_require_key
for device in "$@"; do
- build-app.sh debug "${device}"
+ build-app.sh uitest "${device}"
done
ciq_start_display
@@ -29,19 +31,18 @@ approve=()
[[ "${APPROVE:-0}" == 1 ]] && approve=(--approve)
failed=0
for device in "$@"; do
- if ! ciq_launch_app "${device}" "${out}/${device}.log"; then
+ mkdir -p "${out}/${device}"
+ log_file="${out}/${device}/app.log"
+ if ! ciq_launch_app "${device}" "${log_file}" uitest; then
ciq_stop_app
- log "no first frame on ${device}; see build/screenshots/${device}.log"
+ log "no first frame on ${device}; see ${log_file}"
failed=1
continue
fi
- sleep "${SETTLE_S}"
- mkdir -p "${out}/raw"
- import -window "$(ciq_simulator_window)" "${out}/raw/${device}-window.png"
+ python3 /work/tools/ui_flow.py --device-dir "${CIQ_HOME}/Devices/${device}" --log "${log_file}" \
+ --window "$(ciq_simulator_window)" --out "${out}/${device}" --golden "/work/test/golden/${device}" \
+ "${approve[@]}" || failed=1
ciq_stop_app
- python3 /work/tools/sim_screenshot.py --raw "${out}/raw/${device}-window.png" \
- --device-dir "${CIQ_HOME}/Devices/${device}" --out "${out}/${device}.png" \
- --golden "/work/test/golden/${device}.png" "${approve[@]}" || failed=1
done
((failed == 0)) || die "screenshot checks failed; see build/screenshots/"
diff --git a/test/golden/fenix7/about.png b/test/golden/fenix7/about.png
new file mode 100644
index 0000000..d6a87a2
--- /dev/null
+++ b/test/golden/fenix7/about.png
Binary files differ
diff --git a/test/golden/fenix7/list.png b/test/golden/fenix7/list.png
new file mode 100644
index 0000000..ffcc3ff
--- /dev/null
+++ b/test/golden/fenix7/list.png
Binary files differ
diff --git a/test/golden/fenix7/menu.png b/test/golden/fenix7/menu.png
new file mode 100644
index 0000000..3ceb597
--- /dev/null
+++ b/test/golden/fenix7/menu.png
Binary files differ
diff --git a/test/golden/fenix7/otp.png b/test/golden/fenix7/otp.png
new file mode 100644
index 0000000..8ef0cad
--- /dev/null
+++ b/test/golden/fenix7/otp.png
Binary files differ
diff --git a/test/golden/fenix7/otp_hotp.png b/test/golden/fenix7/otp_hotp.png
new file mode 100644
index 0000000..a325345
--- /dev/null
+++ b/test/golden/fenix7/otp_hotp.png
Binary files differ
diff --git a/test/golden/fenix7/otp_hotp_next.png b/test/golden/fenix7/otp_hotp_next.png
new file mode 100644
index 0000000..a9ff510
--- /dev/null
+++ b/test/golden/fenix7/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/fenix7/otp_locked.png b/test/golden/fenix7/otp_locked.png
new file mode 100644
index 0000000..ef29840
--- /dev/null
+++ b/test/golden/fenix7/otp_locked.png
Binary files differ
diff --git a/test/golden/fenix7/pin.png b/test/golden/fenix7/pin.png
new file mode 100644
index 0000000..d1fe80b
--- /dev/null
+++ b/test/golden/fenix7/pin.png
Binary files differ
diff --git a/test/golden/fenix7/pin_error.png b/test/golden/fenix7/pin_error.png
new file mode 100644
index 0000000..48541c8
--- /dev/null
+++ b/test/golden/fenix7/pin_error.png
Binary files differ
diff --git a/test/golden/fenix7/pin_wait.png b/test/golden/fenix7/pin_wait.png
new file mode 100644
index 0000000..77cce98
--- /dev/null
+++ b/test/golden/fenix7/pin_wait.png
Binary files differ
diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png
new file mode 100644
index 0000000..f8f23d2
--- /dev/null
+++ b/test/golden/fr255s/about.png
Binary files differ
diff --git a/test/golden/fr255s/list.png b/test/golden/fr255s/list.png
new file mode 100644
index 0000000..df30245
--- /dev/null
+++ b/test/golden/fr255s/list.png
Binary files differ
diff --git a/test/golden/fr255s/menu.png b/test/golden/fr255s/menu.png
new file mode 100644
index 0000000..047504c
--- /dev/null
+++ b/test/golden/fr255s/menu.png
Binary files differ
diff --git a/test/golden/fr255s/otp.png b/test/golden/fr255s/otp.png
new file mode 100644
index 0000000..f1637d7
--- /dev/null
+++ b/test/golden/fr255s/otp.png
Binary files differ
diff --git a/test/golden/fr255s/otp_hotp.png b/test/golden/fr255s/otp_hotp.png
new file mode 100644
index 0000000..fb4fb2e
--- /dev/null
+++ b/test/golden/fr255s/otp_hotp.png
Binary files differ
diff --git a/test/golden/fr255s/otp_hotp_next.png b/test/golden/fr255s/otp_hotp_next.png
new file mode 100644
index 0000000..2c36aaf
--- /dev/null
+++ b/test/golden/fr255s/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/fr255s/otp_locked.png b/test/golden/fr255s/otp_locked.png
new file mode 100644
index 0000000..6b185ad
--- /dev/null
+++ b/test/golden/fr255s/otp_locked.png
Binary files differ
diff --git a/test/golden/fr255s/pin.png b/test/golden/fr255s/pin.png
new file mode 100644
index 0000000..bb3e989
--- /dev/null
+++ b/test/golden/fr255s/pin.png
Binary files differ
diff --git a/test/golden/fr255s/pin_error.png b/test/golden/fr255s/pin_error.png
new file mode 100644
index 0000000..b580338
--- /dev/null
+++ b/test/golden/fr255s/pin_error.png
Binary files differ
diff --git a/test/golden/fr255s/pin_wait.png b/test/golden/fr255s/pin_wait.png
new file mode 100644
index 0000000..cc1e27b
--- /dev/null
+++ b/test/golden/fr255s/pin_wait.png
Binary files differ
diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png
new file mode 100644
index 0000000..c46fa11
--- /dev/null
+++ b/test/golden/fr70/about.png
Binary files differ
diff --git a/test/golden/fr70/list.png b/test/golden/fr70/list.png
new file mode 100644
index 0000000..eaa657e
--- /dev/null
+++ b/test/golden/fr70/list.png
Binary files differ
diff --git a/test/golden/fr70/menu.png b/test/golden/fr70/menu.png
new file mode 100644
index 0000000..b35e078
--- /dev/null
+++ b/test/golden/fr70/menu.png
Binary files differ
diff --git a/test/golden/fr70/otp.png b/test/golden/fr70/otp.png
new file mode 100644
index 0000000..af4f2d4
--- /dev/null
+++ b/test/golden/fr70/otp.png
Binary files differ
diff --git a/test/golden/fr70/otp_hotp.png b/test/golden/fr70/otp_hotp.png
new file mode 100644
index 0000000..7e341f2
--- /dev/null
+++ b/test/golden/fr70/otp_hotp.png
Binary files differ
diff --git a/test/golden/fr70/otp_hotp_next.png b/test/golden/fr70/otp_hotp_next.png
new file mode 100644
index 0000000..75e8f2a
--- /dev/null
+++ b/test/golden/fr70/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/fr70/otp_locked.png b/test/golden/fr70/otp_locked.png
new file mode 100644
index 0000000..8730813
--- /dev/null
+++ b/test/golden/fr70/otp_locked.png
Binary files differ
diff --git a/test/golden/fr70/pin.png b/test/golden/fr70/pin.png
new file mode 100644
index 0000000..71f6579
--- /dev/null
+++ b/test/golden/fr70/pin.png
Binary files differ
diff --git a/test/golden/fr70/pin_error.png b/test/golden/fr70/pin_error.png
new file mode 100644
index 0000000..9a3de60
--- /dev/null
+++ b/test/golden/fr70/pin_error.png
Binary files differ
diff --git a/test/golden/fr70/pin_wait.png b/test/golden/fr70/pin_wait.png
new file mode 100644
index 0000000..ed8132e
--- /dev/null
+++ b/test/golden/fr70/pin_wait.png
Binary files differ
diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png
new file mode 100644
index 0000000..61b9745
--- /dev/null
+++ b/test/golden/fr965/about.png
Binary files differ
diff --git a/test/golden/fr965/list.png b/test/golden/fr965/list.png
new file mode 100644
index 0000000..d0904b2
--- /dev/null
+++ b/test/golden/fr965/list.png
Binary files differ
diff --git a/test/golden/fr965/menu.png b/test/golden/fr965/menu.png
new file mode 100644
index 0000000..ba1149d
--- /dev/null
+++ b/test/golden/fr965/menu.png
Binary files differ
diff --git a/test/golden/fr965/otp.png b/test/golden/fr965/otp.png
new file mode 100644
index 0000000..111001b
--- /dev/null
+++ b/test/golden/fr965/otp.png
Binary files differ
diff --git a/test/golden/fr965/otp_hotp.png b/test/golden/fr965/otp_hotp.png
new file mode 100644
index 0000000..fbcc1a4
--- /dev/null
+++ b/test/golden/fr965/otp_hotp.png
Binary files differ
diff --git a/test/golden/fr965/otp_hotp_next.png b/test/golden/fr965/otp_hotp_next.png
new file mode 100644
index 0000000..181bc37
--- /dev/null
+++ b/test/golden/fr965/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/fr965/otp_locked.png b/test/golden/fr965/otp_locked.png
new file mode 100644
index 0000000..537dc0c
--- /dev/null
+++ b/test/golden/fr965/otp_locked.png
Binary files differ
diff --git a/test/golden/fr965/pin.png b/test/golden/fr965/pin.png
new file mode 100644
index 0000000..06cab65
--- /dev/null
+++ b/test/golden/fr965/pin.png
Binary files differ
diff --git a/test/golden/fr965/pin_error.png b/test/golden/fr965/pin_error.png
new file mode 100644
index 0000000..c187ada
--- /dev/null
+++ b/test/golden/fr965/pin_error.png
Binary files differ
diff --git a/test/golden/fr965/pin_wait.png b/test/golden/fr965/pin_wait.png
new file mode 100644
index 0000000..e381d23
--- /dev/null
+++ b/test/golden/fr965/pin_wait.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png
new file mode 100644
index 0000000..1400601
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/about.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/list.png b/test/golden/instinct3amoled45mm/list.png
new file mode 100644
index 0000000..d4b9323
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/list.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/menu.png b/test/golden/instinct3amoled45mm/menu.png
new file mode 100644
index 0000000..6c6f81e
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/menu.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/otp.png b/test/golden/instinct3amoled45mm/otp.png
new file mode 100644
index 0000000..0b780ae
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/otp.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/otp_hotp.png b/test/golden/instinct3amoled45mm/otp_hotp.png
new file mode 100644
index 0000000..def95cf
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/otp_hotp.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/otp_hotp_next.png b/test/golden/instinct3amoled45mm/otp_hotp_next.png
new file mode 100644
index 0000000..9608c2c
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/otp_locked.png b/test/golden/instinct3amoled45mm/otp_locked.png
new file mode 100644
index 0000000..0ef7e4b
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/otp_locked.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/pin.png b/test/golden/instinct3amoled45mm/pin.png
new file mode 100644
index 0000000..92b60d5
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/pin.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/pin_error.png b/test/golden/instinct3amoled45mm/pin_error.png
new file mode 100644
index 0000000..9d330b8
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/pin_error.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/pin_wait.png b/test/golden/instinct3amoled45mm/pin_wait.png
new file mode 100644
index 0000000..39eae8d
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/pin_wait.png
Binary files differ
diff --git a/test/golden/venusq2/list.png b/test/golden/venusq2/list.png
new file mode 100644
index 0000000..f778d10
--- /dev/null
+++ b/test/golden/venusq2/list.png
Binary files differ
diff --git a/test/golden/venusq2/menu.png b/test/golden/venusq2/menu.png
new file mode 100644
index 0000000..6393819
--- /dev/null
+++ b/test/golden/venusq2/menu.png
Binary files differ
diff --git a/test/golden/venusq2/otp.png b/test/golden/venusq2/otp.png
new file mode 100644
index 0000000..3948080
--- /dev/null
+++ b/test/golden/venusq2/otp.png
Binary files differ
diff --git a/test/golden/venusq2/otp_hotp.png b/test/golden/venusq2/otp_hotp.png
new file mode 100644
index 0000000..2331618
--- /dev/null
+++ b/test/golden/venusq2/otp_hotp.png
Binary files differ
diff --git a/test/golden/venusq2/otp_hotp_next.png b/test/golden/venusq2/otp_hotp_next.png
new file mode 100644
index 0000000..564df92
--- /dev/null
+++ b/test/golden/venusq2/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/venusq2/otp_locked.png b/test/golden/venusq2/otp_locked.png
new file mode 100644
index 0000000..9181d66
--- /dev/null
+++ b/test/golden/venusq2/otp_locked.png
Binary files differ
diff --git a/test/golden/venusq2/pin.png b/test/golden/venusq2/pin.png
new file mode 100644
index 0000000..9b51943
--- /dev/null
+++ b/test/golden/venusq2/pin.png
Binary files differ
diff --git a/test/golden/venusq2/pin_error.png b/test/golden/venusq2/pin_error.png
new file mode 100644
index 0000000..80a803e
--- /dev/null
+++ b/test/golden/venusq2/pin_error.png
Binary files differ
diff --git a/test/golden/venusq2/pin_wait.png b/test/golden/venusq2/pin_wait.png
new file mode 100644
index 0000000..618e517
--- /dev/null
+++ b/test/golden/venusq2/pin_wait.png
Binary files differ
diff --git a/test/golden/venux1/list.png b/test/golden/venux1/list.png
new file mode 100644
index 0000000..8ba6f1f
--- /dev/null
+++ b/test/golden/venux1/list.png
Binary files differ
diff --git a/test/golden/venux1/menu.png b/test/golden/venux1/menu.png
new file mode 100644
index 0000000..6930419
--- /dev/null
+++ b/test/golden/venux1/menu.png
Binary files differ
diff --git a/test/golden/venux1/otp.png b/test/golden/venux1/otp.png
new file mode 100644
index 0000000..52d83df
--- /dev/null
+++ b/test/golden/venux1/otp.png
Binary files differ
diff --git a/test/golden/venux1/otp_hotp.png b/test/golden/venux1/otp_hotp.png
new file mode 100644
index 0000000..e6b8c6c
--- /dev/null
+++ b/test/golden/venux1/otp_hotp.png
Binary files differ
diff --git a/test/golden/venux1/otp_hotp_next.png b/test/golden/venux1/otp_hotp_next.png
new file mode 100644
index 0000000..7ffbdf5
--- /dev/null
+++ b/test/golden/venux1/otp_hotp_next.png
Binary files differ
diff --git a/test/golden/venux1/otp_locked.png b/test/golden/venux1/otp_locked.png
new file mode 100644
index 0000000..5564888
--- /dev/null
+++ b/test/golden/venux1/otp_locked.png
Binary files differ
diff --git a/test/golden/venux1/pin.png b/test/golden/venux1/pin.png
new file mode 100644
index 0000000..6263938
--- /dev/null
+++ b/test/golden/venux1/pin.png
Binary files differ
diff --git a/test/golden/venux1/pin_error.png b/test/golden/venux1/pin_error.png
new file mode 100644
index 0000000..ff2e723
--- /dev/null
+++ b/test/golden/venux1/pin_error.png
Binary files differ
diff --git a/test/golden/venux1/pin_wait.png b/test/golden/venux1/pin_wait.png
new file mode 100644
index 0000000..c91126f
--- /dev/null
+++ b/test/golden/venux1/pin_wait.png
Binary files differ
diff --git a/test/storage/PinTest.mc b/test/storage/PinTest.mc
index 7dc677d..0212936 100644
--- a/test/storage/PinTest.mc
+++ b/test/storage/PinTest.mc
@@ -248,3 +248,16 @@ function pinKeyBuffersAreWiped(logger as Test.Logger) as Boolean {
keys.wipe();
return derived && wiped && StorageFixtures.allZero(keys.encKey) && StorageFixtures.allZero(keys.macKey);
}
+
+// The UI-test fixture's wrap was computed in Python (tools/gen_fixture_wrap.py);
+// the app's unlock code must accept it with the fixture PIN and recover the
+// fixture master key, which cross-checks the wrap format.
+(:test)
+function pinOpensThePythonComputedFixtureWrap(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ store.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(Fixtures.FIXTURE_WRAP));
+ var vault = new Vault(store, new FakeTime());
+ var wrongRejected = !StorageFixtures.unlock(vault, StorageFixtures.OTHER_PIN);
+ var opened = StorageFixtures.unlock(vault, Fixtures.FIXTURE_PIN);
+ return wrongRejected && opened && Bytes.equal(vault.session.masterKey(), Bytes.fromHex(Fixtures.FIXTURE_MASTER_KEY));
+}
diff --git a/test/ui/UiLogicTest.mc b/test/ui/UiLogicTest.mc
new file mode 100644
index 0000000..01b7dae
--- /dev/null
+++ b/test/ui/UiLogicTest.mc
@@ -0,0 +1,92 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the UI's logic without a screen: the code cache, code grouping, text helpers and option defaults.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function codeCacheSplitsCodesForReading(logger as Test.Logger) as Boolean {
+ var cases = [["123456", "123|456"], ["1234567", "123|4567"], ["12345678", "1234|5678"], ["R7VXK", "R7VXK"]];
+ for (var i = 0; i < cases.size(); i++) {
+ var c = cases[i] as Array<String>;
+ var parts = CodeCache.split(c[0]);
+ var joined = parts.size() == 2 ? parts[0] + "|" + parts[1] : parts[0];
+ if (!TestSupport.sameString(logger, "split " + c[0], joined, c[1])) {
+ return false;
+ }
+ }
+ return true;
+}
+
+// The cache gives the same codes as the OTP module, keeps them until the
+// period ends, and learns the period and cost from the encrypted part.
+(:test)
+function codeCacheComputesAndExpiresCodes(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var e = StorageFixtures.totp("Cache");
+ var secret = Bytes.copy(e.secret as ByteArray);
+ var id = vault.add(e);
+ var cache = new CodeCache(vault);
+ var now = 1800000026l; // 34 s left in the fixture's 60 s period
+ var expected = Otp.totp(Hashes.SHA256, secret, now, 60, 8);
+ var next = Otp.totp(Hashes.SHA256, secret, now + 60, 60, 8);
+ var unknownCost = cache.costOf(e);
+ var code = cache.code(id, now, 0);
+ var stillCached = cache.cached(id, now + 33) != null;
+ var expired = cache.cached(id, now + 34) == null;
+ return TestSupport.sameString(logger, "current", code, expected)
+ && TestSupport.sameString(logger, "next", cache.code(id, now, 1), next)
+ && stillCached && expired && cache.periodOf(id) == 60
+ && unknownCost == CodeCache.COST_SHA512 && cache.costOf(e) == CodeCache.COST_NATIVE;
+}
+
+(:test)
+function codeCacheHotpAndInvalidate(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ var id = vault.add(StorageFixtures.hotp("Counter", 5l));
+ var cache = new CodeCache(vault);
+ var first = cache.code(id, 0l, 0);
+ vault.setCounter(id, 6l);
+ var stale = cache.code(id, 0l, 0);
+ cache.invalidate(id);
+ var fresh = cache.code(id, 0l, 0);
+ // RFC 4226 Appendix D: counters 5 and 6.
+ return first.equals("254676") && stale.equals("254676") && fresh.equals("287922");
+}
+
+(:test)
+function codeCacheRefusesLockedEntriesWhileLocked(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ var e = StorageFixtures.totp("Locked");
+ e.locked = true;
+ var id = vault.add(e);
+ var cache = new CodeCache(vault);
+ var unlockedCode = cache.code(id, 1800000026l, 0);
+ vault.lock();
+ cache.clear();
+ var code = 0;
+ try {
+ cache.code(id, 1800000026l, 0);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ return unlockedCode.length() == 8 && code == VaultError.LOCKED && cache.cached(id, 1800000026l) == null;
+}
+
+(:test)
+function uiTextHelpers(logger as Test.Logger) as Boolean {
+ var parts = Ui.split("a b c", " ");
+ return Ui.isDigits("123 456") && !Ui.isDigits("12a") && !Ui.isDigits("") && !Ui.isDigits("------")
+ && parts.size() == 4 && parts[0].equals("a") && parts[2].equals("") && parts[3].equals("c")
+ && Ui.minutesSeconds(90).equals("1:30") && Ui.minutesSeconds(3600).equals("60:00") && Ui.minutesSeconds(5).equals("0:05");
+}
+
+// resources/settings/properties.xml defaults give README 10.2's defaults.
+(:test)
+function optionsDefaults(logger as Test.Logger) as Boolean {
+ var o = new Options();
+ o.load();
+ return o.autoLockMs == 60000 && !o.eraseAfterFailures && o.timeOffset == 0 && o.codeSize == Options.CODE_LARGE
+ && o.theme == Options.THEME_DARK && o.showGroups && !o.encryptNames && !o.glanceCode && o.fullBrightness;
+}
diff --git a/tools/budgets.json b/tools/budgets.json
index 93e9a2b..ba7432d 100644
--- a/tools/budgets.json
+++ b/tools/budgets.json
@@ -1,12 +1,12 @@
{
"_author": "cirakg & claude ai",
"_summary": "Simulator budgets checked by `make perf` and `make memtest` (README section 18).",
- "_about": "Simulator timings depend on your PC and include the JVM start of monkeydo, so perf budgets only catch big regressions; from M4 on they are set to about 2x the measured baseline. Memory is a hard limit: at most 70 % of the watch-app memory of the device.",
+ "_about": "Simulator timings depend on your PC. startup_ms (in-app, object creation to first full frame) measured 1-18 ms in M4; its budget leaves room for simulator jitter. first_frame_wall_ms includes the JVM start of monkeydo, loading the glance, the 2 s pause and the tap that opens the app (16-18 s in M4), so it only catches big regressions; set to about 2x. Memory is a hard limit: at most 70 % of the watch-app memory of the device.",
"perf": {
- "startup_ms": 3000,
- "first_frame_wall_ms": 15000
+ "startup_ms": 100,
+ "first_frame_wall_ms": 36000
},
"mem": {
- "mem_used_fraction": 0.70
+ "mem_used_fraction": 0.7
}
}
diff --git a/tools/gen_fixture_wrap.py b/tools/gen_fixture_wrap.py
new file mode 100644
index 0000000..c389fea
--- /dev/null
+++ b/tools/gen_fixture_wrap.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+# Author: cirakg & claude ai
+# Summary: Compute the UI-test fixture's PIN wrap (fake PIN 123456) so the watch needn't run PBKDF2 at start-up.
+"""Print the fake PIN wrap that source/app/Fixtures.mc stores in the UI-test build.
+
+The wrap follows docs/SECURITY.md ("PIN wrap, version 1") with fixed, fake
+inputs: PIN 123456, salt 10..1f, IV 20..2f, master key 40..5f, 2000
+PBKDF2 iterations. Deriving the PIN key on the watch in one event trips the
+watchdog, and the UI tests need the store ready before the first screen, so
+the fixture stores this precomputed value instead. The app's own unlock code
+then checks it like any other wrap, which also cross-checks the format.
+
+Usage: gen_fixture_wrap.py (prints FIXTURE_WRAP and FIXTURE_MASTER_KEY in hex)
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import struct
+import subprocess
+
+PIN = b"123456"
+ITERATIONS = 2000
+SALT = bytes(range(0x10, 0x20))
+IV = bytes(range(0x20, 0x30))
+MASTER_KEY = bytes(range(0x40, 0x60))
+
+
+def hkdf(ikm: bytes, info: bytes, length: int = 32) -> bytes:
+ """HKDF-SHA256 with an empty salt (RFC 5869), as in source/crypto/Kdf.mc."""
+ prk = hmac.new(bytes(32), ikm, hashlib.sha256).digest()
+ out, block, counter = b"", b"", 1
+ while len(out) < length:
+ block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
+ out += block
+ counter += 1
+ return out[:length]
+
+
+def aes_cbc_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes:
+ """AES-256-CBC with PKCS#7 padding, through openssl (not in the standard library)."""
+ result = subprocess.run(
+ ["openssl", "enc", "-aes-256-cbc", "-K", key.hex(), "-iv", iv.hex()],
+ input=plaintext,
+ capture_output=True,
+ check=True,
+ )
+ return result.stdout
+
+
+def sealed_box(enc_key: bytes, mac_key: bytes, aad: bytes, plaintext: bytes) -> bytes:
+ """source/crypto/SealedBox.mc: 0x01 | IV | ciphertext | HMAC tag."""
+ body = b"\x01" + IV + aes_cbc_encrypt(enc_key, IV, plaintext)
+ tag = hmac.new(mac_key, b"OTPW-REC-1" + struct.pack(">I", len(aad)) + aad + body, hashlib.sha256).digest()
+ return body + tag
+
+
+def fixture_wrap() -> bytes:
+ pin_key = hashlib.pbkdf2_hmac("sha256", PIN, SALT, ITERATIONS, 32)
+ enc_key, mac_key = hkdf(pin_key, b"OTPW enc v1"), hkdf(pin_key, b"OTPW mac v1")
+ count = struct.pack(">I", ITERATIONS)
+ aad = b"OTPW-WRAP-1" + SALT + count
+ return SALT + count + sealed_box(enc_key, mac_key, aad, MASTER_KEY)
+
+
+def main() -> None:
+ print("FIXTURE_WRAP =", fixture_wrap().hex())
+ print("FIXTURE_MASTER_KEY =", MASTER_KEY.hex())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/tests/test_gen_fixture_wrap.py b/tools/tests/test_gen_fixture_wrap.py
new file mode 100644
index 0000000..1c72e66
--- /dev/null
+++ b/tools/tests/test_gen_fixture_wrap.py
@@ -0,0 +1,33 @@
+# Author: cirakg & claude ai
+# Summary: Tests that the fixture PIN wrap generator matches the constants in source/app/Fixtures.mc and RFC 5869.
+from __future__ import annotations
+
+import re
+import shutil
+from pathlib import Path
+
+import gen_fixture_wrap
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def fixture_constant(name: str) -> str:
+ text = (ROOT / "source" / "app" / "Fixtures.mc").read_text(encoding="utf-8")
+ match = re.search(rf'const {name} = "([0-9a-f]+)"', text)
+ assert match, f"{name} not found in Fixtures.mc"
+ return match.group(1)
+
+
+def test_hkdf_matches_rfc5869_case_3() -> None:
+ # RFC 5869 A.3: empty salt and info.
+ okm = gen_fixture_wrap.hkdf(bytes([0x0B] * 22), b"", 42)
+ assert okm.hex() == (
+ "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8" # RFC 5869 A.3
+ )
+
+
+@pytest.mark.skipif(shutil.which("openssl") is None, reason="needs openssl for AES")
+def test_generated_wrap_matches_the_fixture() -> None:
+ assert gen_fixture_wrap.fixture_wrap().hex() == fixture_constant("FIXTURE_WRAP")
+ assert gen_fixture_wrap.MASTER_KEY.hex() == fixture_constant("FIXTURE_MASTER_KEY")
diff --git a/tools/tests/test_ui_flow.py b/tools/tests/test_ui_flow.py
new file mode 100644
index 0000000..7e0d336
--- /dev/null
+++ b/tools/tests/test_ui_flow.py
@@ -0,0 +1,73 @@
+# Author: cirakg & claude ai
+# Summary: Tests for the UI flow driver's pure parts: device key layout, JSON lookup and the app-log reader.
+from __future__ import annotations
+
+import json
+import threading
+import time
+from pathlib import Path
+
+import pytest
+import ui_flow
+
+
+def write_sim(tmp_path: Path, config: dict[str, object]) -> Path:
+ (tmp_path / "simulator.json").write_text(json.dumps(config), encoding="utf-8")
+ return tmp_path
+
+
+def test_load_keys_gives_button_centres(tmp_path: Path) -> None:
+ device = write_sim(
+ tmp_path,
+ {
+ "keys": [
+ {"id": "enter", "behavior": "onSelect", "location": {"x": 10, "y": 20, "width": 30, "height": 40}},
+ {
+ "id": "menu",
+ "behavior": "onMenu",
+ "isHold": True,
+ "location": {"x": 0, "y": 0, "width": 2, "height": 2},
+ },
+ {"id": "start", "location": {"x": 5, "y": 5, "width": 10, "height": 10}},
+ ]
+ },
+ )
+ keys = ui_flow.load_keys(device)
+ assert keys["enter"] == ui_flow.Key(25, 40, "onSelect", False)
+ assert keys["menu"].hold
+ assert keys["start"].behavior == ""
+
+
+def test_find_key_searches_nested_structures() -> None:
+ data = {"display": {"isTouch": True}, "glance": [{"contentArea": {"x": 1}}]}
+ assert ui_flow.find_key(data, "isTouch") is True
+ assert ui_flow.find_key(data, "contentArea") == {"x": 1}
+ assert ui_flow.find_key(data, "missing") is None
+
+
+def test_app_log_waits_only_for_lines_after_the_mark(tmp_path: Path) -> None:
+ log_path = tmp_path / "app.log"
+ log_path.write_text("DIAG shown list\n", encoding="utf-8")
+ log = ui_flow.AppLog(log_path)
+ assert log.wait_for(r"DIAG shown list\s*$", 1).group(0).startswith("DIAG shown list")
+ log.set_mark()
+
+ def append_later() -> None:
+ time.sleep(0.2)
+ with log_path.open("a", encoding="utf-8") as f:
+ f.write("DIAG shown otp\n")
+
+ writer = threading.Thread(target=append_later)
+ writer.start()
+ assert log.wait_for(r"DIAG shown otp", 3)
+ writer.join()
+ with pytest.raises(ui_flow.FlowError):
+ log.wait_for(r"DIAG shown list", 0.3) # only before the mark
+
+
+def test_app_log_latest_returns_the_last_match(tmp_path: Path) -> None:
+ log_path = tmp_path / "app.log"
+ log_path.write_text("DIAG list_focus y=10\nother\nDIAG list_focus y=42\n", encoding="utf-8")
+ match = ui_flow.AppLog(log_path).latest(r"DIAG list_focus y=(\d+)")
+ assert match is not None and match.group(1) == "42"
+ assert ui_flow.AppLog(tmp_path / "missing.log").latest("x") is None
diff --git a/tools/ui_flow.py b/tools/ui_flow.py
new file mode 100644
index 0000000..f43cf81
--- /dev/null
+++ b/tools/ui_flow.py
@@ -0,0 +1,465 @@
+#!/usr/bin/env python3
+# Author: cirakg & claude ai
+# Summary: Drive the app in the headless simulator with scripted input and check a screenshot of every screen.
+"""Scripted UI flow for the UI-test build (README 17.4).
+
+Usage (inside the dev container, with the simulator running and the
+UI-test build launched by docker/scripts/screenshots.sh):
+ ui_flow.py --device fr70 --device-dir DIR --log APP.log --window ID
+ --out build/screenshots/fr70 --golden test/golden/fr70 [--approve]
+
+Input goes to the simulator window with xdotool: clicks on the device
+drawing's buttons (positions from DIR/simulator.json) and taps and swipes on
+the screen. Watches without a button for an action use touch instead (Venu
+Sq 2 has no UP/DOWN and no select button).
+
+The app (UI-test build, source/util/TestMode.mc) starts with fake entries,
+the PIN 123456 and a frozen clock, and prints "DIAG shown <screen>" after
+drawing each screen, so every step waits for the screen it expects instead
+of sleeping. Menus are native Menu2 views that print nothing; for those the
+script waits a fixed time.
+
+The flow covers: the list, a TOTP entry, a HOTP entry and its next code, the
+PIN screen, a wrong PIN, a correct PIN opening a locked entry, auto-lock
+closing it, the menu, the About screen (watches with buttons; touch-only
+menus have no focus to move) and the lockout after 5 wrong PINs.
+Each screenshot is compared with test/golden/<device>/<screen>.png like
+sim_screenshot.py does (or saved there with --approve).
+
+Exit: 0 all screens match, 1 a screen differs or is missing a golden image,
+2 the flow failed (a screen didn't appear in time).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import sim_screenshot as shots
+
+EXIT_OK, EXIT_DIFFERENT, EXIT_FLOW = 0, 1, 2
+SCREEN_TIMEOUT_S = 20.0
+AUTO_LOCK_TIMEOUT_S = 25.0 # the UI-test build locks after 10 s without input
+MENU_SETTLE_S = 1.5 # Menu2 has no "shown" line
+SETTLE_S = 0.4 # after a "shown" line, before the screenshot
+CLICK_PAUSE_S = 0.25
+HOLD_S = 1.6 # long enough for a hold (menu) on every device
+FIXTURE_PIN = "123456" # source/app/Fixtures.mc
+WRONG_PIN = "000000"
+FAILURES_UNTIL_WAIT = 5 # source/storage/Lockout.mc: the 5th failure starts a delay
+SELECTOR_OK = 10 # source/ui/PinView.mc: selector positions 0-9, then OK
+
+
+class FlowError(Exception):
+ """The app didn't reach an expected screen."""
+
+
+@dataclass(frozen=True)
+class Key:
+ x: int
+ y: int
+ behavior: str
+ hold: bool
+
+
+def load_keys(device_dir: Path) -> dict[str, Key]:
+ """Button centres in device-drawing coordinates, by button id (enter, up, down, esc, menu)."""
+ sim = json.loads((device_dir / "simulator.json").read_text(encoding="utf-8"))
+ keys: dict[str, Key] = {}
+ for key in sim.get("keys", []):
+ loc = key["location"]
+ keys[str(key["id"])] = Key(
+ int(loc["x"]) + int(loc["width"]) // 2,
+ int(loc["y"]) + int(loc["height"]) // 2,
+ str(key.get("behavior", "")),
+ bool(key.get("isHold", False)),
+ )
+ return keys
+
+
+class AppLog:
+ """The app's console output, read as it grows."""
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self.mark = 0
+
+ def lines(self) -> list[str]:
+ try:
+ return self.path.read_text(encoding="utf-8", errors="replace").splitlines()
+ except OSError:
+ return []
+
+ def set_mark(self) -> None:
+ """Later waits only look at lines written after this point."""
+ self.mark = len(self.lines())
+
+ def wait_for(self, pattern: str, timeout: float) -> re.Match[str]:
+ regex = re.compile(pattern)
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ for line in self.lines()[self.mark :]:
+ match = regex.search(line)
+ if match:
+ return match
+ time.sleep(0.05)
+ raise FlowError(f"timed out after {timeout:.0f} s waiting for /{pattern}/")
+
+ def latest(self, pattern: str) -> re.Match[str] | None:
+ regex = re.compile(pattern)
+ found = None
+ for line in self.lines():
+ match = regex.search(line)
+ if match:
+ found = match
+ return found
+
+
+class Simulator:
+ """Input to and screenshots of the simulator window."""
+
+ def __init__(self, window: str, device_dir: Path, raw_dir: Path) -> None:
+ self.window = window
+ self.device_dir = device_dir
+ self.display = shots.load_display(device_dir)
+ self.keys = load_keys(device_dir)
+ self.raw_dir = raw_dir
+ self.offset = self._find_offset()
+
+ def capture(self, name: str) -> Path:
+ path = self.raw_dir / f"{name}-window.png"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ subprocess.run(["import", "-window", self.window, str(path)], check=True)
+ return path
+
+ def _find_offset(self) -> tuple[int, int]:
+ raw = shots.open_image(self.capture("offset"))
+ device = shots.open_image(self.device_dir / self.display.image)
+ dx, dy, score = shots.find_offset(raw, shots.bezel_only(device, self.display))
+ if score > shots.MAX_BEZEL_DIFF:
+ raise FlowError(f"device drawing not found in the simulator window (difference {score:.1f})")
+ return dx, dy
+
+ def _xdotool(self, *args: str) -> None:
+ subprocess.run(["xdotool", *args], check=True)
+
+ def _move(self, wx: int, wy: int) -> None:
+ self._xdotool("mousemove", "--window", self.window, str(wx), str(wy))
+
+ def _to_window(self, dx: int, dy: int) -> tuple[int, int]:
+ """Device-drawing coordinates to window coordinates."""
+ return self.offset[0] + dx, self.offset[1] + dy
+
+ def has(self, key_id: str, behavior: str | None = None) -> bool:
+ key = self.keys.get(key_id)
+ return key is not None and (behavior is None or key.behavior == behavior)
+
+ def press(self, key_id: str, hold: bool = False) -> None:
+ key = self.keys[key_id]
+ self._move(*self._to_window(key.x, key.y))
+ if hold:
+ self._xdotool("mousedown", "1")
+ time.sleep(HOLD_S)
+ self._xdotool("mouseup", "1")
+ else:
+ self._xdotool("click", "1")
+ time.sleep(CLICK_PAUSE_S)
+
+ def tap(self, sx: int, sy: int) -> None:
+ """Tap at screen coordinates (0, 0 = top left of the watch screen)."""
+ self._move(*self._to_window(self.display.x + sx, self.display.y + sy))
+ self._xdotool("click", "1")
+ time.sleep(CLICK_PAUSE_S)
+
+ def long_press(self, sx: int, sy: int) -> None:
+ self._move(*self._to_window(self.display.x + sx, self.display.y + sy))
+ self._xdotool("mousedown", "1")
+ time.sleep(HOLD_S)
+ self._xdotool("mouseup", "1")
+ time.sleep(CLICK_PAUSE_S)
+
+ def swipe(self, sx0: int, sy0: int, sx1: int, sy1: int) -> None:
+ self._move(*self._to_window(self.display.x + sx0, self.display.y + sy0))
+ self._xdotool("mousedown", "1")
+ steps = 8
+ for i in range(1, steps + 1):
+ x = sx0 + (sx1 - sx0) * i // steps
+ y = sy0 + (sy1 - sy0) * i // steps
+ self._move(*self._to_window(self.display.x + x, self.display.y + y))
+ time.sleep(0.02)
+ self._xdotool("mouseup", "1")
+ time.sleep(CLICK_PAUSE_S * 2)
+
+
+class Flow:
+ """The abstract actions of README 13.4, mapped to this device's buttons or touch screen."""
+
+ def __init__(self, sim: Simulator, log: AppLog, out: Path, golden: Path, approve: bool) -> None:
+ self.sim = sim
+ self.log = log
+ self.out = out
+ self.golden = golden
+ self.approve = approve
+ self.results: dict[str, int] = {}
+ self.selector = 0 # the PIN digit selector's position (button-only watches)
+ touch = log.wait_for(r"DIAG device touch=(\d)", SCREEN_TIMEOUT_S)
+ self.touch = touch.group(1) == "1"
+
+ # -- navigation ---------------------------------------------------------
+
+ def _w(self) -> int:
+ return self.sim.display.width
+
+ def _h(self) -> int:
+ return self.sim.display.height
+
+ def next(self) -> None:
+ if self.sim.has("down"):
+ self.sim.press("down")
+ else:
+ self.sim.swipe(self._w() // 2, self._h() * 3 // 4, self._w() // 2, self._h() // 4)
+
+ def back(self) -> None:
+ self.sim.press("esc")
+
+ def menu(self) -> None:
+ """Hold MENU, or long-press the screen on watches without one (Venu X1)."""
+ if self.sim.has("menu"):
+ self.sim.press("menu", hold=True)
+ else:
+ self.sim.long_press(self._w() // 2, self._h() // 2)
+
+ def open_focused_row(self) -> None:
+ if self.sim.has("enter", "onSelect"):
+ self.sim.press("enter")
+ return
+ focus = self.log.latest(r"DIAG list_focus y=(\d+)")
+ if focus is None:
+ raise FlowError("the list didn't report its focused row")
+ self.sim.tap(self._w() // 2, int(focus.group(1)))
+
+ def select(self) -> None:
+ """START, or a tap in the middle of the screen."""
+ if self.sim.has("enter", "onSelect"):
+ self.sim.press("enter")
+ else:
+ self.sim.tap(self._w() // 2, self._h() // 2)
+
+ def enter_pin(self, pin: str) -> None:
+ if self.touch:
+ keys = self.log.latest(r"DIAG pin_keys (\S+)")
+ if keys is None:
+ raise FlowError("the PIN screen didn't report its keypad")
+ centres = {}
+ for item in keys.group(1).split(";"):
+ value, xy = item.split("=")
+ x, y = xy.split(",")
+ centres[int(value)] = (int(x), int(y))
+ for digit in pin:
+ self.sim.tap(*centres[int(digit)])
+ self.sim.tap(*centres[-2]) # OK
+ return
+ for position in [int(d) for d in pin] + [SELECTOR_OK]:
+ steps = (position - self.selector) % (SELECTOR_OK + 1)
+ for _ in range(steps):
+ self.sim.press("down")
+ self.selector = position
+ self.sim.press("enter")
+
+ # -- checking -----------------------------------------------------------
+
+ def expect(self, screen: str, timeout: float = SCREEN_TIMEOUT_S) -> None:
+ self.log.wait_for(rf"DIAG shown {re.escape(screen)}\s*$", timeout)
+ time.sleep(SETTLE_S)
+
+ def opened_pin_screen(self) -> None:
+ """A new PIN screen starts with its selector at 0."""
+ self.selector = 0
+
+ def act(self) -> None:
+ """Call before an input whose result `expect` should wait for."""
+ self.log.set_mark()
+
+ def shot(self, name: str) -> None:
+ raw = self.sim.capture(name)
+ out = self.out / f"{name}.png"
+ label = f"{self.sim.device_dir.name}/{name}"
+ try:
+ screen = shots.extract_screen(raw, self.sim.device_dir, out)
+ except shots.ScreenshotError as err:
+ print(f"[{label}] {err}")
+ self.results[name] = EXIT_DIFFERENT
+ return
+ golden = self.golden / f"{name}.png"
+ if self.approve:
+ shots.save_png(screen, golden, f"Approved simulator screen '{name}' of {self.sim.device_dir.name}")
+ print(f"[{label}] approved as {golden}")
+ self.results[name] = EXIT_OK
+ return
+ self.results[name] = shots.check_golden(screen, label, out, golden)
+
+
+def flow_entries(f: Flow) -> None:
+ """The list, a TOTP entry, a HOTP entry and its next code."""
+ f.expect("list")
+ f.shot("list")
+ f.act()
+ f.open_focused_row() # GitHub, TOTP
+ f.expect("otp")
+ f.shot("otp")
+ f.act()
+ f.back()
+ f.expect("list")
+ for _ in range(3): # Google, Bank, VPN
+ f.next()
+ f.act()
+ f.open_focused_row() # VPN, HOTP
+ f.expect("otp_hotp")
+ f.shot("otp_hotp")
+ f.act()
+ f.select() # next HOTP code
+ f.expect("otp_hotp")
+ f.shot("otp_hotp_next")
+ f.act()
+ f.back()
+ f.expect("list")
+
+
+def flow_locked_entry(f: Flow) -> None:
+ """Bank is locked: PIN screen, a wrong PIN, the right PIN, then auto-lock."""
+ for _ in range(6): # wrap around to Bank (index 2 of 7)
+ f.next()
+ f.act()
+ f.open_focused_row()
+ f.expect("pin")
+ f.opened_pin_screen()
+ f.shot("pin")
+ f.act()
+ f.enter_pin(WRONG_PIN)
+ f.expect("pin_error")
+ f.shot("pin_error")
+ f.act()
+ f.enter_pin(FIXTURE_PIN)
+ f.expect("otp")
+ f.shot("otp_locked")
+ # Auto-lock closes the locked entry.
+ f.act()
+ f.expect("list", AUTO_LOCK_TIMEOUT_S)
+
+
+def flow_menu(f: Flow) -> None:
+ """The menu, and About on watches whose menu has a focus to move."""
+ f.menu()
+ time.sleep(MENU_SETTLE_S)
+ f.shot("menu")
+ f.act()
+ if f.sim.has("down"):
+ # Locked with a PIN: Change PIN, Reset PIN, Settings results, Entry
+ # IDs, Clock status, About.
+ for _ in range(5):
+ f.next()
+ time.sleep(MENU_SETTLE_S)
+ f.act()
+ f.select()
+ f.expect("about")
+ f.shot("about")
+ f.act()
+ # Touch-only watches show the menu as a freely scrolling list without
+ # focus, so there is no reliable "select About"; just close it.
+ f.back()
+ f.expect("list")
+
+
+def flow_lockout(f: Flow) -> None:
+ """Five wrong PINs start the 30 s wait."""
+ f.act()
+ f.open_focused_row()
+ f.expect("pin")
+ f.opened_pin_screen()
+ for attempt in range(1, FAILURES_UNTIL_WAIT + 1):
+ f.act()
+ f.enter_pin(WRONG_PIN)
+ f.expect("pin_wait" if attempt == FAILURES_UNTIL_WAIT else "pin_error")
+ f.shot("pin_wait")
+
+
+def run_flow(f: Flow) -> None:
+ """The scripted session; see the module docstring."""
+ flow_entries(f)
+ flow_locked_entry(f)
+ flow_menu(f)
+ flow_lockout(f)
+
+
+def find_key(data: object, key: str) -> object:
+ """The first value stored under `key` anywhere in a JSON structure, or None."""
+ if isinstance(data, dict):
+ if key in data:
+ return data[key]
+ data = list(data.values())
+ if isinstance(data, list):
+ for item in data:
+ found = find_key(item, key)
+ if found is not None:
+ return found
+ return None
+
+
+def open_from_glance(window: str, device_dir: Path, raw_dir: Path) -> None:
+ """Open the full app from its glance, as found in the simulator: a tap on
+ it on touch screens; on button-only watches DOWN (which selects the
+ glance) and then START. START alone does nothing in either case."""
+ sim = Simulator(window, device_dir, raw_dir)
+ config = json.loads((device_dir / "simulator.json").read_text(encoding="utf-8"))
+ area = find_key(config.get("glance", {}), "contentArea")
+ if find_key(config, "isTouch") is True and isinstance(area, dict):
+ sim.tap(int(area["x"]) + int(area["width"]) // 2, int(area["y"]) + int(area["height"]) // 2)
+ else:
+ sim.press("down")
+ time.sleep(1.0)
+ sim.press("enter")
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Scripted UI flow with screenshot checks.")
+ parser.add_argument("--device-dir", type=Path, required=True)
+ parser.add_argument("--window", required=True, help="X window id of the simulator")
+ parser.add_argument("--out", type=Path, required=True, help="directory for the screenshots")
+ parser.add_argument("--log", type=Path, help="the app's console log (monkeydo output)")
+ parser.add_argument("--golden", type=Path, help="directory of the approved images")
+ parser.add_argument("--approve", action="store_true")
+ parser.add_argument("--open-from-glance", action="store_true", help="only open the app from its glance")
+ args = parser.parse_args(argv)
+ name = args.device_dir.name
+ if args.open_from_glance:
+ try:
+ open_from_glance(args.window, args.device_dir, args.out / "raw")
+ except (FlowError, subprocess.CalledProcessError, OSError) as err:
+ print(f"[{name}] could not open the app from its glance: {err}")
+ return EXIT_FLOW
+ return EXIT_OK
+ if args.log is None or args.golden is None:
+ parser.error("--log and --golden are required for the flow")
+ try:
+ sim = Simulator(args.window, args.device_dir, args.out / "raw")
+ flow = Flow(sim, AppLog(args.log), args.out, args.golden, args.approve)
+ run_flow(flow)
+ except (FlowError, subprocess.CalledProcessError, OSError) as err:
+ print(f"[{name}] UI flow failed: {err}")
+ return EXIT_FLOW
+ failed = sorted(screen for screen, code in flow.results.items() if code != EXIT_OK)
+ if failed:
+ print(f"[{name}] {len(failed)} of {len(flow.results)} screens need attention: {', '.join(failed)}")
+ return EXIT_DIFFERENT
+ print(f"[{name}] all {len(flow.results)} screens match")
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())