Run anywhere: no author paths left, and CI that proves it

tools/run_tests.sh defaults QEMU_SRC to a sibling of the checkout, which is where setup_qemu.sh puts it; the two defaults disagreed, so a fresh clone rebuilt nothing and reported a build that was not there. The interpreter list was also reading an empty $PY.

tools/test_bk4819_readback.sh was the last test with the author's paths, and the only one that could not run elsewhere. It now takes QEMU, GDB and ELF from the environment or PATH like the python tests, skips with a reason when one is missing, and says so on a platform whose QEMU cannot make the unix socket it uses.

tools/check_docs.py points UVK5_FW_DIR at a sibling and skips the file:line checks, with a message, when there is no firmware tree -- a fresh clone used to see thirteen failures it could do nothing about.

Added .github/workflows/unit.yml (the fast half of run_tests.sh on every push and PR), requirements-dev.txt for the one pip dependency, and a Dockerfile. Flask is not always installed, so test_webui now skips through setUpModule rather than erroring.
This commit is contained in:
mckero committed 2026-10-01 15:12:45 +08:00
1 parent 1308c98769
commit ae8b48c85a
12 files changed
+175 -17

No files matched your search

+24
View File
@@ -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
+11
View File
@@ -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 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/fetch_firmware.py # a release image to run
python3 tools/make_flash.py # the flash image it reads settings from 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 -q # fast; no emulator needed
bash tools/run_tests.sh # everything; needs the tree above 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 `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. 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 ## What not to commit
- **Firmware of any kind**, including released images, localised builds and bootloader - **Firmware of any kind**, including released images, localised builds and bootloader
dumps. `tools/fetch_firmware.py` fetches what a test needs into `assets/firmware/`, dumps. `tools/fetch_firmware.py` fetches what a test needs into `assets/firmware/`,
which is ignored. which is ignored.
+28
View File
@@ -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"]
+19
View File
@@ -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) 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) 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 ## What is not in this repository
Two things are deliberately absent, and neither should be committed: Two things are deliberately absent, and neither should be committed:
+16
View File
@@ -106,6 +106,22 @@ VFO 重算了状态。在真机上你只看到"什么都没发生",在这里
留着是因为随手就能用,不是因为它们打磨过) 留着是因为随手就能用,不是因为它们打磨过)
harness/, stubs/, shim/, tests/ CW 时序链的宿主机构建(阶段 A) 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` 指向一份源码树即可让它生效。
## 仓库里没有什么 ## 仓库里没有什么
有两类东西是刻意不放的,也都不应该提交: 有两类东西是刻意不放的,也都不应该提交:
+3
View File
@@ -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
+10 -1
View File
@@ -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 # 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 # 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. # 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 = [ PAIRS = [
("README.md", "README.zh-CN.md"), ("README.md", "README.zh-CN.md"),
@@ -185,6 +189,11 @@ def check_documented_flags():
def check_line_refs(): def check_line_refs():
print("firmware file:line references must point at what the docs claim") 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()): for (name, line), needle in sorted(LINE_REFS.items()):
path = resolve_fw(name) path = resolve_fw(name)
if path is None: if path is None:
+8 -1
View File
@@ -24,7 +24,14 @@ QMP_SOCKET = "/tmp/uvk5-qmp.sock"
GPIOB_BASE = 0x50000400 GPIOB_BASE = 0x50000400
GPIO_IDR = 0x10 GPIO_IDR = 0x10
GPIO_ODR = 0x14 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: class Qmp:
+1 -1
View File
@@ -7,7 +7,7 @@
set -uo pipefail set -uo pipefail
TOOLS="$(cd "$(dirname "$0")" && pwd)" TOOLS="$(cd "$(dirname "$0")" && pwd)"
OUT="${OUT:-/root/vm_screen.png}" OUT="${OUT:-$PWD/vm_screen.png}"
HOLD="${HOLD:-3}" HOLD="${HOLD:-3}"
SETTLE="${SETTLE:-3}" SETTLE="${SETTLE:-3}"
+5 -2
View File
@@ -18,7 +18,10 @@ set -u
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
SIM=$(dirname "$HERE") 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 # 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 # 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. # from a runner that passes, which is the failure mode this script exists to avoid.
PY=${PYTHON:-} PY=${PYTHON:-}
if [ -z "$PY" ]; then 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 if command -v "$candidate" >/dev/null 2>&1; then PY=$candidate; break; fi
done done
fi fi
+34 -11
View File
@@ -13,24 +13,44 @@
# about alignment, not interrupt semantics. # about alignment, not interrupt semantics.
set -u 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) HERE=$(cd "$(dirname "$0")" && pwd)
SIM=$(dirname "$HERE") SIM=$(dirname "$HERE")
SRC="$SIM/qemu/py32f071.c" 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 SEED=0x1248
PORT=1259 PORT=1259
SOCK=/tmp/bk-readback.sock SOCK=/tmp/bk-readback.sock
IMG=/tmp/bk-readback.img 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 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' python3 - "$SRC" "$SEED" <<'PY'
import sys 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)) open(src, "w", encoding="utf-8").write(s.replace(needle, f"{needle}\n s->regs[0x0C] = {seed};", 1))
PY PY
if [ -d "$QEMU_SRC/build" ]; then
cp "$SRC" "$QSRC" cp "$SRC" "$QSRC"
if (cd /root/qemu-build/qemu-7.2+dfsg/build && ninja qemu-system-arm 2>&1 \ fi
if [ -d "$QEMU_SRC/build" ] && (cd "$QEMU_SRC/build" && ninja qemu-system-arm 2>&1 \
| grep -qE 'FAILED|error:'); then | grep -qE 'FAILED|error:'); then
echo "FAIL build error" echo "FAIL build error"
exit 1 exit 1
@@ -59,7 +81,8 @@ sleep 24
# A command file, not a pile of -ex flags: a `commands` block cannot survive being # 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". # passed that way, and the failure looks exactly like "the firmware never read it".
cat > /tmp/bk-readback.gdb <<GDB GDBFILE=$(mktemp)
cat > "$GDBFILE" <<GDB
set confirm off set confirm off
set pagination off set pagination off
set height 0 set height 0
@@ -80,7 +103,7 @@ end
continue continue
GDB GDB
GOT=$(timeout 45 gdb-multiarch -batch -x /tmp/bk-readback.gdb "$ELF" 2>/dev/null \ GOT=$(timeout 45 "$GDB" -batch -x "$GDBFILE" "$ELF" 2>/dev/null \
| grep -oE 'GOT 0x[0-9A-Fa-f]{4}' | head -1) | grep -oE 'GOT 0x[0-9A-Fa-f]{4}' | head -1)
kill $QPID 2>/dev/null || true kill $QPID 2>/dev/null || true
+16 -1
View File
@@ -10,7 +10,22 @@ import time
import unittest import unittest
import uvk5_image 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 from uvk5_supervisor import FlashSlot