summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
commit85f1ecabd7f58f306b2a042376209ac8cf49ab9b (patch)
tree00a0f3e2717e5c20ce0c427a979ef609a400d326
parent2071e2beda763137416ac386d4bb953711ec995d (diff)
build: complete the Docker pipeline for build, test, screenshots and packaging
- Container scripts now run from the mounted project (no image rebuild per script change): build-app.sh (strict -l 3, warnings fail), run-unit-tests.sh, screenshots.sh, app-diag.sh (perf/memory), lint.sh, package.sh and sim-vnc.sh (noVNC on 127.0.0.1:6080). - Headless simulator helpers: wait for port 1234, remove the stale single-instance lock (~/Sim-dev) that made later starts hang, show the simulator log on failure. - New targets: screenshots-approve, icons; `make lock` re-adds the requirements header; pytest treats warnings as errors. - Author/Summary headers on every build file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--.gitignore3
-rw-r--r--.markdownlint-cli2.jsonc3
-rw-r--r--Makefile19
-rw-r--r--docker/Dockerfile2
-rw-r--r--docker/docs.Dockerfile2
-rw-r--r--docker/docs/package.json1
-rw-r--r--docker/docs/puppeteer.json7
-rw-r--r--docker/requirements.in3
-rw-r--r--docker/requirements.txt2
-rwxr-xr-xdocker/scripts/app-diag.sh36
-rwxr-xr-xdocker/scripts/build-app.sh49
-rwxr-xr-xdocker/scripts/ciq-env.sh93
-rwxr-xr-xdocker/scripts/gen-key.sh3
-rwxr-xr-xdocker/scripts/lint.sh33
-rwxr-xr-xdocker/scripts/package.sh45
-rwxr-xr-xdocker/scripts/run-unit-tests.sh46
-rwxr-xr-xdocker/scripts/screenshots.sh48
-rwxr-xr-xdocker/scripts/sdk-setup.sh3
-rwxr-xr-xdocker/scripts/sim-vnc.sh39
-rw-r--r--pyproject.toml5
20 files changed, 437 insertions, 5 deletions
diff --git a/.gitignore b/.gitignore
index 8da1c0d..82bb7f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Author: cirakg & claude ai
+# Summary: Files git must never track: local SDK/key cache, build output, signing material, tool caches.
+#
# Local tool state: Garmin SDK, device files, SDK-manager login token and the
# developer key. Never commit any of it (Garmin's licence, and the key signs
# every release).
diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc
index bd36a04..00d2fd8 100644
--- a/.markdownlint-cli2.jsonc
+++ b/.markdownlint-cli2.jsonc
@@ -1,3 +1,6 @@
+// Author: cirakg & claude ai
+// Summary: Markdown lint settings used by `make docs`.
+//
// Markdown lint settings for `make docs` (markdownlint-cli2 in the docs image).
// Default rules, with these deliberate exceptions:
{
diff --git a/Makefile b/Makefile
index 29b7702..d53b836 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,6 @@
+# Author: cirakg & claude ai
+# Summary: Host entry point: every build, test, packaging and docs step runs inside Docker.
+#
# OTP Wallet for Connect IQ: host Makefile.
#
# Every target runs inside Docker; the host needs only Docker and make
@@ -66,7 +69,7 @@ DOCS_RUN = $(DOCKER) run --rm --init --label project=$(PROJECT) \
--read-only --tmpfs /tmp:rw,exec,nosuid,size=1g --shm-size 512m \
-v "$(ROOT)":/work -w /work --network none $(DOCS_IMAGE)
-.PHONY: help image docs-image sdk key build test test-tools sim screenshots perf memtest \
+.PHONY: help image docs-image sdk key build test test-tools sim screenshots screenshots-approve perf memtest icons \
lint package docs ci shell clean nuke lock products \
check-docker dirs need-image need-docs-image
@@ -103,7 +106,10 @@ lock: check-docker ## Regenerate the Python and npm lock files (network)
pip install --quiet --user uv==$(UV_VERSION) && \
/tmp/.local/bin/uv pip compile requirements.in --generate-hashes --no-build \
--python-version 3.10 --python-platform x86_64-manylinux_2_35 \
- --exclude-newer $(LOCK_CUTOFF) --no-header --quiet -o requirements.txt'
+ --exclude-newer $(LOCK_CUTOFF) --no-header --quiet -o requirements.txt && \
+ { printf "%s\\n" "# Author: cirakg & claude ai" \
+ "# Summary: Pinned, hash-checked Python tool dependencies of the dev image (generated by \`make lock\`)."; \
+ cat requirements.txt; } > requirements.tmp && mv requirements.tmp requirements.txt'
$(DOCKER) run --rm --label project=$(PROJECT) --user $(HOST_UID):$(HOST_GID) -e HOME=/tmp \
-e npm_config_cache=/tmp/.npm -v "$(ROOT)/docker/docs":/w -w /w $(NODE_IMAGE) sh -c '\
npm install --package-lock-only --ignore-scripts --no-audit --no-fund \
@@ -124,6 +130,10 @@ package: dirs need-image ## Signed .iq for the Store beta + per-device release .
products: dirs need-image ## Check manifest products against the downloaded device files
$(DEV_OFFLINE) python3 tools/gen_products.py --check
+icons: dirs need-image ## Regenerate the launcher icons for every product (after changing manifest.xml)
+ $(DEV_OFFLINE) sh -c 'python3 tools/make_icon.py --all --devices /home/dev/.Garmin/ConnectIQ/Devices && \
+ python3 tools/make_icon.py --size 60 --out resources/drawables/launcher_icon.png'
+
shell: dirs need-image ## Interactive shell in the dev container (no network)
$(DOCKER) run -it $(RUN_FLAGS) --network none $(DEV_IMAGE) bash
@@ -135,9 +145,12 @@ 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 into build/screenshots
+screenshots: dirs need-image ## Screenshot the app on TEST_DEVICES and compare with test/golden
$(DEV_OFFLINE) screenshots.sh $(TEST_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)
+
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/Dockerfile b/docker/Dockerfile
index 7d691d5..a43e90f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,4 +1,6 @@
# syntax=docker/dockerfile:1
+# Author: cirakg & claude ai
+# Summary: garmin-otp/dev image: Connect IQ build, test and headless-simulator toolchain (no SDK inside).
#
# garmin-otp/dev: the toolchain image for this project.
#
diff --git a/docker/docs.Dockerfile b/docker/docs.Dockerfile
index 44dfa93..3f4c9e2 100644
--- a/docker/docs.Dockerfile
+++ b/docker/docs.Dockerfile
@@ -1,4 +1,6 @@
# syntax=docker/dockerfile:1
+# Author: cirakg & claude ai
+# Summary: garmin-otp/docs image: Markdown lint and Mermaid rendering for `make docs`.
#
# garmin-otp/docs: documentation checks for `make docs`.
# * markdownlint-cli2 lints every Markdown file.
diff --git a/docker/docs/package.json b/docker/docs/package.json
index 1dd8cb4..44482fb 100644
--- a/docker/docs/package.json
+++ b/docker/docs/package.json
@@ -1,6 +1,7 @@
{
"name": "garmin-otp-docs-tools",
"private": true,
+ "author": "cirakg & claude ai",
"description": "Documentation checks for the garmin-otp project: Markdown lint and Mermaid rendering. Installed with `npm ci` inside the garmin-otp/docs image; regenerate the lock with `make lock`.",
"dependencies": {
"@mermaid-js/mermaid-cli": "12.0.0",
diff --git a/docker/docs/puppeteer.json b/docker/docs/puppeteer.json
index 9ee160d..3c6a13c 100644
--- a/docker/docs/puppeteer.json
+++ b/docker/docs/puppeteer.json
@@ -1,3 +1,8 @@
{
- "args": ["--no-sandbox", "--disable-gpu"]
+ "_author": "cirakg & claude ai",
+ "_summary": "Chrome launch options for mermaid-cli in the docs image (no sandbox: cap-dropped container).",
+ "args": [
+ "--no-sandbox",
+ "--disable-gpu"
+ ]
}
diff --git a/docker/requirements.in b/docker/requirements.in
index 8c28cb7..69716b7 100644
--- a/docker/requirements.in
+++ b/docker/requirements.in
@@ -1,3 +1,6 @@
+# Author: cirakg & claude ai
+# Summary: Top-level Python tool dependencies of the dev image; `make lock` pins them in requirements.txt.
+#
# Python tooling baked into the garmin-otp/dev image.
# Edit this file, then run `make lock` to regenerate docker/requirements.txt
# (exact versions + SHA-256 hashes, resolved only from releases published
diff --git a/docker/requirements.txt b/docker/requirements.txt
index 3892775..4b1b230 100644
--- a/docker/requirements.txt
+++ b/docker/requirements.txt
@@ -1,3 +1,5 @@
+# Author: cirakg & claude ai
+# Summary: Pinned, hash-checked Python tool dependencies of the dev image (generated by `make lock`).
ast-serialize==0.11.2 \
--hash=sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e \
--hash=sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719 \
diff --git a/docker/scripts/app-diag.sh b/docker/scripts/app-diag.sh
new file mode 100755
index 0000000..804d68a
--- /dev/null
+++ b/docker/scripts/app-diag.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Run the debug app on each device in the headless simulator and check start-up time or memory budgets.
+#
+# Usage: app-diag.sh perf|mem DEVICE [DEVICE ...]
+# Output: build/diag/<DEVICE>.log (console with DIAG and HARNESS lines) and
+# build/diag/<perf|mem>-summary.json; verdict from tools/check_budgets.py.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+kind="${1:?usage: app-diag.sh perf|mem DEVICE [DEVICE ...]}"
+shift
+[[ "${kind}" == perf || "${kind}" == mem ]] || die "unknown kind '${kind}' (use perf or mem)"
+(($# > 0)) || die "usage: app-diag.sh perf|mem DEVICE [DEVICE ...]"
+ciq_require_sdk
+ciq_require_key
+
+for device in "$@"; do
+ build-app.sh debug "${device}"
+done
+
+ciq_start_display
+ciq_start_simulator
+
+out="${BUILD_DIR}/diag"
+mkdir -p "${out}"
+logs=()
+for device in "$@"; do
+ log "starting the app on ${device}"
+ ciq_launch_app "${device}" "${out}/${device}.log" || log "no first frame on ${device} within ${FIRST_FRAME_TIMEOUT_S} s"
+ ciq_stop_app
+ logs+=("${out}/${device}.log")
+done
+
+python3 /work/tools/check_budgets.py "${kind}" "${logs[@]}" --summary "${out}/${kind}-summary.json"
diff --git a/docker/scripts/build-app.sh b/docker/scripts/build-app.sh
new file mode 100755
index 0000000..74dd042
--- /dev/null
+++ b/docker/scripts/build-app.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Compile the watch app for one device: debug, release or unit-test build.
+#
+# Usage: build-app.sh debug|release|test 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
+# 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}"
+
+ciq_require_sdk
+ciq_require_key
+ciq_require_device "${device}"
+
+# icons.jungle (generated by tools/make_icon.py --all) gives every device a
+# launcher icon of exactly its size.
+jungles="/work/monkey.jungle;/work/icons.jungle"
+args=(-d "${device}" -y "${DEV_KEY}" -l 3 -w)
+case "${mode}" in
+ debug) ;;
+ release) args+=(-r) ;;
+ test)
+ jungles="${jungles};/work/test.jungle"
+ args+=(-t)
+ ;;
+ *) die "unknown build mode '${mode}' (use debug, release or test)" ;;
+esac
+
+out_dir="${BUILD_DIR}/${mode}"
+mkdir -p "${out_dir}"
+compile_log="${out_dir}/${device}.log"
+
+if ! monkeyc -f "${jungles}" -o "${out_dir}/${device}.prg" "${args[@]}" >"${compile_log}" 2>&1; then
+ cat "${compile_log}" >&2
+ die "compile failed for ${device} (${mode} build)"
+fi
+if grep -q "^WARNING" "${compile_log}"; then
+ grep "^WARNING" "${compile_log}" >&2
+ die "compiler warnings for ${device} (${mode} build); warnings count as errors"
+fi
+log "built build/${mode}/${device}.prg"
diff --git a/docker/scripts/ciq-env.sh b/docker/scripts/ciq-env.sh
index 945d59a..a52db78 100755
--- a/docker/scripts/ciq-env.sh
+++ b/docker/scripts/ciq-env.sh
@@ -1,5 +1,8 @@
#!/usr/bin/env bash
-# Shared helpers for the container scripts. Source this file; don't run it.
+# Author: cirakg & claude ai
+# Summary: Shared helpers for the container scripts (SDK and key lookup, headless display and simulator).
+#
+# Source this file; don't run it.
#
# Paths, as seen inside the container:
# /work the project folder (bind mount)
@@ -12,6 +15,12 @@ KEY_DIR="/work/.cache/keys"
DEV_KEY="${KEY_DIR}/developer_key.der"
BUILD_DIR="/work/build"
+# Virtual display for the headless simulator, and the TCP port (1234, hex
+# 04D2 as in /proc/net/tcp) on which the simulator accepts monkeydo.
+SIM_DISPLAY=":99"
+SIM_PORT_HEX="04D2"
+SIM_START_TIMEOUT_S=60
+
# Print an error and stop. Used for every user-facing failure, so the message
# says what to do next instead of leaving a bare stack trace.
die() {
@@ -47,3 +56,85 @@ ciq_require_device() {
[[ -f "${CIQ_HOME}/Devices/${device}/compiler.json" ]] \
|| die "No device files for '${device}'. Is it in manifest.xml? Then run 'make sdk' to download them."
}
+
+# Start a virtual X display (Xvfb) with the openbox window manager. The window
+# manager matters: screenshots and scripted input find the simulator window
+# through it.
+ciq_start_display() {
+ export DISPLAY="${SIM_DISPLAY}"
+ Xvfb "${DISPLAY}" -screen 0 1600x1200x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
+ local _
+ for _ in $(seq 1 50); do
+ xdpyinfo >/dev/null 2>&1 && break
+ sleep 0.1
+ done
+ xdpyinfo >/dev/null 2>&1 || die "Xvfb did not start (see /tmp/xvfb.log)."
+ openbox >/tmp/openbox.log 2>&1 &
+}
+
+# True when something listens on the simulator's monkeydo port.
+ciq_simulator_listening() {
+ awk -v port=":${SIM_PORT_HEX}" \
+ 'NR > 1 && $4 == "0A" && substr($2, length($2) - 4) == port { found = 1 } END { exit !found }' \
+ /proc/net/tcp
+}
+
+# Start the Connect IQ simulator (needs ciq_require_sdk and ciq_start_display)
+# and wait until it accepts monkeydo connections.
+ciq_start_simulator() {
+ # The simulator's single-instance lock (~/Sim-<user>) stores its process ID
+ # and survives in .cache/home after the container is gone. A later container
+ # 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.
+ rm -f "${HOME}"/Sim-*
+ simulator >/tmp/simulator.log 2>&1 &
+ local pid=$! _
+ for _ in $(seq 1 $((SIM_START_TIMEOUT_S * 5))); do
+ ciq_simulator_listening && return 0
+ kill -0 "${pid}" 2>/dev/null || break
+ sleep 0.2
+ done
+ # The container (and /tmp) disappears after a failure, so show the log now.
+ echo "--- last lines of the simulator log ---" >&2
+ tail -n 25 /tmp/simulator.log >&2 || true
+ die "The Connect IQ simulator did not start within ${SIM_START_TIMEOUT_S} s."
+}
+
+# The simulator's main window ID, for screenshots and scripted input.
+ciq_simulator_window() {
+ xdotool search --name '^CIQ Simulator' | head -n 1
+}
+
+# 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,
+# 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.
+ciq_launch_app() {
+ local device="$1" app_log="$2" start _
+ start="$(date +%s%3N)"
+ monkeydo "${BUILD_DIR}/debug/${device}.prg" "${device}" >"${app_log}" 2>&1 &
+ CIQ_MONKEYDO_PID=$!
+ for _ in $(seq 1 $((FIRST_FRAME_TIMEOUT_S * 20))); do
+ if grep -q "DIAG first_frame" "${app_log}"; then
+ echo "HARNESS first_frame wall_ms=$(($(date +%s%3N) - start))" >>"${app_log}"
+ return 0
+ fi
+ # monkeydo only exits on its own when the app could not run.
+ kill -0 "${CIQ_MONKEYDO_PID}" 2>/dev/null || break
+ sleep 0.05
+ done
+ return 1
+}
+
+# Stop the monkeydo session started by ciq_launch_app.
+ciq_stop_app() {
+ kill "${CIQ_MONKEYDO_PID:-0}" 2>/dev/null || true
+ wait "${CIQ_MONKEYDO_PID:-0}" 2>/dev/null || true
+}
diff --git a/docker/scripts/gen-key.sh b/docker/scripts/gen-key.sh
index 7b99863..015f9d9 100755
--- a/docker/scripts/gen-key.sh
+++ b/docker/scripts/gen-key.sh
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Generate the RSA-4096 developer key that signs every build, never overwriting an existing one.
+#
# Generate the developer key that signs every build (README section 15.6).
#
# Output: .cache/keys/developer_key.der (PKCS#8 DER, what monkeyc wants) and
diff --git a/docker/scripts/lint.sh b/docker/scripts/lint.sh
new file mode 100755
index 0000000..09b8410
--- /dev/null
+++ b/docker/scripts/lint.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: All static checks: project policy, Python lint and types, and strict Monkey C builds without warnings.
+#
+# Usage: lint.sh DEVICE [DEVICE ...]
+#
+# 1. tools/lint_project.py: project rules (headers, no logging, no network, ...)
+# 2. ruff (lint + formatting) and mypy --strict on the Python tools
+# 3. tools/gen_products.py --check: the manifest's products meet the targets
+# 4. Debug and release builds with strict type checking (-l 3) on each device;
+# build-app.sh fails on any compiler warning.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+(($# > 0)) || die "usage: lint.sh DEVICE [DEVICE ...]"
+
+log "project policy"
+python3 /work/tools/lint_project.py /work
+
+log "Python tools: ruff and mypy"
+ruff check /work/tools
+ruff format --check /work/tools
+(cd /work && mypy)
+
+log "product list"
+python3 /work/tools/gen_products.py --check
+
+for device in "$@"; do
+ build-app.sh debug "${device}"
+ build-app.sh release "${device}"
+done
+log "lint passed"
diff --git a/docker/scripts/package.sh b/docker/scripts/package.sh
new file mode 100755
index 0000000..8d58e39
--- /dev/null
+++ b/docker/scripts/package.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Build the signed .iq package for the Connect IQ Store beta, plus release .prg files for sideload tests.
+#
+# Usage: package.sh [DEVICE ...] (devices for the .prg files; default: TEST_DEVICES)
+# Output: build/package/otp-wallet-<version>.iq all 72 products, release, signed
+# build/package/prg/<DEVICE>.prg release builds for sideload tests
+#
+# The .iq is what you upload as a beta app (README section 4). It is built
+# with the same strict settings as every other build: type check level 3 and
+# no compiler warnings.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+ciq_require_sdk
+ciq_require_key
+
+version="$(sed -n 's/^ *const VERSION = "\([0-9.]*\)";.*/\1/p' /work/source/app/AppInfo.mc)"
+[[ -n "${version}" ]] || die "could not read VERSION from source/app/AppInfo.mc"
+
+out="${BUILD_DIR}/package"
+mkdir -p "${out}/prg"
+iq="${out}/otp-wallet-${version}.iq"
+compile_log="${out}/otp-wallet-${version}.log"
+
+log "exporting ${iq#/work/} for every product in manifest.xml (this takes a while)"
+if ! monkeyc -e -r -f "/work/monkey.jungle;/work/icons.jungle" -o "${iq}" -y "${DEV_KEY}" -l 3 -w \
+ >"${compile_log}" 2>&1; then
+ cat "${compile_log}" >&2
+ die "packaging failed"
+fi
+if grep -q "^WARNING" "${compile_log}"; then
+ grep "^WARNING" "${compile_log}" >&2
+ die "compiler warnings while packaging; warnings count as errors"
+fi
+
+devices=("$@")
+((${#devices[@]} > 0)) || read -r -a devices <<<"${TEST_DEVICES:-}"
+for device in "${devices[@]}"; do
+ build-app.sh release "${device}"
+ cp "${BUILD_DIR}/release/${device}.prg" "${out}/prg/${device}.prg"
+done
+
+log "package ready: ${iq#/work/} ($(du -h "${iq}" | cut -f1)); release .prg files in build/package/prg/"
diff --git a/docker/scripts/run-unit-tests.sh b/docker/scripts/run-unit-tests.sh
new file mode 100755
index 0000000..60a8955
--- /dev/null
+++ b/docker/scripts/run-unit-tests.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Build and run the Monkey C unit tests on each test device in the headless simulator.
+#
+# Usage: run-unit-tests.sh DEVICE [DEVICE ...]
+# Output: build/test/results/<DEVICE>.log (console) and <DEVICE>.xml (JUnit).
+#
+# Every test build is compiled first, so a compile error stops the run before
+# the simulator starts. One simulator then runs every device in turn.
+# monkeydo exits with 1 even when all tests pass, so the verdict comes from
+# tools/ciq_test_report.py; the script fails if any device fails.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+# Longest a single device's test run may take before it counts as hung.
+readonly TEST_TIMEOUT_S=600
+
+(($# > 0)) || die "usage: run-unit-tests.sh DEVICE [DEVICE ...]"
+ciq_require_sdk
+ciq_require_key
+
+for device in "$@"; do
+ build-app.sh test "${device}"
+done
+
+ciq_start_display
+ciq_start_simulator
+
+results="${BUILD_DIR}/test/results"
+mkdir -p "${results}"
+failed=0
+for device in "$@"; do
+ log "running unit tests on ${device}"
+ # monkeydo's exit status is ignored on purpose (see above); a timeout leaves
+ # an incomplete log, which the report then rejects.
+ timeout "${TEST_TIMEOUT_S}" monkeydo "${BUILD_DIR}/test/${device}.prg" "${device}" -t \
+ >"${results}/${device}.log" 2>&1 || true
+ python3 /work/tools/ciq_test_report.py "${results}/${device}.log" \
+ --device "${device}" --junit "${results}/${device}.xml" || failed=1
+done
+
+if ((failed)); then
+ die "unit tests failed; console logs are in build/test/results/"
+fi
+log "unit tests passed on: $*"
diff --git a/docker/scripts/screenshots.sh b/docker/scripts/screenshots.sh
new file mode 100755
index 0000000..3fc52f5
--- /dev/null
+++ b/docker/scripts/screenshots.sh
@@ -0,0 +1,48 @@
+#!/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.
+#
+# 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.
+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}"
+done
+
+ciq_start_display
+ciq_start_simulator
+
+out="${BUILD_DIR}/screenshots"
+mkdir -p "${out}"
+approve=()
+[[ "${APPROVE:-0}" == 1 ]] && approve=(--approve)
+failed=0
+for device in "$@"; do
+ if ! ciq_launch_app "${device}" "${out}/${device}.log"; then
+ ciq_stop_app
+ log "no first frame on ${device}; see build/screenshots/${device}.log"
+ failed=1
+ continue
+ fi
+ sleep "${SETTLE_S}"
+ mkdir -p "${out}/raw"
+ import -window "$(ciq_simulator_window)" "${out}/raw/${device}-window.png"
+ 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/"
+log "screenshots checked on: $*"
diff --git a/docker/scripts/sdk-setup.sh b/docker/scripts/sdk-setup.sh
index 278b060..1f14c58 100755
--- a/docker/scripts/sdk-setup.sh
+++ b/docker/scripts/sdk-setup.sh
@@ -1,4 +1,7 @@
#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Interactive `make sdk`: accept Garmin's agreements, log in, download the SDK and device files.
+#
# Interactive first-time setup behind `make sdk` (README section 15.3).
#
# 1. Garmin's agreements: you read them in your browser and accept them here.
diff --git a/docker/scripts/sim-vnc.sh b/docker/scripts/sim-vnc.sh
new file mode 100755
index 0000000..207db09
--- /dev/null
+++ b/docker/scripts/sim-vnc.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Author: cirakg & claude ai
+# Summary: Run the app in the simulator and show the simulator in your browser through noVNC.
+#
+# Usage: sim-vnc.sh DEVICE (the Makefile publishes port 6080 on 127.0.0.1 only)
+# View: http://127.0.0.1:6080/vnc.html, then "Connect".
+#
+# The VNC server listens only inside the container (localhost, no password);
+# the only way in is the noVNC web socket that the Makefile publishes on your
+# machine's 127.0.0.1. Stop with Ctrl-C.
+set -euo pipefail
+# shellcheck source=ciq-env.sh
+source "$(dirname "${BASH_SOURCE[0]}")/ciq-env.sh"
+
+readonly VNC_PORT=5900
+readonly NOVNC_PORT=6080
+
+device="${1:?usage: sim-vnc.sh DEVICE}"
+ciq_require_sdk
+ciq_require_key
+build-app.sh debug "${device}"
+
+ciq_start_display
+ciq_start_simulator
+x11vnc -display "${DISPLAY}" -localhost -nopw -forever -shared -quiet -rfbport "${VNC_PORT}" \
+ >/tmp/x11vnc.log 2>&1 &
+websockify --web /usr/share/novnc "${NOVNC_PORT}" "localhost:${VNC_PORT}" >/tmp/websockify.log 2>&1 &
+
+cat >&2 <<EOF
+
+ Simulator running with the app on ${device}.
+ Open http://127.0.0.1:${NOVNC_PORT}/vnc.html in your browser and press Connect.
+ The app's console output follows below. Press Ctrl-C to stop.
+
+EOF
+monkeydo "${BUILD_DIR}/debug/${device}.prg" "${device}"
+# monkeydo returns when the app exits; keep the simulator up until Ctrl-C.
+log "the app exited; the simulator stays up until you press Ctrl-C"
+wait
diff --git a/pyproject.toml b/pyproject.toml
index c92bed4..ba959eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,6 @@
+# Author: cirakg & claude ai
+# Summary: Settings for pytest, ruff and mypy, which check the Python tools in tools/.
+#
# Configuration for the Python tools in tools/ (they run inside the dev image).
# This is not a Python package; the file only configures pytest, ruff and mypy.
# Caches go to build/ so the source tree stays clean.
@@ -6,6 +9,8 @@
testpaths = ["tools/tests"]
cache_dir = "build/.pytest_cache"
addopts = "-p no:cacheprovider"
+# Warnings are errors here too (README section 17.7).
+filterwarnings = ["error"]
[tool.ruff]
line-length = 120