diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..432e258 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,24 @@ +name: unit tests + +# The fast half of tools/run_tests.sh: the test_uvk5_*.py unit tests plus check_docs.py. +# It needs no emulator, no firmware and no QEMU build, so it runs on a bare runner -- +# which is the point. Before this existed, nothing ran on anyone else's machine, and the +# suite quietly grew machine-specific defaults that a fresh clone could not satisfy. + +on: + push: + branches: [master] + pull_request: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: dependencies + run: python -m pip install -r requirements-dev.txt + - name: unit tests, and the docs against the code + run: bash tools/run_tests.sh -q diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 397b30c..b9c8231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Short version: build it, run the tests, and do not commit firmware or radio data QEMU_SRC=~/src/qemu-7.2 bash tools/setup_qemu.sh # patch a QEMU tree and build it python3 tools/fetch_firmware.py # a release image to run python3 tools/make_flash.py # the flash image it reads settings from + pip install -r requirements-dev.txt # flask, for the web UI tests bash tools/run_tests.sh -q # fast; no emulator needed bash tools/run_tests.sh # everything; needs the tree above @@ -24,8 +25,18 @@ works where the platform has unix sockets and TCP where it does not, and `tools/uvk5_testenv.py` finds a QEMU, a firmware and a gdb, skipping with a reason when one is absent. Use them rather than hardcoding a path or a socket family. +## What runs automatically + +`.github/workflows/unit.yml` runs `tools/run_tests.sh -q` on every push and pull request -- +the unit tests and the documentation check, which need nothing but Python. If you add a +test that needs an emulator, it belongs in the slow half of the runner, not here; if you +add one that needs nothing, make sure it is picked up by the `-q` path so CI covers it. + +`Dockerfile` gives you the same environment locally. + ## What not to commit + - **Firmware of any kind**, including released images, localised builds and bootloader dumps. `tools/fetch_firmware.py` fetches what a test needs into `assets/firmware/`, which is ignored. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..973415a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# A box that can run the suite, and build the machine if you give it a QEMU tree. +# +# docker build -t uvk5 . && docker run --rm uvk5 # unit tests +# docker build -t uvk5 . && docker run --rm uvk5 bash tools/run_tests.sh +# +# The emulator tests need a patched QEMU build. Fetch a QEMU 7.2 tree first and mount it, +# because tools/setup_qemu.sh patches a tree rather than downloading one: +# +# docker run --rm -v /path/to/qemu-7.2:/qemu-7.2 -e QEMU_SRC=/qemu-7.2 uvk5 \ +# bash -lc 'bash tools/setup_qemu.sh && bash tools/run_tests.sh' + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash build-essential git ca-certificates curl \ + ninja-build pkg-config python3 \ + libglib2.0-dev libpixman-1-dev libfdt-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . /src + +# Fast suite on build: it must pass without a QEMU tree, because that is what a +# contributor has on their first day. +RUN bash tools/run_tests.sh -q + +CMD ["bash", "tools/run_tests.sh"] diff --git a/README.md b/README.md index 056da2f..e6227bf 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,25 @@ keypresses silently stop working. Run the test after touching that code; kept because they are quick to reach for, not because they are polished) harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A) +### Running it elsewhere: CI and a container + +`.github/workflows/unit.yml` installs `requirements-dev.txt` (flask) and runs `tools/run_tests.sh -q` +on every push and pull request: +the `test_uvk5_*.py` unit tests plus `check_docs.py`, which need no emulator, no firmware and +no QEMU build. That path is what keeps the suite honest on a machine that is not this one -- +before it existed, the runner's default paths were the author's, and a fresh clone could not +run anything without editing it. + +`Dockerfile` builds a box with the same tooling, and can run the emulator tests if you give +it a QEMU tree, because `tools/setup_qemu.sh` patches a tree rather than downloading one: + + docker build -t uvk5 . && docker run --rm uvk5 # unit tests + docker run --rm -v /path/to/qemu-7.2:/qemu-7.2 -e QEMU_SRC=/qemu-7.2 uvk5 \ + bash -lc 'bash tools/setup_qemu.sh && bash tools/run_tests.sh' + +The `file:line` checks in `check_docs.py` need the firmware sources, which are not in this +repository. Without them the checker skips those checks and says so; point `UVK5_FW_DIR` at +a tree to have them run. ## What is not in this repository Two things are deliberately absent, and neither should be committed: diff --git a/README.zh-CN.md b/README.zh-CN.md index 5e5b8e4..504ed16 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,6 +106,22 @@ VFO 重算了状态。在真机上你只看到"什么都没发生",在这里 留着是因为随手就能用,不是因为它们打磨过) harness/, stubs/, shim/, tests/ CW 时序链的宿主机构建(阶段 A) +### 在别的机器上跑:CI 与容器 + +`.github/workflows/unit.yml` 会安装 `requirements-dev.txt`(flask)并跑 `tools/run_tests.sh -q`:即 +`test_uvk5_*.py` 单元测试加上 `check_docs.py`,它们不需要模拟器、不需要固件、也不需要 +编译 QEMU。这条路才是让套件在**别人的机器**上保持诚实的东西——在它出现之前,runner 的默认 +路径是作者本人的,新克隆不先改文件就什么也跑不了。 + +`Dockerfile` 构建同样的工具环境;若把 QEMU 源码树挂进去,还能跑模拟器测试(因为 +`tools/setup_qemu.sh` 是给已有源码树打补丁,而不是下载一份): + + docker build -t uvk5 . && docker run --rm uvk5 # 单元测试 + docker run --rm -v /path/to/qemu-7.2:/qemu-7.2 -e QEMU_SRC=/qemu-7.2 uvk5 \ + bash -lc 'bash tools/setup_qemu.sh && bash tools/run_tests.sh' + +`check_docs.py` 里的 `file:line` 检查需要固件源码,而它不在本仓库里。没有它时检查器会**跳过** +那部分并明确说明;把 `UVK5_FW_DIR` 指向一份源码树即可让它生效。 ## 仓库里没有什么 有两类东西是刻意不放的,也都不应该提交: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1a6df3b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# What the fast half of tools/run_tests.sh needs beyond the standard library. +# The emulator half needs nothing from pip; it needs a QEMU build (tools/setup_qemu.sh). +flask diff --git a/tools/check_docs.py b/tools/check_docs.py index 0a95731..b277caf 100755 --- a/tools/check_docs.py +++ b/tools/check_docs.py @@ -36,7 +36,11 @@ SIM = pathlib.Path(__file__).resolve().parent.parent # Where the firmware sources are. Override when the tree is not at the default # path -- without that the checker cannot run anywhere but the machine it was # written on, and the file:line checks are the ones that catch drifting prose. -FW = pathlib.Path(os.environ.get("UVK5_FW_DIR", "/root/uvk5-port/uvk5-sat/App")) +# Firmware sources are not in this repository (see the README), so the default is a +# sibling of this checkout and the checks that need the tree are skipped without it. +# A fresh clone used to see thirteen failures it could do nothing about. +_fw_env = os.environ.get("UVK5_FW_DIR") +FW = pathlib.Path(_fw_env) if _fw_env else (SIM.parent / "uvk5-port" / "uvk5-sat" / "App") PAIRS = [ ("README.md", "README.zh-CN.md"), @@ -185,6 +189,11 @@ def check_documented_flags(): def check_line_refs(): print("firmware file:line references must point at what the docs claim") + if not FW.is_dir(): + print(f" SKIP no firmware tree at {FW}; set UVK5_FW_DIR to check " + f"{len(LINE_REFS)} file:line references") + return + for (name, line), needle in sorted(LINE_REFS.items()): path = resolve_fw(name) if path is None: diff --git a/tools/gpio_watch.py b/tools/gpio_watch.py index 6e165cb..3c984a6 100644 --- a/tools/gpio_watch.py +++ b/tools/gpio_watch.py @@ -24,7 +24,14 @@ QMP_SOCKET = "/tmp/uvk5-qmp.sock" GPIOB_BASE = 0x50000400 GPIO_IDR = 0x10 GPIO_ODR = 0x14 -ELF = "/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf" +ELF = os.environ.get("ELF") or os.environ.get("UVK5_FIRMWARE") or "" +if not ELF: # the same search order as tools/uvk5_testenv.py + import glob as _glob + for _pat in ("assets/firmware/*.elf", "work/*.elf"): + _hits = sorted(_glob.glob(str(pathlib.Path(__file__).resolve().parent.parent / _pat))) + if _hits: + ELF = _hits[0] + break class Qmp: diff --git a/tools/press_and_shot.sh b/tools/press_and_shot.sh index ff46df7..fd77317 100755 --- a/tools/press_and_shot.sh +++ b/tools/press_and_shot.sh @@ -7,7 +7,7 @@ set -uo pipefail TOOLS="$(cd "$(dirname "$0")" && pwd)" -OUT="${OUT:-/root/vm_screen.png}" +OUT="${OUT:-$PWD/vm_screen.png}" HOLD="${HOLD:-3}" SETTLE="${SETTLE:-3}" diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 08ad822..55d6f13 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -18,7 +18,10 @@ set -u HERE=$(cd "$(dirname "$0")" && pwd) SIM=$(dirname "$HERE") -QEMU_SRC=${QEMU_SRC:-/root/qemu-build/qemu-7.2+dfsg} +# A sibling of this checkout by default, which is where tools/setup_qemu.sh puts it. +# The two defaults have to agree: they disagreed once, so a fresh clone rebuilt nothing +# and reported a build that was not there. +QEMU_SRC=${QEMU_SRC:-$SIM/../qemu-7.2} # One interpreter name, resolved once. This script used to spell $PY on every # line, which is a Windows problem (there is a python, not a python3) and a @@ -26,7 +29,7 @@ QEMU_SRC=${QEMU_SRC:-/root/qemu-build/qemu-7.2+dfsg} # from a runner that passes, which is the failure mode this script exists to avoid. PY=${PYTHON:-} if [ -z "$PY" ]; then - for candidate in $PY python; do + for candidate in python3 python; do if command -v "$candidate" >/dev/null 2>&1; then PY=$candidate; break; fi done fi diff --git a/tools/test_bk4819_readback.sh b/tools/test_bk4819_readback.sh index 0e7938c..84f6fac 100755 --- a/tools/test_bk4819_readback.sh +++ b/tools/test_bk4819_readback.sh @@ -13,24 +13,44 @@ # about alignment, not interrupt semantics. set -u -QEMU=${QEMU:-/root/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm} -ELF=${ELF:-/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf} HERE=$(cd "$(dirname "$0")" && pwd) SIM=$(dirname "$HERE") SRC="$SIM/qemu/py32f071.c" -QSRC=/root/qemu-build/qemu-7.2+dfsg/hw/arm/py32f071.c + +# Same rules as the python tests (tools/uvk5_testenv.py): QEMU, GDB and ELF come from the +# environment, then PATH, then the checkout -- and a missing one is a SKIP with a reason +# rather than a failure. These were the author's home paths, which is why this was the one +# test that could not run anywhere else. +QEMU=${QEMU:-$(command -v qemu-system-arm || true)} +GDB=${GDB:-$(command -v gdb-multiarch || command -v arm-none-eabi-gdb || true)} +QEMU_SRC=${QEMU_SRC:-$SIM/../qemu-7.2} +QSRC="$QEMU_SRC/hw/arm/py32f071.c" +if [ -z "${ELF:-}" ]; then + for cand in "$SIM"/assets/firmware/*.elf "$SIM"/work/*.elf; do + [ -e "$cand" ] && ELF=$cand + done +fi +for t in "$QEMU" "$GDB" "${ELF:-}"; do + [ -n "$t" ] && [ -e "$t" ] || { + echo "SKIP missing ${t:-a prerequisite} (set QEMU, GDB and ELF; see the README Quick start)" + exit 0 + } +done + +# QMP travels over a unix socket here and a Windows QEMU cannot create one: say so rather +# than letting the launch fail and calling it a firmware problem. +if ! python3 -c "import socket; socket.socket(socket.AF_UNIX)" 2>/dev/null; then + echo "SKIP no unix sockets on this platform, so -qmp unix: is unavailable" + exit 0 +fi SEED=0x1248 PORT=1259 SOCK=/tmp/bk-readback.sock IMG=/tmp/bk-readback.img -for t in "$QEMU" "$ELF"; do - [ -e "$t" ] || { echo "SKIP missing $t"; exit 0; } -done - cp "$SRC" /tmp/bk-readback-orig.c -trap 'cp /tmp/bk-readback-orig.c "$SRC"; cp "$SRC" "$QSRC" 2>/dev/null || true; rm -f "$IMG" "$SOCK"' EXIT +trap 'cp /tmp/bk-readback-orig.c "$SRC"; [ -d "$QEMU_SRC/build" ] && cp "$SRC" "$QSRC" 2>/dev/null; rm -f "$IMG" "$SOCK" "$GDBFILE" 2>/dev/null' EXIT python3 - "$SRC" "$SEED" <<'PY' import sys @@ -42,8 +62,10 @@ if needle not in s: open(src, "w", encoding="utf-8").write(s.replace(needle, f"{needle}\n s->regs[0x0C] = {seed};", 1)) PY -cp "$SRC" "$QSRC" -if (cd /root/qemu-build/qemu-7.2+dfsg/build && ninja qemu-system-arm 2>&1 \ +if [ -d "$QEMU_SRC/build" ]; then + cp "$SRC" "$QSRC" +fi +if [ -d "$QEMU_SRC/build" ] && (cd "$QEMU_SRC/build" && ninja qemu-system-arm 2>&1 \ | grep -qE 'FAILED|error:'); then echo "FAIL build error" exit 1 @@ -59,7 +81,8 @@ sleep 24 # A command file, not a pile of -ex flags: a `commands` block cannot survive being # passed that way, and the failure looks exactly like "the firmware never read it". -cat > /tmp/bk-readback.gdb < "$GDBFILE" </dev/null \ +GOT=$(timeout 45 "$GDB" -batch -x "$GDBFILE" "$ELF" 2>/dev/null \ | grep -oE 'GOT 0x[0-9A-Fa-f]{4}' | head -1) kill $QPID 2>/dev/null || true diff --git a/tools/test_webui.py b/tools/test_webui.py index 6af0e09..b6766b6 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -10,7 +10,22 @@ import time import unittest import uvk5_image -import webui +# Flask is the one thing the fast suite needs from pip, so it is not always there. +# A missing dependency is a skip with a reason, not a failure -- but at *module* level +# raising SkipTest is reported as an error rather than a skip, so the check goes in +# setUpModule(), which unittest treats as skipping the whole file. +webui = None +try: + import webui # noqa: F811 +except ImportError: + pass + + +def setUpModule(): + if webui is None: + raise unittest.SkipTest( + "the web UI tests need flask; install it with " + "pip install -r requirements-dev.txt") from uvk5_supervisor import FlashSlot