diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-19 22:28:09 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-19 22:28:09 +0200 |
| commit | 2373b6f02ef027a423762d5d6349cd9e0f36090e (patch) | |
| tree | 7509b0b1905d7c593074bba8ed4c94e30e437604 | |
| parent | 35042ff04ffc50e55ff59a385849442be4429d6b (diff) | |
test(ui): drive the simulator with a scripted flow and check every screen
tools/ui_flow.py clicks buttons and taps from the device files, waits for
DIAG shown lines and compares 10 screens per watch with goldens on four
test and three spot-check devices. Adds the fixture-wrap generator, UI
logic tests and driver tests; the launcher opens apps from their glance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 files changed, 808 insertions, 32 deletions
@@ -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 Binary files differnew file mode 100644 index 0000000..d6a87a2 --- /dev/null +++ b/test/golden/fenix7/about.png diff --git a/test/golden/fenix7/list.png b/test/golden/fenix7/list.png Binary files differnew file mode 100644 index 0000000..ffcc3ff --- /dev/null +++ b/test/golden/fenix7/list.png diff --git a/test/golden/fenix7/menu.png b/test/golden/fenix7/menu.png Binary files differnew file mode 100644 index 0000000..3ceb597 --- /dev/null +++ b/test/golden/fenix7/menu.png diff --git a/test/golden/fenix7/otp.png b/test/golden/fenix7/otp.png Binary files differnew file mode 100644 index 0000000..8ef0cad --- /dev/null +++ b/test/golden/fenix7/otp.png diff --git a/test/golden/fenix7/otp_hotp.png b/test/golden/fenix7/otp_hotp.png Binary files differnew file mode 100644 index 0000000..a325345 --- /dev/null +++ b/test/golden/fenix7/otp_hotp.png diff --git a/test/golden/fenix7/otp_hotp_next.png b/test/golden/fenix7/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..a9ff510 --- /dev/null +++ b/test/golden/fenix7/otp_hotp_next.png diff --git a/test/golden/fenix7/otp_locked.png b/test/golden/fenix7/otp_locked.png Binary files differnew file mode 100644 index 0000000..ef29840 --- /dev/null +++ b/test/golden/fenix7/otp_locked.png diff --git a/test/golden/fenix7/pin.png b/test/golden/fenix7/pin.png Binary files differnew file mode 100644 index 0000000..d1fe80b --- /dev/null +++ b/test/golden/fenix7/pin.png diff --git a/test/golden/fenix7/pin_error.png b/test/golden/fenix7/pin_error.png Binary files differnew file mode 100644 index 0000000..48541c8 --- /dev/null +++ b/test/golden/fenix7/pin_error.png diff --git a/test/golden/fenix7/pin_wait.png b/test/golden/fenix7/pin_wait.png Binary files differnew file mode 100644 index 0000000..77cce98 --- /dev/null +++ b/test/golden/fenix7/pin_wait.png diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png Binary files differnew file mode 100644 index 0000000..f8f23d2 --- /dev/null +++ b/test/golden/fr255s/about.png diff --git a/test/golden/fr255s/list.png b/test/golden/fr255s/list.png Binary files differnew file mode 100644 index 0000000..df30245 --- /dev/null +++ b/test/golden/fr255s/list.png diff --git a/test/golden/fr255s/menu.png b/test/golden/fr255s/menu.png Binary files differnew file mode 100644 index 0000000..047504c --- /dev/null +++ b/test/golden/fr255s/menu.png diff --git a/test/golden/fr255s/otp.png b/test/golden/fr255s/otp.png Binary files differnew file mode 100644 index 0000000..f1637d7 --- /dev/null +++ b/test/golden/fr255s/otp.png diff --git a/test/golden/fr255s/otp_hotp.png b/test/golden/fr255s/otp_hotp.png Binary files differnew file mode 100644 index 0000000..fb4fb2e --- /dev/null +++ b/test/golden/fr255s/otp_hotp.png diff --git a/test/golden/fr255s/otp_hotp_next.png b/test/golden/fr255s/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..2c36aaf --- /dev/null +++ b/test/golden/fr255s/otp_hotp_next.png diff --git a/test/golden/fr255s/otp_locked.png b/test/golden/fr255s/otp_locked.png Binary files differnew file mode 100644 index 0000000..6b185ad --- /dev/null +++ b/test/golden/fr255s/otp_locked.png diff --git a/test/golden/fr255s/pin.png b/test/golden/fr255s/pin.png Binary files differnew file mode 100644 index 0000000..bb3e989 --- /dev/null +++ b/test/golden/fr255s/pin.png diff --git a/test/golden/fr255s/pin_error.png b/test/golden/fr255s/pin_error.png Binary files differnew file mode 100644 index 0000000..b580338 --- /dev/null +++ b/test/golden/fr255s/pin_error.png diff --git a/test/golden/fr255s/pin_wait.png b/test/golden/fr255s/pin_wait.png Binary files differnew file mode 100644 index 0000000..cc1e27b --- /dev/null +++ b/test/golden/fr255s/pin_wait.png diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png Binary files differnew file mode 100644 index 0000000..c46fa11 --- /dev/null +++ b/test/golden/fr70/about.png diff --git a/test/golden/fr70/list.png b/test/golden/fr70/list.png Binary files differnew file mode 100644 index 0000000..eaa657e --- /dev/null +++ b/test/golden/fr70/list.png diff --git a/test/golden/fr70/menu.png b/test/golden/fr70/menu.png Binary files differnew file mode 100644 index 0000000..b35e078 --- /dev/null +++ b/test/golden/fr70/menu.png diff --git a/test/golden/fr70/otp.png b/test/golden/fr70/otp.png Binary files differnew file mode 100644 index 0000000..af4f2d4 --- /dev/null +++ b/test/golden/fr70/otp.png diff --git a/test/golden/fr70/otp_hotp.png b/test/golden/fr70/otp_hotp.png Binary files differnew file mode 100644 index 0000000..7e341f2 --- /dev/null +++ b/test/golden/fr70/otp_hotp.png diff --git a/test/golden/fr70/otp_hotp_next.png b/test/golden/fr70/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..75e8f2a --- /dev/null +++ b/test/golden/fr70/otp_hotp_next.png diff --git a/test/golden/fr70/otp_locked.png b/test/golden/fr70/otp_locked.png Binary files differnew file mode 100644 index 0000000..8730813 --- /dev/null +++ b/test/golden/fr70/otp_locked.png diff --git a/test/golden/fr70/pin.png b/test/golden/fr70/pin.png Binary files differnew file mode 100644 index 0000000..71f6579 --- /dev/null +++ b/test/golden/fr70/pin.png diff --git a/test/golden/fr70/pin_error.png b/test/golden/fr70/pin_error.png Binary files differnew file mode 100644 index 0000000..9a3de60 --- /dev/null +++ b/test/golden/fr70/pin_error.png diff --git a/test/golden/fr70/pin_wait.png b/test/golden/fr70/pin_wait.png Binary files differnew file mode 100644 index 0000000..ed8132e --- /dev/null +++ b/test/golden/fr70/pin_wait.png diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png Binary files differnew file mode 100644 index 0000000..61b9745 --- /dev/null +++ b/test/golden/fr965/about.png diff --git a/test/golden/fr965/list.png b/test/golden/fr965/list.png Binary files differnew file mode 100644 index 0000000..d0904b2 --- /dev/null +++ b/test/golden/fr965/list.png diff --git a/test/golden/fr965/menu.png b/test/golden/fr965/menu.png Binary files differnew file mode 100644 index 0000000..ba1149d --- /dev/null +++ b/test/golden/fr965/menu.png diff --git a/test/golden/fr965/otp.png b/test/golden/fr965/otp.png Binary files differnew file mode 100644 index 0000000..111001b --- /dev/null +++ b/test/golden/fr965/otp.png diff --git a/test/golden/fr965/otp_hotp.png b/test/golden/fr965/otp_hotp.png Binary files differnew file mode 100644 index 0000000..fbcc1a4 --- /dev/null +++ b/test/golden/fr965/otp_hotp.png diff --git a/test/golden/fr965/otp_hotp_next.png b/test/golden/fr965/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..181bc37 --- /dev/null +++ b/test/golden/fr965/otp_hotp_next.png diff --git a/test/golden/fr965/otp_locked.png b/test/golden/fr965/otp_locked.png Binary files differnew file mode 100644 index 0000000..537dc0c --- /dev/null +++ b/test/golden/fr965/otp_locked.png diff --git a/test/golden/fr965/pin.png b/test/golden/fr965/pin.png Binary files differnew file mode 100644 index 0000000..06cab65 --- /dev/null +++ b/test/golden/fr965/pin.png diff --git a/test/golden/fr965/pin_error.png b/test/golden/fr965/pin_error.png Binary files differnew file mode 100644 index 0000000..c187ada --- /dev/null +++ b/test/golden/fr965/pin_error.png diff --git a/test/golden/fr965/pin_wait.png b/test/golden/fr965/pin_wait.png Binary files differnew file mode 100644 index 0000000..e381d23 --- /dev/null +++ b/test/golden/fr965/pin_wait.png diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png Binary files differnew file mode 100644 index 0000000..1400601 --- /dev/null +++ b/test/golden/instinct3amoled45mm/about.png diff --git a/test/golden/instinct3amoled45mm/list.png b/test/golden/instinct3amoled45mm/list.png Binary files differnew file mode 100644 index 0000000..d4b9323 --- /dev/null +++ b/test/golden/instinct3amoled45mm/list.png diff --git a/test/golden/instinct3amoled45mm/menu.png b/test/golden/instinct3amoled45mm/menu.png Binary files differnew file mode 100644 index 0000000..6c6f81e --- /dev/null +++ b/test/golden/instinct3amoled45mm/menu.png diff --git a/test/golden/instinct3amoled45mm/otp.png b/test/golden/instinct3amoled45mm/otp.png Binary files differnew file mode 100644 index 0000000..0b780ae --- /dev/null +++ b/test/golden/instinct3amoled45mm/otp.png diff --git a/test/golden/instinct3amoled45mm/otp_hotp.png b/test/golden/instinct3amoled45mm/otp_hotp.png Binary files differnew file mode 100644 index 0000000..def95cf --- /dev/null +++ b/test/golden/instinct3amoled45mm/otp_hotp.png diff --git a/test/golden/instinct3amoled45mm/otp_hotp_next.png b/test/golden/instinct3amoled45mm/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..9608c2c --- /dev/null +++ b/test/golden/instinct3amoled45mm/otp_hotp_next.png diff --git a/test/golden/instinct3amoled45mm/otp_locked.png b/test/golden/instinct3amoled45mm/otp_locked.png Binary files differnew file mode 100644 index 0000000..0ef7e4b --- /dev/null +++ b/test/golden/instinct3amoled45mm/otp_locked.png diff --git a/test/golden/instinct3amoled45mm/pin.png b/test/golden/instinct3amoled45mm/pin.png Binary files differnew file mode 100644 index 0000000..92b60d5 --- /dev/null +++ b/test/golden/instinct3amoled45mm/pin.png diff --git a/test/golden/instinct3amoled45mm/pin_error.png b/test/golden/instinct3amoled45mm/pin_error.png Binary files differnew file mode 100644 index 0000000..9d330b8 --- /dev/null +++ b/test/golden/instinct3amoled45mm/pin_error.png diff --git a/test/golden/instinct3amoled45mm/pin_wait.png b/test/golden/instinct3amoled45mm/pin_wait.png Binary files differnew file mode 100644 index 0000000..39eae8d --- /dev/null +++ b/test/golden/instinct3amoled45mm/pin_wait.png diff --git a/test/golden/venusq2/list.png b/test/golden/venusq2/list.png Binary files differnew file mode 100644 index 0000000..f778d10 --- /dev/null +++ b/test/golden/venusq2/list.png diff --git a/test/golden/venusq2/menu.png b/test/golden/venusq2/menu.png Binary files differnew file mode 100644 index 0000000..6393819 --- /dev/null +++ b/test/golden/venusq2/menu.png diff --git a/test/golden/venusq2/otp.png b/test/golden/venusq2/otp.png Binary files differnew file mode 100644 index 0000000..3948080 --- /dev/null +++ b/test/golden/venusq2/otp.png diff --git a/test/golden/venusq2/otp_hotp.png b/test/golden/venusq2/otp_hotp.png Binary files differnew file mode 100644 index 0000000..2331618 --- /dev/null +++ b/test/golden/venusq2/otp_hotp.png diff --git a/test/golden/venusq2/otp_hotp_next.png b/test/golden/venusq2/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..564df92 --- /dev/null +++ b/test/golden/venusq2/otp_hotp_next.png diff --git a/test/golden/venusq2/otp_locked.png b/test/golden/venusq2/otp_locked.png Binary files differnew file mode 100644 index 0000000..9181d66 --- /dev/null +++ b/test/golden/venusq2/otp_locked.png diff --git a/test/golden/venusq2/pin.png b/test/golden/venusq2/pin.png Binary files differnew file mode 100644 index 0000000..9b51943 --- /dev/null +++ b/test/golden/venusq2/pin.png diff --git a/test/golden/venusq2/pin_error.png b/test/golden/venusq2/pin_error.png Binary files differnew file mode 100644 index 0000000..80a803e --- /dev/null +++ b/test/golden/venusq2/pin_error.png diff --git a/test/golden/venusq2/pin_wait.png b/test/golden/venusq2/pin_wait.png Binary files differnew file mode 100644 index 0000000..618e517 --- /dev/null +++ b/test/golden/venusq2/pin_wait.png diff --git a/test/golden/venux1/list.png b/test/golden/venux1/list.png Binary files differnew file mode 100644 index 0000000..8ba6f1f --- /dev/null +++ b/test/golden/venux1/list.png diff --git a/test/golden/venux1/menu.png b/test/golden/venux1/menu.png Binary files differnew file mode 100644 index 0000000..6930419 --- /dev/null +++ b/test/golden/venux1/menu.png diff --git a/test/golden/venux1/otp.png b/test/golden/venux1/otp.png Binary files differnew file mode 100644 index 0000000..52d83df --- /dev/null +++ b/test/golden/venux1/otp.png diff --git a/test/golden/venux1/otp_hotp.png b/test/golden/venux1/otp_hotp.png Binary files differnew file mode 100644 index 0000000..e6b8c6c --- /dev/null +++ b/test/golden/venux1/otp_hotp.png diff --git a/test/golden/venux1/otp_hotp_next.png b/test/golden/venux1/otp_hotp_next.png Binary files differnew file mode 100644 index 0000000..7ffbdf5 --- /dev/null +++ b/test/golden/venux1/otp_hotp_next.png diff --git a/test/golden/venux1/otp_locked.png b/test/golden/venux1/otp_locked.png Binary files differnew file mode 100644 index 0000000..5564888 --- /dev/null +++ b/test/golden/venux1/otp_locked.png diff --git a/test/golden/venux1/pin.png b/test/golden/venux1/pin.png Binary files differnew file mode 100644 index 0000000..6263938 --- /dev/null +++ b/test/golden/venux1/pin.png diff --git a/test/golden/venux1/pin_error.png b/test/golden/venux1/pin_error.png Binary files differnew file mode 100644 index 0000000..ff2e723 --- /dev/null +++ b/test/golden/venux1/pin_error.png diff --git a/test/golden/venux1/pin_wait.png b/test/golden/venux1/pin_wait.png Binary files differnew file mode 100644 index 0000000..c91126f --- /dev/null +++ b/test/golden/venux1/pin_wait.png 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()) |
