diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85094e..038ab7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - openssl xz-utils unzip binutils file squashfs-tools + openssl xz-utils unzip binutils file squashfs-tools python3-pytest python3-jinja2 # Syntax and the brick lint are the first thing run-tests.sh does, so # they are not repeated here. @@ -52,19 +52,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - abi: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install binutils - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends binutils file - - name: MIPS ABI checks - # Skips cleanly when no MIPS binaries are present (they are build - # artifacts and are never committed). - run: ./test/test-abi.sh - # Does the repo have access to the stock firmware? Secrets cannot be read # from a job-level `if`, so decide it in a step and fan out from there. have-firmware: diff --git a/Makefile b/Makefile index 5a3e550..9138975 100644 --- a/Makefile +++ b/Makefile @@ -61,9 +61,9 @@ endif .DEFAULT_GOAL := help .PHONY: help image shell build vendor \ - rootfs verify test test-lint test-install test-applets \ + rootfs verify test test-py test-install \ printer-image printer-image-push \ - test-recovery test-ui test-mcu test-ash test-abi test-macros test-chamber test-basecfg test-model release clean distclean + test-recovery test-mcu release clean distclean help: @echo 'creator5-custom-firmware -- everything runs in Docker' @@ -79,33 +79,27 @@ help: @echo 'Recovery: keep a copy of the STOCK FlashForge .tgz on a spare stick.' @echo 'Flashing it restores every file the mod touches (see make test-recovery).' @echo - @echo 'Test:' - @echo ' make test everything below' - @echo ' make test-lint brick-risk lint' + @echo 'Test -- four gates, no more:' + @echo ' make test all of them' + @echo ' make test-py klipper config + rootfs paths (pytest, no firmware)' @echo ' make test-install end-to-end: USB stick -> update -> reboot' - @echo ' make test-recovery install mod -> flash stock -> back to stock' - @echo ' make test-ui UI startup, crash protection, SAFE-MODE' @echo ' make test-mcu ff-mcu-bringup.py runs on the printer own python' - @echo ' make test-model both models gated + firmware correct' - @echo ' make test-applets every command the payload uses exists on the printer' - @echo ' make test-ash parse the payload with the printer own busybox' - @echo ' make test-abi MIPS ELF ABI checks' - @echo ' make c_helper.so exports everything klippy declares' - @echo ' make test-macros the ff-*.cfg gcode macros parse as Jinja' - @echo ' make test-chamber the chamber heater is off on the Creator 5' - @echo ' make test-basecfg our printer.base.cfg still matches the stock one' + @echo ' make test-recovery install mod -> flash stock -> back to stock' @echo - @echo 'test-install, test-recovery, test-ui, test-mcu and test-ash run inside' - @echo 'a replica of the printer: the real rootfs.squashfs under qemu-mipsel, with' - @echo '/usr/prog installed by FlashForge own updater. test-install goes the' - @echo 'whole way -- the package sits on a real FAT filesystem at /dev/sda1' - @echo 'and the printer own app_startup.sh finds it, installs it, and boots.' - @echo 'They need make rootfs first (or PRINTER_IMAGE), which needs the' - @echo 'stock package.' + @echo 'test-py needs nothing proprietary and runs on any checkout. The other' + @echo 'three run inside a replica of the printer: the real rootfs.squashfs' + @echo 'under qemu-mipsel, with /usr/prog installed by FlashForge own updater.' + @echo 'test-install goes the whole way -- the package sits on a real FAT' + @echo 'filesystem at /dev/sda1 and the printer own app_startup.sh finds it,' + @echo 'installs it, and boots. They need make rootfs first (or PRINTER_IMAGE),' + @echo 'which needs the stock package.' + @echo + @echo 'A gate that cannot run is reported SKIP, not ok, and make test then' + @echo 'fails. ALLOW_SKIP=1 accepts the gap deliberately.' @echo @echo 'Other:' @echo ' make vendor download Mainsail + HelixScreen into vendor/' - @echo ' make rootfs extract the real printer rootfs (enables test-ash)' + @echo ' make rootfs extract the real printer rootfs (enables the replica gates)' @echo ' make image build the build container' @echo ' make shell shell inside it' @echo ' make clean | distclean' @@ -192,34 +186,10 @@ printer-image: image printer-image-push: image @$(RUNSIM) ./test/build-printer-image.sh --push -test-lint: image - @$(RUN) ./test/lint-danger.sh payload payload/init.d - -test-model: image - @$(RUN) ./test/test-model-gate.sh - -# RUNSIM, not RUN: it starts a sibling container with the printer's busybox, -# so it needs the docker socket. Under RUN it always skipped, silently. -test-ash: image - @$(RUNSIM) ./test/test-ash-conformance.sh - -test-abi: image - @$(RUN) ./test/test-abi.sh - -test-macros: image - @$(RUN) python3 ./test/test-macros.py - -test-chamber: image - @$(RUN) python3 ./test/test-chamber.py - -test-basecfg: image - @$(RUN) python3 ./test/test-base-cfg.py - -test-applets: image - @$(RUN) python3 ./test/test-applets.py - -test-ui: image - @$(RUNSIM) ./test/printer-exec.sh ./test/printer/case-ui.sh +# The Python gate. Tests marked `rootfs` need the extracted printer rootfs and +# are skipped without it; everything else runs on any checkout. +test-py: image + @$(RUN) python3 -m pytest ./test -q test-mcu: image @$(RUNSIM) ./test/printer-exec.sh ./test/printer/case-mcu-bringup.sh diff --git a/bin/patch.sh b/bin/patch.sh index 40c7386..83ad14d 100644 --- a/bin/patch.sh +++ b/bin/patch.sh @@ -82,8 +82,9 @@ if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then # [include printer.chamber.cfg] -- Klipper can override an option but # cannot un-declare a section, and the plain Creator 5 has no chamber # heating element, so its heater has to be absent rather than neutralised. - # test/test-base-cfg.py reconstructs the stock file from ours plus the - # Pro's chamber file and fails if FlashForge's has changed. + # NOTE: this cp is why the stock-drift check lives in bin/unpack.sh and + # not in a test -- it overwrites the pristine copy, and the test that used + # to read it afterwards was comparing our file against itself. cp -f payload/klipper/config/printer.base.cfg "$SW/klipper/config/printer.base.cfg" # Anything that differs between models exists once per model, named diff --git a/bin/unpack.sh b/bin/unpack.sh index f1b6230..14c9502 100644 --- a/bin/unpack.sh +++ b/bin/unpack.sh @@ -45,6 +45,34 @@ echo ">> package installs on: ${PKG_MACHINE:-unknown} (PID ${PKG_PID:-unknown})" echo "$STOCK_SW_VER" > work/.stock_sw_ver echo "$SRC" > work/.source_pkg +# ---- has FlashForge changed printer.base.cfg under us? --------------------- +# Our payload/klipper/config/printer.base.cfg is FlashForge's file with the +# chamber block replaced by an include, so a change on their side means a pin +# map, stepper current or endstop we are shipping stale. +# +# This lives HERE, and not in a test, because here is the only moment a +# pristine stock tree exists: bin/patch.sh copies our file straight over +# work/software/klipper/config/printer.base.cfg a few steps later. test-base-cfg.py +# read it afterwards and so spent its life diffing our file against itself -- +# green on a cold tree, red on a second run, and blind to the drift either way. +# +# A warning, not a gate: their file changing is news, not a broken build, and +# it can only happen when you point the build at a new stock package. +STOCK_BASE=work/software/klipper/config/printer.base.cfg +OURS=payload/klipper/config/printer.base.cfg +if [ -f "$STOCK_BASE" ] && [ -f "$OURS" ]; then + # Compare only the section/option lines: comments and blank lines differ by + # design, and our chamber include replaces their heater block. + strip() { grep -vE '^\s*(#|$)' "$1" | grep -vE '^\[(heater_generic|verify_heater) chamber_heater\]|^\[include printer\.chamber\.cfg\]'; } + if strip "$STOCK_BASE" | diff -q - <(strip "$OURS") >/dev/null 2>&1; then + echo ">> printer.base.cfg matches the stock file" + else + echo " !! printer.base.cfg DIFFERS from this package's stock file." >&2 + echo " FlashForge may have changed pins/currents/limits. Review:" >&2 + echo " diff $STOCK_BASE $OURS" >&2 + fi +fi + echo echo "Unpacked. Edit work/software/ then run ./pack.sh" echo " stock software version: $STOCK_SW_VER" diff --git a/docker/Dockerfile.build b/docker/Dockerfile.build index a02e4e5..7bc5959 100644 --- a/docker/Dockerfile.build +++ b/docker/Dockerfile.build @@ -10,7 +10,8 @@ ENV DEBIAN_FRONTEND=noninteractive RUN echo 'Acquire::Retries "3";' > /etc/apt/apt.conf.d/80-retries \ && apt-get update && apt-get install -y --no-install-recommends \ bash coreutils tar xz-utils gzip openssl unzip curl ca-certificates \ - python3 python3-jinja2 binutils file findutils grep sed gawk make git diffutils procps \ + python3 python3-jinja2 python3-pytest \ + binutils file findutils grep sed gawk make git diffutils procps \ squashfs-tools qemu-user-static docker.io shellcheck \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/building.md b/docs/building.md index c93ec1c..1db16a4 100644 --- a/docs/building.md +++ b/docs/building.md @@ -162,7 +162,7 @@ docker/ Dockerfile.build -- the container every target runs in **Tests it** — never ships, and never touched by a build: ``` -test/ the suite, the synthetic fixture, the brick lint +test/ the suite: pytest for the config gate, the synthetic fixture printer/ the replica: binfmt, mount layout, its two Dockerfiles, and the cases that run inside it on the printer's binaries extract-rootfs.sh pulls the real rootfs out of the stock package @@ -180,7 +180,7 @@ package contains any file byte-identical to one in `bin/`, `test/` or `klippy/chelper/c_helper.so` must be MIPS32r2 / nan2008 / o32 or klippy dies on import — `patch.sh` refuses to build a package with anything else, and -`make test-abi` checks every shipped binary. Debian's cross-compiler cannot +`test-install` checks the copy that lands on the machine. Debian's cross-compiler cannot produce one; use the Ingenic/K1 toolchain. See the Klipper fork's own notes. Nothing checks the .so's *symbols* against the klippy tree shipped beside it. diff --git a/docs/hardware-testing.md b/docs/hardware-testing.md index 864a929..4f02fef 100644 --- a/docs/hardware-testing.md +++ b/docs/hardware-testing.md @@ -56,7 +56,7 @@ you unpack. restores a placeholder serial and you may need to put yours back. - [ ] Confirm you can reach the printer's IP. - [ ] `make test` passes. -- [ ] `make rootfs && make test-ash` passes — this parses every script that +- [ ] `make rootfs && make test-install` passes — this parses every script that will run on the printer using the printer's own busybox ash. Two USB sticks, FAT32, both packages at the **root** of the stick (not in a diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 8a04282..55a54f0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -87,7 +87,7 @@ firmware, so each must be built from its own stock package. The filename prefix and the gate inside must agree: `app_startup.sh` globs for the prefix, and `runFirmwareExe.sh` checks the gate. A mismatch means the printer picks the file up and then refuses it. `make verify` and -`make test-model` both check this. +`bin/verify.sh` both check this. ## Recovery diff --git a/docs/printer-replica.md b/docs/printer-replica.md index 243f3a8..0bd9592 100644 --- a/docs/printer-replica.md +++ b/docs/printer-replica.md @@ -197,8 +197,8 @@ logged instead. make rootfs # once -- extracts rootfs.squashfs from the stock package make test-install # install the built package into the replica make test-recovery # mod in, stock package back out -make test-ui # UI startup and crash protection, on the printer's shell -make test-applets # every command the payload uses exists on the printer +make test-mcu # ff-mcu-bringup.py on the printer's own Python +make test-py # klipper config, and rootfs paths when one is present make test # all of it ``` diff --git a/docs/testing.md b/docs/testing.md index c22f8f5..72d200d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,8 +4,8 @@ The suite has two halves, and only one of them can answer the question. **Half 1 — static and packaging.** Needs no printer and no proprietary firmware: a synthetic fixture (`test/fixtures/make-stock-fixture.sh`) -reproduces the package *structure*, so shell syntax, bashism and brick-pattern -lint, the packaging pipeline and the model gates all run in CI on a clean +reproduces the package *structure*, so shell syntax, the bashism pass, the +pytest config gate and the whole packaging pipeline run in CI on a clean machine. **Half 2 — the printer replica.** The real `rootfs.squashfs`, extracted from @@ -34,23 +34,29 @@ is stubbed, and what it still cannot tell you. make test # everything below ``` -| Test | What it does | Replica | +| Gate | What it does | Replica | |---|---|:-:| -| `test-lint` | Scans on-printer scripts for brick patterns: raw block-device writes, `rm -rf` of top-level paths, unguarded `rm -rf $VAR/`, missing backups, a boot chain that could end with no UI | | -| `test-model` | Each package's filename prefix matches the gate inside, the PID matches the model, and the two models ship **different** `firmwareExe` binaries | | -| `test-install` | **End-to-end.** The package sits on a real FAT filesystem exposed as `/dev/sda1`, and the machine's own `app_startup.sh` runs verbatim through three boots: stick in → it installs; stick still in → it installs again (idempotence); stick pulled → the machine boots with the mod running and the stock `ps`-watchdog satisfied. Asserts along the way: UI present and executable, boot scripts unmodified and still parsing, Klipper owned by a service, `c_helper.so` still nan2008 MIPS, user `printer.cfg` preserved, the wrapper unchanged by a re-install | ✓ | -| `test-recovery` | Installs the mod, then flashes the **stock** package, and asserts the machine is genuinely back to stock byte-for-byte and the leftover payload is inert | ✓ | -| `test-ui` | Drives the UI decision logic on the printer's shell: HelixScreen missing, crash-loop, SAFE-MODE latch and release, and that SAFE-MODE means **headless, not a respawn loop** | ✓ | -| `test-applets` | Every command the payload runs exists on the printer. Its busybox has no `timeout`, no `bash`, no `systemctl`; reaching for one is a blank screen at boot | ✓ | -| `test-ash` | Parses every on-printer script with the printer's own busybox 1.31.1 ash | ✓ | -| `test-abi` | Every shipped MIPS binary is `nan2008`/`mips32r2`/`o32` | | +| `test-py` | pytest. The Klipper config gate: every `ff-*.cfg` gcode body parses in **Klipper's** Jinja dialect, and the chamber macros are rendered per model to prove a chamber target is refused on a Creator 5 that has no heating element — plus, with a rootfs present, that every absolute path the payload names exists on the printer | partly | +| `test-install` | **End-to-end.** The package sits on a real FAT filesystem exposed as `/dev/sda1`, and the machine's own `app_startup.sh` runs verbatim through three boots: stick in -> it installs; stick still in -> it installs again (idempotence); stick pulled -> the machine boots with the mod running and the stock `ps`-watchdog satisfied. Asserts along the way: UI present and executable, boot scripts unmodified and still parsing, every installed script `sh -n`-clean under the printer's own busybox, Klipper owned by a service, `c_helper.so` still nan2008 MIPS, user `printer.cfg` preserved, the wrapper unchanged by a re-install | yes | +| `test-mcu` | Runs `ff-mcu-bringup.py` on the printer's **own** Python 3.8.2 in the exact environment `start.sh` sets — the only gate that executes our Python on the real interpreter, and the one that pins the `LD_LIBRARY_PATH` regression that shipped broken once | yes | +| `test-recovery` | Installs the mod, then flashes the **stock** package, and asserts the machine is genuinely back to stock byte-for-byte and the leftover payload is inert | yes | -The replica tests need `make rootfs` first, which extracts the printer's +Four gates, deliberately. `test-install` boots the machine, so it independently +covers what `verify.sh` checks, parses every installed script with the same +busybox that `test-ash` used, and reads `c_helper.so`'s ELF header the way +`test-abi` did — the separate checks were the weaker copies. + +The replica gates need `make rootfs` first, which extracts the printer's genuine root filesystem from the stock package's `kernel-*.tar.xz`. It is never committed — it is FlashForge's firmware. `make test` skips the replica half with a loud message when no stock package is configured; `REQUIRE_PRINTER_SIM=1` turns that skip into a failure. +**A skip is not a pass.** `run-tests.sh` counts skips separately and exits +non-zero if any gate did not run; `ALLOW_SKIP=1` accepts the gap deliberately. +This matters more with four gates than it did with twelve — and it is not +hypothetical, see `test-abi` below. + ## Speed Almost all of a replica run used to be setup, repeated per test case: @@ -77,10 +83,10 @@ firmware. say where it goes — `run-tests.sh` stamps every header with elapsed seconds: ``` -== brick-risk lint == [0s] +== python checks (klipper config) == [0s] == build on the fixture == [1s] == extracting the printer rootfs == [16s] -== applets / ash / ABI / UI safety == [18s] +== python checks (rootfs) / MCU bring-up == [18s] == end-to-end update on the replica == [111s] == recovery: a stock package reverts the mod == [250s] [326s] @@ -118,13 +124,47 @@ Tests that cannot fail are worse than no tests, because they read as coverage: honest but not useful enough to earn its place, and the whole check is gone. - **The hand-rolled bashism grep in `run-tests.sh`.** It knew five constructs; `shellcheck -s dash` knows the whole SC3xxx family and now stands in its - place (the authoritative check is still `test-ash`, on the printer's own - busybox). The same pass gave every replica launcher one shared + place. The same pass gave every replica launcher one shared `test/sim-image.sh` for the docker plumbing, which also fixed two `make test-ash` bugs: it ran without the docker socket (so it always silently skipped), and it never read `test.env`, so it ignored `PRINTER_IMAGE` and rebuilt the local sim image every run. +- **Eight checks, in one pass, once each had been read properly.** The suite + had grown to twelve gates and most of them were the weaker copy of a gate + that survives: + - `test-abi` had **never asserted anything in CI**. `run-tests.sh` deleted + `work/modpayload` immediately before it ran and CI set `KLIPPER_FORK=""`, + so it had no targets on any run, skipped, exited 0 and was printed as + `ok`. `bin/patch.sh` refuses to build a non-nan2008 `c_helper.so` anyway, + and `test-install` reads the ELF header of the file that actually landed. + - `test-macros` used `jinja2.Environment()` — the default `{{ }}` syntax. + The configs contain **no** `{{` at all, only single-brace expressions, so + it validated `{% %}` block structure and never once looked inside an + expression. `test_default_delimiters_are_blind` now pins that. + - `test-ash-conformance` parsed the payload with the printer's busybox; + `test-install` runs `sh -n` over every *installed* script with the same + qemu'd busybox and then greps the boot log for `Syntax error`. + - `test-model-gate` checked what `verify.sh` §8b/§9 checks and what + `pack.sh` already refuses to build. Its header claimed it proved the two + models ship different files; no such check existed in it. + - `test-base-cfg` compared our `printer.base.cfg` against + `work/software/.../printer.base.cfg` — which `bin/patch.sh` overwrites + *with our own file* before the test reads it. It had been diffing our file + against itself: green on a cold tree, red on a second `make test`, and + blind to real drift either way. The comparison moved into `bin/unpack.sh`, + where a pristine stock tree actually exists. + - `test-applets`' command-word scan only extracted the first word of a + simple, unprefixed command: `if timeout 5 foo; then` yielded + `['if','echo','fi']`, so the one failure its docstring named was invisible + to it. Its allowlist had drifted to nine entries that excused nothing. The + absolute-path half survives, because `S50wifi`'s binaries sit on a branch + no simulation reaches. + - `lint-danger`'s structural greps were satisfiable by comments — + `firmwareExe:44` and `S80ui:16` *mention* `helix` and `SAFE-MODE`, so the + checks passed with the logic deleted. + - `case-ui` went with the helix-only decision. + ## Bugs these caught Not theoretical. Writing them caught real bugs before any hardware was @@ -140,3 +180,5 @@ involved: - three checks in the brick lint had been quietly dead for months: two were guarded on a `payload/boot.sh` that no longer existed, and the file glob never matched `firmwareExe` or the `init.d/S*` scripts at all +- a skip counted as a pass for the whole life of the suite, which is how + `test-abi` sat in it for months checking nothing while printing green diff --git a/docs/toolchange.md b/docs/toolchange.md index 5e961a7..ab3aa90 100644 --- a/docs/toolchange.md +++ b/docs/toolchange.md @@ -33,7 +33,7 @@ documented in the file headers. | [`payload/klipper/config/ff-print-macros.cfg`](../payload/klipper/config/ff-print-macros.cfg) | `/usr/data/config/` | `START_PRINT` / `END_PRINT` / `PAUSE` / `RESUME` / `CANCEL_PRINT`, reconstructed from the app's sequences, plus the calibration gate | | [`payload/klipper/config/ff-filament.cfg`](../payload/klipper/config/ff-filament.cfg) | `/usr/data/config/` | `LOAD_FILAMENT` / `UNLOAD_FILAMENT` / `PURGE` — the touchscreen's filament-load sequence (grab tool, purge chute, feed) recovered from the binary; unload is a designed retract (the stock app has none) | | [`payload/klipper/config/ff-runout.cfg`](../payload/klipper/config/ff-runout.cfg) | `/usr/data/config/` | Runout / clog handling: gives the stock `fd_ex*` / `fm_ex*` sensors a `runout_gcode` that pauses a Mainsail print when the **mounted** tool runs out or clogs (the app's E0162 / E0163), optionally after printing through the PTFE buffer (`runout_distance`); `ff_toolchange` arms only the mounted tool's sensors | -| [`payload/klipper/config/printer.base.cfg`](../payload/klipper/config/printer.base.cfg) | `/usr/prog/klipper/config/` | FlashForge's `printer.base.cfg` with the chamber block replaced by `[include printer.chamber.cfg]`. Klipper can override an option but cannot un-declare a section, and the plain Creator 5 has no chamber heating element, so its heater must be **absent** rather than neutralised. `make test-basecfg` reconstructs the stock file from this plus the Pro's chamber file and fails if FlashForge's has changed | +| [`payload/klipper/config/printer.base.cfg`](../payload/klipper/config/printer.base.cfg) | `/usr/prog/klipper/config/` | FlashForge's `printer.base.cfg` with the chamber block replaced by `[include printer.chamber.cfg]`. Klipper can override an option but cannot un-declare a section, and the plain Creator 5 has no chamber heating element, so its heater must be **absent** rather than neutralised. `bin/unpack.sh` compares this against each stock package it unpacks and warns if FlashForge's has changed | | [`printer.chamber.cfg.creator5`](../payload/klipper/config/printer.chamber.cfg.creator5) · [`.creator5pro`](../payload/klipper/config/printer.chamber.cfg.creator5pro) | `/usr/prog/klipper/config/printer.chamber.cfg` | The one per-model difference: the Pro gets `[heater_generic chamber_heater]` + `[verify_heater]` verbatim from FlashForge, the Creator 5 gets only `[temperature_sensor chamber]` on the same pin. Anything that differs between models exists once per model with a `.creator5` / `.creator5pro` suffix and is installed under its real name — **nothing is edited at build time** | | [`payload/klipper/config/ff-chamber.cfg`](../payload/klipper/config/ff-chamber.cfg) | `/usr/data/config/` | `M141` / `M191` for the chamber heater (Klipper has neither, and the stock app drove the chamber only from its own UI), plus the gate: the macros ask Klipper whether `heater_generic chamber_heater` exists, so a non-zero chamber target is refused on a machine that does not declare one. Nothing to keep in sync; identical in every package | | [`docs/notes/`](notes/) | (reference only) | Condensed reverse-engineering notes: what the stock app actually does, with binary addresses | diff --git a/payload/klipper/config/printer.base.cfg b/payload/klipper/config/printer.base.cfg index 20ea3ba..1dfa01e 100644 --- a/payload/klipper/config/printer.base.cfg +++ b/payload/klipper/config/printer.base.cfg @@ -127,8 +127,8 @@ stall_threshold: 0.6 # plain Creator 5 does not. Klipper config can override an option but cannot # un-declare a section, so a heater that does not exist has to be absent from # the file rather than neutralised in it. Everything else here is FlashForge's -# printer.base.cfg verbatim -- test/test-base-cfg.py reconstructs the stock -# file from this plus the Pro's printer.chamber.cfg and fails if they diverge. +# printer.base.cfg verbatim -- bin/unpack.sh compares it against each stock +# package it opens and warns if FlashForge's file has changed. [include printer.chamber.cfg] [stepper_enable] diff --git a/test/applets.allow b/test/applets.allow deleted file mode 100644 index 9719a42..0000000 --- a/test/applets.allow +++ /dev/null @@ -1,16 +0,0 @@ -# Command words the payload may use that are not busybox applets in the -# printer's rootfs. Each one must be justified: either the mod ships it, or a -# firmware package installs it onto /usr/prog. -# -# Anything added here without one of those two reasons is a boot failure -# waiting to happen -- test/test-applets.py exists to force this review. -# -# The list is EMPTY, and that is the healthy state. It used to name -# helix-screen, nginx, python3, klipperDaemon and five others, none of which -# test-applets.py had asked about for a long time: the payload reaches all of -# them through an absolute path (/usr/prog/nginx/sbin/nginx) or a variable -# ($NGINX, $FF_PY), and neither form is a bare command word. Nine entries that -# excused nothing read like nine commands under review, so they are gone. -# -# Add a line here only when test-applets.py actually names a word, and say -# which of the two reasons above covers it. diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..7be7e84 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,55 @@ +"""Shared fixtures for the Python half of the suite. + +Being a package directory with a conftest.py is also what lets these modules +import ffcfg by name. The old scripts were called test-chamber.py and +test-macros.py, and the dash made `import test_macros` impossible, so one of +them reached the other's parser through importlib machinery. Underscores and +a conftest remove the whole problem. +""" +import os +import sys + +import pytest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# TARGET_MACHINE -> the suffix bin/patch.sh derives from it. A model that is +# buildable but missing here would otherwise go untested. +MODELS = {"Creator5Pro": "creator5pro", "Creator5": "creator5"} + +HEATER = "heater_generic chamber_heater" + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "rootfs: needs the printer's extracted rootfs, i.e. the proprietary " + "package. run-tests.sh runs `-m 'not rootfs'` in the half that works " + "on a plain pull request and `-m rootfs` once it has extracted one, " + "so neither lane reports skips it did not expect.") + + +@pytest.fixture(scope="session") +def root(): + return ROOT + + +@pytest.fixture(scope="session") +def cfgdir(): + """payload/klipper/config -- in the repo, so this needs no firmware.""" + return os.path.join(ROOT, "payload", "klipper", "config") + + +@pytest.fixture(scope="session") +def rootfs(): + """The printer's real extracted rootfs, or skip. + + This is the one Python fixture that needs the proprietary package. A skip + here is a gate that did not run: run-tests.sh reports it as SKIP rather + than as a pass, and refuses to call the suite clean. + """ + path = os.path.join(ROOT, "work", "rootfs") + if not os.path.isdir(os.path.join(path, "bin")): + pytest.skip("no printer rootfs -- run 'make rootfs' first") + return path diff --git a/test/ffcfg.py b/test/ffcfg.py index ef741b1..8ab1598 100644 --- a/test/ffcfg.py +++ b/test/ffcfg.py @@ -1,12 +1,11 @@ -"""Shared pieces of the test/ Python checks. +"""Klipper config parsing for the test suite. -`sections()` parses Klipper config the way klippy's own parser sees it; the -reporting helpers keep the PASS/FAIL output format run-tests.sh consumes. +`sections()` parses Klipper config the way klippy's own parser sees it. -This module exists so test-chamber.py and test-base-cfg.py stop -re-implementing the reporting and stop loading test-macros.py through importlib -machinery just to reach its parser (the dash in that filename made a plain -import impossible). +This used to also carry ok()/bad()/finish() reporting helpers, because the +checks were standalone scripts that printed their own PASS/FAIL lines and +returned an exit code. They are pytest tests now, so pytest does the +reporting and only the parser is left. """ import re @@ -40,24 +39,3 @@ def sections(path): opts[key] = m.group(2) if cur: yield cur, opts - - -P, F = [], [] - - -def ok(name): - P.append(name) - print(" \033[32mPASS\033[0m %s" % name) - - -def bad(name, detail=""): - F.append(name) - print(" \033[31mFAIL\033[0m %s" % name) - if detail: - print(" %s" % detail) - - -def finish(): - """Print the summary line; return the process exit code.""" - print("\n %d passed, %d failed" % (len(P), len(F))) - return 1 if F else 0 diff --git a/test/lint-danger.sh b/test/lint-danger.sh deleted file mode 100644 index e8ef162..0000000 --- a/test/lint-danger.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# Scan every script that will RUN ON THE PRINTER for operations that could -# brick it. This is a static gate: it runs before anything is packed. -# -# ./test/lint-danger.sh ... -set -uo pipefail -FAIL=0 -bad() { printf ' \033[31mDANGER\033[0m %s\n %s\n' "$1" "$2"; FAIL=1; } -warn() { printf ' \033[33mWARN\033[0m %s\n %s\n' "$1" "$2"; } -ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; } - -TARGETS=("$@") -[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(payload bin/../payload) - -# firmwareExe and the init.d/S* scripts have no .sh suffix, and an earlier -# version of this glob therefore never scanned them -- the UI wrapper and every -# service script went unchecked. They run on the printer, so they are scanned. -FILES=$(find "${TARGETS[@]}" -type f \ - \( -name '*.sh' -o -name 'run.sh' -o -name 'firmwareExe' \ - -o -name 'S[0-9][0-9]*' \) 2>/dev/null | sort -u) -[ -n "$FILES" ] || { echo "no scripts found in ${TARGETS[*]}"; exit 1; } - -echo "scanning $(echo "$FILES" | wc -l) script(s) for brick risks" -echo - -# --- things that destroy the device outright -------------------------------- -while IFS= read -r f; do - # Writing to raw block devices / MTD / bootloader - if grep -nE '(^|[^#])(dd[[:space:]]+.*of=/dev/(mmcblk|mtd|sd)|mkfs|fdisk[[:space:]]+[^-]|flash_erase|nandwrite)' "$f" >/dev/null; then - bad "$f" "writes to a raw block device / formats a partition" - grep -nE 'dd[[:space:]]+.*of=/dev/|mkfs|flash_erase|nandwrite' "$f" | head -3 | sed 's/^/ /' - fi - # rm -rf on a root-level path - if grep -nE 'rm[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*(/|/usr|/usr/prog|/etc|/bin|/lib|/usr/data)[[:space:]]*($|[;&])' "$f" >/dev/null; then - bad "$f" "rm -rf of a top-level directory" - grep -nE 'rm[[:space:]]+-[a-zA-Z]*[[:space:]]*(/|/usr|/etc)[[:space:]]*($|[;&])' "$f" | head -3 | sed 's/^/ /' - fi - # Unquoted rm with a variable that could be empty -> "rm -rf /" - if grep -nE 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+\$[A-Za-z_]+/' "$f" >/dev/null \ - && ! grep -qE 'refusing to run|:\?|\[ -n "\$[A-Za-z_]+" \]' "$f"; then - warn "$f" "rm -rf \$VAR/... -- if VAR is empty this becomes rm -rf /..." - grep -nE 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+\$[A-Za-z_]+/' "$f" | head -3 | sed 's/^/ /' - fi - # Replacing the init/boot chain without restoring a UI - if grep -nE '(cp|mv|install|tee|sed[[:space:]]+-i|>>?[[:space:]]*)[^|;]*app_startup\.sh' "$f" >/dev/null \ - && ! grep -qE 'firmwareExe|helix' "$f"; then - warn "$f" "modifies app_startup.sh but never mentions a UI launcher" - fi -done <<< "$FILES" - -# --- the boot chain must always end with something on screen ---------------- -# This used to be guarded on `[ -f payload/boot.sh ]`. boot.sh was replaced by -# the firmwareExe wrapper long ago, so the guard was false and the whole check -# silently passed on every run. It is now pinned to the files that exist, and -# their absence is itself a failure. -UIW=payload/firmwareExe -if [ ! -f "$UIW" ]; then - bad "payload/firmwareExe" "missing -- nothing replaces FlashForge's UI binary" -else - grep -q 'helix' "$UIW" \ - && ok "firmwareExe launches HelixScreen" \ - || bad "$UIW" "never mentions HelixScreen -- nothing would drive the screen" - grep -qE '^[[:space:]]*set -e' "$UIW" \ - && warn "$UIW" "an early exit here can leave the printer with no UI" \ - || ok "$UIW has no bare 'set -e'" - # It must hold the foreground: app_startup.sh greps ps for firmwareExe and - # re-execs us if we exit, which would restart every service underneath. - # Matched on the loop, not on `sleep 3600`: the number is an arbitrary - # detail and pinning the check to it turns a change of interval into a - # brick warning. - grep -qE 'while +(true|:) *(;| ) *do' "$UIW" \ - && ok "firmwareExe holds the process when the UI exits" \ - || bad "$UIW" "returns when the UI exits -- app_startup.sh restarts everything" -fi - -UISEL=payload/init.d/S80ui -if [ ! -f "$UISEL" ]; then - bad "payload/init.d/S80ui" "missing -- nothing chooses the UI or latches SAFE-MODE" -else - grep -q 'SAFE-MODE' "$UISEL" \ - && ok "S80ui latches SAFE-MODE on a crash loop" \ - || bad "$UISEL" "no SAFE-MODE latch -- a crash-looping UI cannot be recovered" -fi - -# --- the installer must not hard-fail into a boot loop ---------------------- -for f in payload/run-append.sh; do - [ -f "$f" ] || continue - if grep -qE '^[[:space:]]*set -e' "$f"; then - bad "$f" "'set -e' in an installer fragment aborts run.sh mid-way, leaving a half-installed system" - else - ok "$f has no bare 'set -e'" - fi -done - -# --- every stock file we replace must be backed up first -------------------- -# The backups moved to run-pre.sh, which runs at the TOP of run.sh while the -# files are still stock. This check used to read run-append.sh, where none of -# these names appear any more, so it matched nothing and reported nothing. -# app_startup.sh is deliberately absent: the mod replaces firmwareExe instead -# and never touches the stock boot scripts. -PRE=payload/run-pre.sh -if [ ! -f "$PRE" ]; then - bad "payload/run-pre.sh" "missing -- the stock files are replaced with no backup" -else - grep -q 'BACKUP' "$PRE" \ - && ok "run-pre.sh has a backup step" \ - || bad "$PRE" "no BACKUP step" - for target in start.sh passwd shadow; do - grep -q "$target" "$PRE" \ - && ok "backup covers $target" \ - || bad "$PRE" "$target is replaced but never backed up" - done - grep -q 'backup/stock' "$PRE" \ - && ok "pristine backup/stock kept from the first install" \ - || warn "$PRE" "no pristine copy -- a re-flash overwrites the only stock backup" -fi - -echo -[ "$FAIL" = 0 ] && echo "no brick risks found" || echo "BRICK RISKS FOUND" -exit $FAIL diff --git a/test/printer/case-ui.sh b/test/printer/case-ui.sh deleted file mode 100644 index 687ce00..0000000 --- a/test/printer/case-ui.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/sh -# Exercise the UI decision and crash-protection logic on the printer's own -# shell. -# -# This matters because FlashForge's firmwareExe is retired in favour of -# HelixScreen and there is nothing to fall back to. A UI failure must not cost -# you the machine: ssh comes from the stock /etc/init.d/S50dropbear and is -# unaffected, and the mod has to go headless rather than crash-loop. -# -# The payload under test is mounted at /tmp/payload. -FAIL=0 -ok() { echo " PASS $*"; } -bad() { echo " FAIL $*"; FAIL=1; } -MOD=/usr/data/anvil -SWD=/usr/prog/PROGRAM/software - -setup() { # $1=helix installed? - killall sleep 2>/dev/null - rm -rf $MOD $SWD - mkdir -p $MOD/init.d $MOD/helixscreen/bin /usr/data/logs $SWD - cp /tmp/payload/init.d/S* $MOD/init.d/; chmod +x $MOD/init.d/S* - printf 'MOD_WEB=0\nMOD_SSH=1\n' > $MOD/anvil.conf - [ "$1" = yes ] && { printf '#!/bin/sh\nsleep 300\n' > $MOD/helixscreen/bin/helix-launcher.sh - chmod +x $MOD/helixscreen/bin/helix-launcher.sh; } - return 0 -} -choice() { cat $MOD/.ui-choice 2>/dev/null; } -run_ui() { $MOD/init.d/S80ui start >/tmp/ui.out 2>&1; } - -echo " case 1: helix installed" -setup yes; run_ui -[ "$(choice)" = helix ] && ok "chose helix" || bad "chose '$(choice)', expected helix" - -echo -echo " case 2: helix NOT installed -- headless, not a blank crash loop" -setup no; run_ui -[ "$(choice)" = none ] && ok "chose none" || bad "chose '$(choice)', expected none" -grep -q 'headless' /tmp/ui.out && ok "headless state reported" || bad "headless state not reported" - -echo -echo " case 3: repeated short boots latch SAFE-MODE" -setup yes -echo 3 > $MOD/ui-failures -run_ui -[ -f $MOD/SAFE-MODE ] && ok "SAFE-MODE latched" || bad "SAFE-MODE not latched" -[ "$(choice)" = none ] && ok "SAFE-MODE means no UI at all" || bad "chose '$(choice)', expected none" - -echo -echo " case 4: SAFE-MODE is honoured on the next boot" -rm -f $MOD/ui-failures -run_ui -[ "$(choice)" = none ] && ok "still headless while SAFE-MODE is latched" || bad "SAFE-MODE ignored" -grep -q "$MOD/SAFE-MODE" /tmp/ui.out && ok "the log says which file to delete" \ - || bad "the log never names the SAFE-MODE file" -rm -f $MOD/SAFE-MODE -run_ui -[ "$(choice)" = helix ] && ok "clearing SAFE-MODE re-enables helix" \ - || bad "still '$(choice)' after clearing SAFE-MODE" - -echo -echo " case 5: the wrapper holds the foreground even with no UI" -setup no; run_ui -cp /tmp/payload/firmwareExe $SWD/firmwareExe; chmod +x $SWD/firmwareExe -# The printer's busybox has no `timeout` applet, so hold-and-check by hand. -sh $SWD/firmwareExe >/dev/null 2>&1 & -WPID=$! -sleep 5 -if kill -0 $WPID 2>/dev/null; then - ok "wrapper HOLDS the process instead of exiting (no respawn loop)" - kill $WPID 2>/dev/null -else - bad "wrapper exited -- app_startup.sh would respawn it forever" -fi - -echo -echo " case 6: Klipper is started independently of the UI" -setup yes -mkdir -p /usr/prog/klipper -printf '#!/bin/sh\ntouch /tmp/klipper-started\nsleep 60\n' > /usr/prog/klipper/start.sh -chmod +x /usr/prog/klipper/start.sh -rm -f /tmp/klipper-started -$MOD/init.d/S70klipper start >/dev/null 2>&1 -sleep 2 -[ -f /tmp/klipper-started ] \ - && ok "S70klipper starts Klipper without firmwareExe" \ - || bad "Klipper never started -- a UI swap would leave the printer unable to print" -killall sleep 2>/dev/null - -echo -[ "$FAIL" = 0 ] && echo " UI decision and crash protection behave correctly" \ - || echo " UI SAFETY TESTS FAILED" -exit $FAIL diff --git a/test/run-tests.sh b/test/run-tests.sh index 160ebd1..e9f75a4 100644 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -17,16 +17,35 @@ set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -PASS=0; FAIL=0 +PASS=0; FAIL=0; SKIP=0 # Every section carries the elapsed time. The suite is mostly waiting on qemu # and xz, and without a clock in the output it is guesswork which. T0=$(date +%s) hdr() { printf '\n\033[1m== %s ==\033[0m \033[90m[%ss]\033[0m\n' "$*" "$(( $(date +%s) - T0 ))"; } pass() { printf ' \033[32mok\033[0m %s\n' "$*"; PASS=$((PASS+1)); } fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); } +skip() { printf ' \033[33mSKIP\033[0m %s\n' "$*"; SKIP=$((SKIP+1)); } run() { if "$@" >/tmp/c5t.$$ 2>&1; then pass "$*"; else fail "$*"; tail -25 /tmp/c5t.$$ | sed 's/^/ /'; fi; rm -f /tmp/c5t.$$; } + +# A test that decides it cannot run exits 0 and says "SKIP:". This used to be +# reported as `ok`, so `test-abi.sh` sat in the suite for a long time printing +# green while checking nothing at all -- the wiring deleted work/modpayload +# immediately before it ran, and CI set KLIPPER_FORK="", so it had no targets +# on any run. Four gates now carry the whole suite; a gate that silently does +# not run is the one failure mode that must never look like success, so a skip +# is counted apart from a pass and the summary refuses to call the run clean. sub() { local name="$1"; shift - if "$@" 2>&1 | sed 's/^/ /'; then pass "$name"; else fail "$name"; fi; } + local out="$TMP/sub.$$" + if "$@" >"$out" 2>&1; then + sed 's/^/ /' "$out" + # Both dialects of "did not run": the shell launchers print + # "SKIP:", pytest summarises "N skipped". + if grep -qE '^[[:space:]]*SKIP:|[0-9]+ skipped' "$out" + then skip "$name"; else pass "$name"; fi + else + sed 's/^/ /' "$out"; fail "$name" + fi + rm -f "$out"; } TMP=$(mktemp -d) cleanup() { rm -rf "$TMP"; } @@ -51,13 +70,14 @@ done hdr "no bashisms in the on-printer payload" # The dash dialect of shellcheck knows every "not supported in POSIX sh" -# construct (the SC3xxx family) -- this replaced a hand-rolled grep that knew -# five. It -# still runs without the firmware; the authoritative check remains -# test-ash-conformance.sh, which parses these files with the printer's own -# busybox, but that needs the proprietary rootfs, so on a plain pull request -# this is all there is. dash, not sh: busybox ash, like dash, supports -# `local`, which the payload uses. +# construct (the SC3xxx family). dash, not sh: busybox ash, like dash, +# supports `local`, which the payload uses. +# +# This is now the only syntax gate that runs without the firmware. +# test-ash-conformance.sh used to parse the same files with the printer's own +# busybox, but case-install.sh already runs `sh -n` over every installed +# script with that same qemu'd busybox and greps the boot log for +# "Syntax error", so it was the weaker copy of a check that survives. if command -v shellcheck >/dev/null 2>&1; then BASHISMS=$(shellcheck -s dash -f gcc payload/*.sh payload/init.d/S* payload/firmwareExe 2>&1 \ | grep -E 'SC3[0-9]{3}|SC2039' || true) @@ -68,8 +88,16 @@ else fail "shellcheck not installed (the build image has it -- run through 'make test')" fi -hdr "brick-risk lint" -sub "lint-danger" ./test/lint-danger.sh payload payload/init.d +# The Python half. Needs no firmware -- python3, jinja2 and the configs in the +# repo -- so it runs here, in the half that works on a plain pull request. It +# used to sit behind the rootfs extraction, which meant the one safety-relevant +# check in the suite (a chamber heater declared on a machine that has no +# element for it) never ran on a PR at all. The tests that do need the rootfs +# carry the `rootfs` marker and are deselected here, then run in the replica +# half -- deselected, not skipped, so a skip in this lane still means something +# went wrong rather than being the expected state. +hdr "python checks (klipper config)" +sub "pytest" python3 -m pytest ./test -q -m "not rootfs" # ================================================== packaging, on a fixture == hdr "synthetic stock package" @@ -118,8 +146,10 @@ run ./bin/pack.sh PKG=$(ls -1 work/out/Creator5Pro-*.tgz 2>/dev/null | head -n1) if [ -n "$PKG" ]; then run ./bin/verify.sh "$PKG"; else fail "no package produced"; fi -hdr "model gates" -sub "model gate" ./test/test-model-gate.sh +# The model gate used to get its own section here. bin/verify.sh above already +# compares MACHINE=/PID= inside the package against TARGET_MACHINE (§8b) and +# against the filename prefix (§9), and bin/pack.sh exits on MODEL MISMATCH +# before a package exists at all -- three checks of one invariant. # ============================================================ the replica ==== # Back to the real config for the replica half: it needs the actual stock @@ -145,8 +175,9 @@ if [ -z "$STOCK" ] || [ ! -f "$STOCK" ]; then if [ "${REQUIRE_PRINTER_SIM:-0}" = 1 ]; then fail "no stock package configured -- the replica tests cannot run, and this build requires them" else - printf ' \033[33mskipped\033[0m -- no stock package in config.env.\n' - printf ' These are the tests that decide whether a package bricks a printer.\n' + # Counted, not just narrated: three of the four gates live in here. + skip "the printer replica (no stock package in config.env)" + printf ' These are the gates that decide whether a package bricks a printer.\n' printf ' Set STOCK_TGZ_CREATOR5PRO in config.env and re-run.\n' fi else @@ -158,30 +189,14 @@ else fi if [ -d work/rootfs/bin ]; then - hdr "commands the payload uses exist on the printer" - sub "applets" python3 ./test/test-applets.py - - hdr "busybox ash conformance (printer's own shell)" - sub "ash conformance" ./test/test-ash-conformance.sh - - hdr "MIPS ABI" - sub "abi" ./test/test-abi.sh - - hdr "klipper macros parse" - sub "macros" python3 ./test/test-macros.py - - hdr "chamber heater is gated by model" - sub "chamber gate" python3 ./test/test-chamber.py - - hdr "our printer.base.cfg is still FlashForge's" - sub "base cfg" python3 ./test/test-base-cfg.py + # The rootfs exists now, so the tests that skipped in the Python pass + # above can actually run. Same command, different world. + hdr "python checks, now with the printer rootfs" + sub "pytest (rootfs)" python3 -m pytest ./test -q -m rootfs hdr "MCU bring-up runs on the printer's own Python" sub "mcu bring-up" ./test/printer-exec.sh ./test/printer/case-mcu-bringup.sh - hdr "UI decision and crash protection (on the printer's shell)" - sub "ui safety" ./test/printer-exec.sh ./test/printer/case-ui.sh - hdr "end-to-end update on the printer replica" run ./bin/unpack.sh run ./bin/patch.sh @@ -198,5 +213,15 @@ else fi fi -printf '\n\033[1m%d passed, %d failed\033[0m\n' "$PASS" "$FAIL" +printf '\n\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$PASS" "$FAIL" "$SKIP" + +# A skip is a gate that did not run. With four gates carrying the suite that is +# not a detail to note in passing, so it is not silently forgiven: say what was +# skipped and how to make it stop. ALLOW_SKIP=1 accepts it deliberately, which +# is what a laptop without docker or without the stock package wants. +if [ "$SKIP" -gt 0 ] && [ "${ALLOW_SKIP:-0}" != 1 ]; then + printf '\033[33m%d gate(s) did not run.\033[0m Set STOCK_TGZ_CREATOR5PRO in config.env\n' "$SKIP" + printf 'and make docker available, or pass ALLOW_SKIP=1 to accept the gap.\n' + exit 1 +fi [ "$FAIL" = 0 ] diff --git a/test/test-abi.sh b/test/test-abi.sh deleted file mode 100644 index 5e614c8..0000000 --- a/test/test-abi.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Every binary we ship must be MIPS32r2 / o32 / nan2008, because the Ingenic -# X2000 kernel returns ENOEXEC for legacy-NaN executables. A wrong-ABI binary -# is not a warning on this printer -- it simply will not run. -# -# This reads the ELF header flags. It used to also try executing each binary -# under qemu-mipsel and call that a second level of checking, but the test was -# `qemu "$f" -h || [ $? -lt 126 ]` -- which accepts almost every exit status -# and so could only fail if qemu itself was missing. Running the binaries for -# real is what the printer replica does; see test/sim-install.sh. -# -# ./test/test-abi.sh [dir|file]... -set -uo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" -FAIL=0; CHECKED=0 -ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } -bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; } -note() { printf ' \033[90m....\033[0m %s\n' "$*"; } - -TARGETS=("$@") -if [ ${#TARGETS[@]} -eq 0 ]; then - # assets/mips and assets/dropbear used to be searched here. Nothing in the - # repo has ever produced either: the MIPS binaries the mod ships are staged - # by bin/patch.sh into work/modpayload, and dropbear comes from the stock - # rootfs, not from us. Two directory tests that could never be true. - TARGETS=() - [ -f config.env ] && . ./config.env 2>/dev/null - [ -n "${KLIPPER_FORK:-}" ] && [ -f "$KLIPPER_FORK/klippy/chelper/c_helper.so" ] \ - && TARGETS+=("$KLIPPER_FORK/klippy/chelper/c_helper.so") - [ -d work/modpayload ] && TARGETS+=(work/modpayload) -fi -if [ ${#TARGETS[@]} -eq 0 ]; then - echo " SKIP: no MIPS binaries available to check" - echo " (they are built artifacts, never committed -- see docs)" - exit 0 -fi - -command -v readelf >/dev/null 2>&1 || { echo " SKIP: readelf not installed (binutils)"; exit 0; } - -FILES=$(find "${TARGETS[@]}" -type f 2>/dev/null | while read -r f; do - head -c 4 "$f" 2>/dev/null | grep -q $'\x7fELF' && echo "$f" -done) -[ -n "$FILES" ] || { echo " SKIP: no ELF files found in ${TARGETS[*]}"; exit 0; } - -echo "checking ELF ABI of $(echo "$FILES" | wc -l) binaries" -while IFS= read -r f; do - [ -n "$f" ] || continue - CHECKED=$((CHECKED+1)) - H=$(readelf -h "$f" 2>/dev/null) - FLAGS=$(echo "$H" | sed -n 's/.*Flags: *//p') - MACH=$(echo "$H" | sed -n 's/.*Machine: *//p') - case "$MACH" in - *MIPS*) ;; - *) note "$(basename "$f"): not MIPS ($MACH) -- skipped"; continue ;; - esac - echo "$FLAGS" | grep -q 'nan2008' \ - && ok "$(basename "$f"): nan2008" \ - || bad "$(basename "$f"): legacy NaN -- kernel returns ENOEXEC. Flags: $FLAGS" - echo "$FLAGS" | grep -q 'mips32r2' \ - && ok "$(basename "$f"): mips32r2" \ - || bad "$(basename "$f"): not mips32r2. Flags: $FLAGS" - echo "$FLAGS" | grep -q 'o32' \ - || bad "$(basename "$f"): not o32 ABI. Flags: $FLAGS" -done <<< "$FILES" - -echo -[ "$FAIL" = 0 ] && echo " ABI checks passed ($CHECKED binaries)" || echo " ABI CHECKS FAILED" -exit $FAIL diff --git a/test/test-applets.py b/test/test-applets.py deleted file mode 100644 index b5ded1a..0000000 --- a/test/test-applets.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -"""Every command the payload runs must actually exist on the printer. - -The printer's busybox is a 1.31.1 build with a specific applet set. It has no -`timeout`, no `bash`, no `systemctl`. A script that reaches for one of those -fails at boot, on the machine, with the screen already blank -- so the check -belongs here, against the real extracted rootfs. -""" -import os -import re -import sys - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -RFS = os.path.join(ROOT, "work", "rootfs") - -KEYWORDS = set(""" -if then else elif fi for while until do done case esac in function select time -return exit local export readonly set unset shift eval exec trap break continue -true false echo cd test read printf source wait umask alias command type hash -getopts times ulimit jobs fg bg kill let -""".split()) - -SEPARATORS = re.compile(r"[;&|\n]|\$\(|`|\(\s|\bthen\b|\bdo\b|\belse\b") -WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*") - - -def strip_quotes_and_comments(text): - """Blank out comments and quoted text so prose cannot look like a command.""" - out, i, n = [], 0, len(text) - quote = None - while i < n: - c = text[i] - if quote is None and c == "#" and (not out or out[-1] in " \t\n;&|(`"): - while i < n and text[i] != "\n": - i += 1 - continue - if quote is None and c in "'\"": - quote = c - out.append(" ") - i += 1 - continue - if quote is not None: - if c == "\\" and quote == '"': - out.append(" ") - i += 2 - continue - if c == quote: - quote = None - out.append(" ") - i += 1 - continue - # keep $(...) and `...` inside double quotes: they are real commands - out.append(c if (quote == '"' and c in "$()`") else " ") - i += 1 - continue - out.append(c) - i += 1 - return "".join(out) - - -ARITH = re.compile(r"\$\(\([^()]*\)\)") - - -def command_words(text): - text = strip_quotes_and_comments(text) - while ARITH.search(text): # $((...)) is arithmetic, not a command - text = ARITH.sub(" ", text) - words = [] - for segment in SEPARATORS.split(text): - segment = segment.lstrip(" \t()") - m = WORD.match(segment) - if not m: - continue - word = m.group(0) - rest = segment[m.end():].lstrip() - if rest.startswith("=") or rest.startswith(")"): - continue # assignment, or a case label - words.append(word) - return words - - -def main(): - if not os.path.isdir(os.path.join(RFS, "bin")): - print(" SKIP: no printer rootfs -- run 'make rootfs' first") - return 0 - - avail = set() - for d in ("bin", "sbin", "usr/bin", "usr/sbin"): - p = os.path.join(RFS, d) - if os.path.isdir(p): - avail.update(os.listdir(p)) - - allow = set() - allow_file = os.path.join(ROOT, "test", "applets.allow") - if os.path.exists(allow_file): - for line in open(allow_file): - line = line.split("#")[0].strip() - if line: - allow.add(line) - - files = [] - for dirpath, _, names in os.walk(os.path.join(ROOT, "payload")): - for n in names: - if n.endswith(".sh") or n == "firmwareExe" or re.match(r"^S\d", n): - files.append(os.path.join(dirpath, n)) - files.sort() - - fail = False - for f in files: - src = open(f, encoding="utf-8", errors="replace").read() - funcs = set(re.findall(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\)", src, re.M)) - rel = os.path.relpath(f, ROOT) - for w in sorted(set(command_words(src))): - if w in KEYWORDS or w in funcs or w in avail or w in allow: - continue - print(f" FAIL {rel}: \"{w}\" is not on the printer " - f"and not in test/applets.allow") - fail = True - # Absolute paths into the rootfs must exist. /usr/prog and /usr/data - # live on partitions the rootfs does not contain; the install - # simulation covers those. - for p in sorted(set(re.findall( - r"(?&1 | head -1) -echo " using: $(echo "$V" | cut -c1-46)" -[ "$($ASH uname -m 2>/dev/null)" = mips ] \ - && ok "genuine MIPS userland (uname -m = mips)" \ - || bad "not running the printer userland" - -echo -for f in /payload/firmwareExe /payload/start.sh /payload/run-pre.sh \ - /payload/run-append.sh /payload/init.d/S*; do - [ -f "$f" ] || continue - n=$(basename "$f") - if $ASH sh -n "$f" 2>/tmp/err; then - ok "parses under busybox ash: $n" - else - bad "busybox ash REJECTS $n" - head -3 /tmp/err | sed 's/^/ /' - fi -done - -echo -echo " -- applet behaviour the payload and the stock installer depend on --" -$ASH ps >/dev/null 2>&1 && ok "busybox ps available (UI liveness check)" \ - || bad "no busybox ps -- the UI liveness check degrades" -$ASH head -c 2 /rootfs/bin/busybox >/dev/null 2>&1 \ - && ok "busybox head -c available (used to detect our wrapper script)" \ - || bad "busybox head -c missing" - -# FlashForge's own runFirmwareExe.sh slices the version out of a filename with -# ${file_name:${#start_head}:${version_length}} -- a bash-compat expansion that -# dash rejects. The stock installer only works because this busybox was built -# with ASH_BASH_COMPAT. If a future firmware ships a busybox without it, the -# stock updater breaks and so does ours. -SUB=$($ASH sh -c 'v=software-1.9.7.tar.xz; echo ${v:9:5}' 2>/dev/null) -[ "$SUB" = "1.9.7" ] \ - && ok "busybox ash supports \${var:off:len} (the stock installer needs it)" \ - || bad "busybox ash lacks bash-compat substrings -- the STOCK installer would fail too" - -# The payload has no `timeout` anywhere for a reason: this busybox has no such -# applet. Assert the absence, so nobody 'fixes' a script by adding one. -$ASH timeout 1 true >/dev/null 2>&1 \ - && echo " (this busybox DOES have timeout -- test/applets.allow can be relaxed)" \ - || ok "no timeout applet, as expected (the payload must not use one)" - -echo -[ "$FAIL" = 0 ] && echo " payload is busybox-ash clean" || echo " ASH CONFORMANCE FAILED" -exit $FAIL -INNER diff --git a/test/test-base-cfg.py b/test/test-base-cfg.py deleted file mode 100644 index fa25c2f..0000000 --- a/test/test-base-cfg.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""Our printer.base.cfg must be FlashForge's, minus only the chamber block. - -We ship our own printer.base.cfg because Klipper config can override an option -but cannot un-declare a SECTION, and the plain Creator 5 has no chamber -heating element -- the heater has to be absent from the file, not neutralised -in it. Carrying a copy of someone else's 377-line pin map is only safe if -something proves it is still their file, so this reconstructs the stock one: - - ours (with [include printer.chamber.cfg] resolved to the Pro's variant) - == stock printer.base.cfg, section for section, option for option - -A FlashForge change to printer.base.cfg therefore fails the build rather than -being silently overridden by a stale copy. - - ./test/test-base-cfg.py [stock-printer.base.cfg] -""" -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ffcfg import sections, ok, bad, finish - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CFGDIR = os.path.join(ROOT, "payload", "klipper", "config") -OURS = os.path.join(CFGDIR, "printer.base.cfg") -PRO = os.path.join(CFGDIR, "printer.chamber.cfg.creator5pro") -NONPRO = os.path.join(CFGDIR, "printer.chamber.cfg.creator5") -INCLUDE = "[include printer.chamber.cfg]" -STOCK = sys.argv[1] if len(sys.argv) > 1 else os.path.join( - ROOT, "work", "software", "klipper", "config", "printer.base.cfg") - -def parse(path): - return list(sections(path)) - - -def main(): - if not os.path.exists(STOCK): - print(" SKIP: no stock printer.base.cfg at %s" % STOCK) - print(" (run ./bin/unpack.sh first)") - return 0 - - # --- the reconstruction ------------------------------------------------- - text = open(OURS, encoding="utf-8").read() - if INCLUDE not in text: - bad("our printer.base.cfg includes printer.chamber.cfg") - return 1 - rebuilt = text.replace(INCLUDE, open(PRO, encoding="utf-8").read()) - tmp = OURS + ".rebuilt" - open(tmp, "w", encoding="utf-8").write(rebuilt) - try: - mine, stock = parse(tmp), parse(STOCK) - finally: - os.unlink(tmp) - - mine_names = [n for n, _ in mine] - stock_names = [n for n, _ in stock] - if mine_names == stock_names: - ok("same %d sections, same order, as stock" % len(stock_names)) - else: - only_stock = [n for n in stock_names if n not in mine_names] - only_mine = [n for n in mine_names if n not in stock_names] - bad("same sections, same order, as stock", - "missing %r, extra %r" % (only_stock, only_mine)) - - diffs = [] - for (n, a), (_, b) in zip(mine, stock): - if a != b: - keys = set(a) | set(b) - diffs += ["[%s] %s: %r != %r" % (n, k, a.get(k), b.get(k)) - for k in sorted(keys) if a.get(k) != b.get(k)] - if not diffs: - ok("every option matches stock") - else: - bad("every option matches stock", "; ".join(diffs[:5])) - - # --- and the non-Pro really has no heater ------------------------------- - np = dict(parse(NONPRO)) - heaters = [n for n in np - if n.startswith("heater_generic") or n.startswith("verify_heater")] - if not heaters: - ok("Creator 5 declares no chamber heater at all") - else: - bad("Creator 5 declares no chamber heater at all", repr(heaters)) - - sensor = np.get("temperature_sensor chamber") - if sensor: - ok("Creator 5 still declares the chamber sensor") - else: - bad("Creator 5 still declares the chamber sensor", repr(sorted(np))) - - # The reading must be the stock one: same pin, type and bounds as the - # heater section it replaces, or the chamber would read differently on the - # two models for no reason. - pro = dict(parse(PRO)).get("heater_generic chamber_heater", {}) - if sensor: - mismatch = [k for k in ("sensor_type", "sensor_pin", "min_temp", "max_temp") - if sensor.get(k) != pro.get(k)] - if not mismatch: - ok("Creator 5's sensor matches the Pro's pin, type and bounds") - else: - bad("Creator 5's sensor matches the Pro's pin, type and bounds", - ", ".join("%s: %r != %r" % (k, sensor.get(k), pro.get(k)) - for k in mismatch)) - - return finish() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/test-chamber.py b/test/test-chamber.py deleted file mode 100644 index 0be26aa..0000000 --- a/test/test-chamber.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""The chamber macros must follow the model's own config. - -The Creator 5 Pro has a chamber heating element; the plain Creator 5 does not. -printer.chamber.cfg declares [heater_generic chamber_heater] on the Pro and -only a temperature_sensor on the non-Pro, and ff-chamber.cfg's macros ask -Klipper whether that object exists rather than being told by a flag. - -Syntax alone does not prove the macros branch correctly, so this renders their -bodies through Klipper's own Jinja environment -- delimiters '{%','%}','{','}', -NOT jinja2's defaults -- once per model, with a printer context built from -that model's real config file. - - ./test/test-chamber.py -""" -import glob -import os -import sys - -try: - import jinja2 -except ImportError: - print(" SKIP: jinja2 not installed") - sys.exit(0) - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ffcfg import sections, ok, bad, finish - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CFGDIR = os.path.join(ROOT, "payload", "klipper", "config") -CFG = os.path.join(CFGDIR, "ff-chamber.cfg") -HEATER = "heater_generic chamber_heater" -# TARGET_MACHINE -> the suffix bin/patch.sh derives from it. A model that is -# buildable but missing here would otherwise go untested. -MODELS = {"Creator5Pro": "creator5pro", "Creator5": "creator5"} - -def load(machine): - """ff-chamber.cfg's macros, plus whether this model declares the heater.""" - macros = dict(sections(CFG)) - chamber = os.path.join(CFGDIR, "printer.chamber.cfg.%s" % MODELS[machine]) - if not os.path.exists(chamber): - bad("printer.chamber.cfg.%s exists" % MODELS[machine]) - return None, None - return macros, HEATER in dict(sections(chamber)) - - -def render(macros, has_heater, section, params, rawparams=""): - env = jinja2.Environment("{%", "%}", "{", "}") - said = [] - - def action_respond_info(msg): - said.append(msg) - return "" - - # Klipper's GetStatusWrapper supports `in`; a dict is close enough. - printer = {"gcode_macro _FF_CHAMBER": {"wait_band": 2.0}} - if has_heater: - printer[HEATER] = {"temperature": 25.0, "target": 0.0} - out = env.from_string(macros["gcode_macro " + section]["gcode"]).render( - printer=printer, params=params, rawparams=rawparams, - action_respond_info=action_respond_info) - return out, said - - -def main(): - # --- the build must install the variants, and completely ---------------- - patch_sh = open(os.path.join(ROOT, "bin", "patch.sh"), encoding="utf-8").read() - if '*."$SUFFIX"' in patch_sh: - ok("bin/patch.sh installs . under its real name") - else: - bad("bin/patch.sh installs . under its real name") - - leaked = [os.path.basename(f) for f in glob.glob(os.path.join(CFGDIR, "ff-*.cfg")) - if any(f.endswith("." + s) for s in MODELS.values())] - if not leaked: - ok("no per-model variant matches the plain ff-*.cfg glob") - else: - bad("no per-model variant matches the plain ff-*.cfg glob", repr(leaked)) - - bases = {os.path.basename(f).rsplit(".", 1)[0] - for s in MODELS.values() - for f in glob.glob(os.path.join(CFGDIR, "*." + s))} - missing = ["%s.%s" % (b, s) for b in sorted(bases) for s in MODELS.values() - if not os.path.exists(os.path.join(CFGDIR, "%s.%s" % (b, s)))] - if not missing: - ok("every per-model file exists for every model (%d file(s))" % len(bases)) - else: - bad("every per-model file exists for every model", ", ".join(missing)) - - pro_m, pro_h = load("Creator5Pro") - np_m, np_h = load("Creator5") - if pro_m is None or np_m is None: - return finish() - - if pro_h: - ok("Creator5Pro declares the chamber heater") - else: - bad("Creator5Pro declares the chamber heater") - if not np_h: - ok("Creator5 declares no chamber heater") - else: - bad("Creator5 declares no chamber heater") - - BASE = "SET_HEATER_TEMPERATURE_BASE" - - # --- the point: a chamber target on a machine with no heater ------------ - out, said = render(np_m, np_h, "SET_HEATER_TEMPERATURE", - {"HEATER": "chamber_heater", "TARGET": "50"}, - "HEATER=chamber_heater TARGET=50") - if BASE not in out and said: - ok("non-Pro: chamber TARGET=50 is refused and explained") - else: - bad("non-Pro: chamber TARGET=50 is refused and explained", - "rendered %r, said %r" % (out.strip(), said)) - - out, _ = render(pro_m, pro_h, "SET_HEATER_TEMPERATURE", - {"HEATER": "chamber_heater", "TARGET": "50"}, - "HEATER=chamber_heater TARGET=50") - if BASE in out and "TARGET=50" in out: - ok("Pro: chamber TARGET=50 reaches the real command") - else: - bad("Pro: chamber TARGET=50 reaches the real command", repr(out.strip())) - - # --- everything else passes through on BOTH models ---------------------- - for label, m, h in (("non-Pro", np_m, np_h), ("Pro", pro_m, pro_h)): - # Turning the chamber off must always work: our own macros and the - # stock app send TARGET=0 unconditionally. - out, _ = render(m, h, "SET_HEATER_TEMPERATURE", - {"HEATER": "chamber_heater", "TARGET": "0"}, - "HEATER=chamber_heater TARGET=0") - if BASE in out: - ok("%s: chamber TARGET=0 still passes through" % label) - else: - bad("%s: chamber TARGET=0 still passes through" % label, repr(out.strip())) - - out, _ = render(m, h, "SET_HEATER_TEMPERATURE", - {"HEATER": "extruder", "TARGET": "220"}, - "HEATER=extruder TARGET=220") - if BASE in out and "extruder" in out: - ok("%s: hotends are untouched" % label) - else: - bad("%s: hotends are untouched" % label, repr(out.strip())) - - # --- M191 must not wait for a heater that cannot exist ------------------ - out, _ = render(np_m, np_h, "M191", {"S": "60"}) - if "TEMPERATURE_WAIT" not in out: - ok("non-Pro: M191 does not wait forever") - else: - bad("non-Pro: M191 does not wait forever", repr(out.strip())) - - out, _ = render(pro_m, pro_h, "M191", {"S": "60"}) - if "TEMPERATURE_WAIT" in out and "MINIMUM=58" in out: - ok("Pro: M191 waits to within wait_band") - else: - bad("Pro: M191 waits to within wait_band", repr(out.strip())) - - return finish() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/test-macros.py b/test/test-macros.py deleted file mode 100644 index c7dd492..0000000 --- a/test/test-macros.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -"""The ff-*.cfg macros must parse: as Klipper config, and as Jinja. - -Klipper renders every gcode_macro body through Jinja2 at RUNTIME, and a -config-time syntax error only surfaces when klippy loads the file -- on the -printer, at the start of a print. This parses the same bodies here. - -It checks syntax and undefined-variable-free rendering of the template, not -behaviour: a macro that parses can still do the wrong thing. - - ./test/test-macros.py [config-dir] -""" -import glob -import os -import sys - -try: - import jinja2 -except ImportError: - print(" SKIP: jinja2 not installed") - sys.exit(0) - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ffcfg import sections - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -CFGDIR = sys.argv[1] if len(sys.argv) > 1 else os.path.join( - ROOT, "payload", "klipper", "config") - - -def main(): - env = jinja2.Environment() - # .creator5 / .creator5pro are installed under their real - # name by bin/patch.sh, so they are Klipper config too and must parse. - files = sorted(glob.glob(os.path.join(CFGDIR, "ff-*.cfg")) - + glob.glob(os.path.join(CFGDIR, "ff-*.cfg.*"))) - if not files: - print(" SKIP: no ff-*.cfg in %s" % CFGDIR) - return 0 - fail, checked = 0, 0 - for path in files: - for name, opts in sections(path): - body = opts.get("gcode") - if body is None: - continue - checked += 1 - try: - env.parse(body) - except jinja2.TemplateSyntaxError as e: - fail += 1 - print(" \033[31mFAIL\033[0m %s [%s]: line %s: %s" - % (os.path.basename(path), name, e.lineno, e.message)) - if fail: - print("\n %d macro(s) failed to parse" % fail) - return 1 - print(" \033[32mPASS\033[0m %d macro bodies parse as Jinja (%d files)" - % (checked, len(files))) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/test-model-gate.sh b/test/test-model-gate.sh deleted file mode 100644 index fe7fb5d..0000000 --- a/test/test-model-gate.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Model-gate regression test. -# -# The two models are NOT interchangeable: their stock packages ship different -# firmwareExe binaries, and each package's runFirmwareExe.sh carries -# a MACHINE=/PID= gate that refuses to install on the other one. An earlier -# version of pack.sh emitted BOTH filenames from a single build, which would -# have handed one model the other's firmware. -# -# Asserts, for every package in dist/ (or work/out): -# * the filename prefix matches the gate inside -# * a Pro package and a non-Pro package are not the same file -# * each carries our firmwareExe wrapper -# -# The gate check above is what catches a cross-model build: runFirmwareExe.sh -# comes from the stock package the build was made from, so a Pro-named package -# built off the non-Pro stock tgz says MACHINE=Creator5 inside. The wrapper -# itself is identical on both models -- HelixScreen is the UI now and the -# per-model binary is not shipped at all -- so it cannot discriminate. -set -uo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" -KEY="${FF_KEY:-FFP0331&*%root}" -FAIL=0 -ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; } -bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; } - -DIR=dist; [ -d "$DIR" ] && [ -n "$(ls -1 "$DIR"/*.tgz 2>/dev/null)" ] || DIR=work/out -PKGS=$(ls -1 "$DIR"/Creator5*-*.tgz 2>/dev/null || true) -[ -n "$PKGS" ] || { echo " SKIP: no packages (run 'make release')"; exit 0; } - -T=$(mktemp -d); trap 'rm -rf "$T"' EXIT - -while IFS= read -r p; do - [ -n "$p" ] || continue - b=$(basename "$p") - case "$b" in - Creator5Pro-*) want=Creator5Pro ;; - Creator5-*) want=Creator5 ;; - *) bad "$b: filename matches no model glob"; continue ;; - esac - d="$T/$want"; mkdir -p "$d/sw" - openssl des3 -d -k "$KEY" -salt -md md5 -in "$p" 2>/dev/null | tar -xf - -C "$d" || { - bad "$b: cannot decrypt"; continue; } - gate=$(sed -n 's/^MACHINE=//p' "$d/runFirmwareExe.sh" 2>/dev/null | head -1) - pid=$(sed -n 's/^PID=//p' "$d/runFirmwareExe.sh" 2>/dev/null | head -1) - - if [ "$gate" = "$want" ]; then - ok "$b: filename prefix matches the gate inside ($gate/$pid)" - else - bad "$b: filename says $want but the gate says '$gate' -- the printer would pick it up and refuse it" - fi - case "$want/$pid" in - Creator5Pro/0029|Creator5/0028) ok "$b: PID matches the model" ;; - *) bad "$b: PID '$pid' is wrong for $want" ;; - esac - - tar -xf "$d"/software-*.tar.xz -C "$d/sw" 2>/dev/null - if [ -f "$d/sw/firmwareExe" ]; then - ok "$b: carries a firmwareExe ($(md5sum "$d/sw/firmwareExe" | cut -c1-12))" - else - bad "$b: no firmwareExe in the software component" - fi -done <<< "$PKGS" - -echo -[ "$FAIL" = 0 ] && echo " model gates are correct" || echo " MODEL GATE TEST FAILED" -exit $FAIL diff --git a/test/test_applets.py b/test/test_applets.py new file mode 100644 index 0000000..353e04d --- /dev/null +++ b/test/test_applets.py @@ -0,0 +1,73 @@ +"""Absolute paths the payload uses must exist on the printer. + +What is left of this check is the half that earns its keep: every literal +/bin, /sbin, /etc, /usr/bin, /usr/sbin path in an on-printer script has to +exist in the real extracted rootfs. Its value is the branches no simulation +ever reaches -- S50wifi bails at `[ -d /sys/class/net/wlan0 ]`, which the +replica has not got, so /sbin/udhcpc, /sbin/ifconfig, /usr/sbin/wpa_supplicant +and /usr/sbin/wpa_cli are verified here and nowhere else. + +The bare-command-word scan that used to live here is gone, along with +test/applets.allow. It extracted only the first word of a simple, unprefixed +command, so `if timeout 5 foo; then` yielded ['if','echo','fi'] -- the exact +failure its own docstring claimed to guard was invisible to it, as were +`VAR=1 cmd`, `nohup cmd`, and everything reached through a variable or an +absolute path, which is how the payload invokes essentially everything. Its +allowlist had drifted to nine entries that excused nothing. What it could +really catch, case-install.sh catches properly by booting the machine and +grepping the boot log for "command not found". + +/usr/prog and /usr/data are deliberately not checked: they live on partitions +the rootfs does not contain, and the install simulation covers them. +""" +import os +import re + +import pytest + +PATHS = re.compile( + r"(?