Cut the suite to four gates, and rewrite the Python half as pytest

Twelve gates, most of them the weaker copy of a gate that survives. What is
left: pytest for the Klipper config, and three replica gates -- the end-to-end
install, MCU bring-up, and stock recovery.

Fix first, because the cut depends on it: a skip counted as a pass. Every
launcher exits 0 when it decides it cannot run, and sub() reported that as
`ok`. That is how test-abi.sh sat in the suite printing green while checking
nothing -- run-tests.sh deleted work/modpayload immediately before it ran and
CI set KLIPPER_FORK="", so it had no targets on any run, ever. Skips are now
counted apart, the summary says so, and the suite exits non-zero unless
ALLOW_SKIP=1 says the gap is deliberate. With four gates instead of twelve, a
gate that silently does not run is the failure mode that matters most.

Dropped, each because something else already does the job properly:

  test-abi.sh              bin/patch.sh refuses to build a non-nan2008
                           c_helper.so, bin/verify.sh re-checks the packaged
                           one, and case-install.sh reads the ELF header of
                           the file that actually landed on the machine.
  test-ash-conformance.sh  case-install.sh 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.sh       verify.sh SS8b/SS9 checks the same invariant and
                           pack.sh exits on MODEL MISMATCH before a package
                           exists. Its header claimed it proved the two models
                           ship different files; no such check was in it.
  test-macros.py           It used jinja2.Environment() -- default '{{ }}'.
                           The configs have no '{{' at all, only single-brace
                           expressions, so it checked '{% %}' block structure
                           and never looked inside an expression. Folded into
                           the chamber tests, which had the delimiters right.
  test-base-cfg.py         It read work/software/klipper/config/printer.base.cfg
                           as its stock reference -- the file bin/patch.sh
                           overwrites with ours. It was diffing our file
                           against itself: green on a cold tree, red on a
                           second run, blind to real drift either way. The
                           comparison moves to bin/unpack.sh, where a pristine
                           tree exists, as a warning rather than a gate.
  lint-danger.sh           Its structural greps were satisfiable by comments:
                           firmwareExe:44 and S80ui:16 *mention* helix and
                           SAFE-MODE, so they passed with the logic deleted.
  case-ui.sh               Goes with the helix-only decision.
  applets.allow            Ten entries, none of which the extractor had asked
                           about in a long time.

The command-word half of test-applets went too. It extracted only the first
word of a simple unprefixed command, so `if timeout 5 foo; then` yielded
['if','echo','fi'] -- the one failure its docstring named was invisible to it.
The absolute-path half survives: S50wifi's binaries sit behind a
[ -d /sys/class/net/wlan0 ] guard no simulation ever reaches, so nothing else
checks they exist.

The Python half is pytest now. Real assertions instead of hand-rolled
PASS/FAIL counters, underscored filenames so the modules import normally
(ffcfg keeps only the parser; the dash in test-macros.py was why one test used
to reach the other's parser through importlib), and a `rootfs` marker that
splits the tests needing the proprietary package from those that do not --
deselected in the PR lane, run in the replica half, so neither lane reports
skips it did not expect. test_default_delimiters_are_blind pins the jinja2 bug
so it cannot come back.

The config gate also moves out of the replica half into the half that runs on
a plain pull request. It needs python3, jinja2 and files in the repo, and it
is the only safety-relevant check in the suite -- a chamber heater declared on
a Creator 5 that has no element for it -- so it had no business being gated
behind the proprietary firmware.

Verified: pytest 21 passed / 5 rootfs-marked; the fixture pipeline
(unpack, patch, pack, verify) passes end to end; injecting a broken
single-brace expression fails test_every_macro_parses with the exact line, and
parses clean under the old defaults; the skip path reports SKIP and exits 1,
and ALLOW_SKIP=1 accepts it.

Also drops the CI `abi` job, which ran on a bare checkout with no binaries and
so was permanently green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-24 13:00:12 +03:00
1 parent 3a28fdf65b
commit 91604c2dfb
27 files changed
+535 -1078

No files matched your search

+1 -14
View File
@@ -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:
+21 -51
View File
@@ -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
+3 -2
View File
@@ -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
+28
View File
@@ -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"
+2 -1
View File
@@ -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/*
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
```
+58 -16
View File
@@ -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
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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]
-16
View File
@@ -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.
+55
View File
@@ -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
+6 -28
View File
@@ -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
-120
View File
@@ -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 <dir-of-scripts>...
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
-92
View File
@@ -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
+61 -36
View File
@@ -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 ]
-69
View File
@@ -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
-135
View File
@@ -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"(?<![\w.])(/(?:bin|sbin|etc)/[A-Za-z0-9_./-]+|"
r"/usr/(?:bin|sbin)/[A-Za-z0-9_./-]+)", src))):
if not os.path.exists(RFS + p):
print(f" FAIL {rel}: {p} does not exist on the printer")
fail = True
if not fail:
print(" every command the payload uses exists on the printer")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
# Parse every on-printer script with the PRINTER'S OWN busybox ash.
#
# The payload runs under busybox 1.31.1 ash on MIPS, not under bash or dash.
# `sh -n` on a build machine catches only some of the difference. Here we run
# the genuine /bin/busybox out of the printer's real rootfs under qemu-mipsel,
# so a construct that busybox rejects is caught before it ever reaches the
# machine -- where a parse error in firmwareExe means a blank screen.
#
# No binfmt registration and no chroot are needed: `busybox sh -n` is a single
# process and forks nothing, so plain `qemu-mipsel-static -L` is enough.
#
# It runs inside the printer replica image, which already carries
# qemu-user-static -- and, when PRINTER_IMAGE is set, the rootfs too. The
# earlier version started a bare debian container and apt-installed qemu on
# every single run, which made a local syntax check depend on the network.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# The config and the docker plumbing (skip policy, image choice, local
# sim-image build), shared with the replica launchers -- including
# REQUIRE_PRINTER_SIM, which turns these skips into failures in a release
# build, and PRINTER_IMAGE, without which this script rebuilt the local sim
# image on every run.
# shellcheck disable=SC1091
. "$ROOT/test/sim-image.sh"
MOUNT=()
[ "$PREBUILT" = 1 ] || MOUNT=(-v "$ROOT/work/rootfs:/rootfs:ro")
$DOCKER run --rm -i --entrypoint sh \
"${MOUNT[@]}" \
-v "$ROOT/payload:/payload:ro" \
"$IMAGE" -s <<'INNER'
set -u
FAIL=0
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; }
ASH="qemu-mipsel-static -L /rootfs /rootfs/bin/busybox"
V=$($ASH 2>&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
-110
View File
@@ -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())
-161
View File
@@ -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 <file>.<model> under its real name")
else:
bad("bin/patch.sh installs <file>.<model> 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())
-62
View File
@@ -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()
# <file>.creator5 / <file>.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())
-68
View File
@@ -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
+73
View File
@@ -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"(?<![\w.])(/(?:bin|sbin|etc)/[A-Za-z0-9_./-]+|/usr/(?:bin|sbin)/[A-Za-z0-9_./-]+)")
def payload_scripts(root):
out = []
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):
out.append(os.path.join(dirpath, n))
return sorted(out)
def absolute_paths(root):
"""(script, path) for every rootfs-absolute path the payload names."""
for f in payload_scripts(root):
src = open(f, encoding="utf-8", errors="replace").read()
for p in sorted(set(PATHS.findall(src))):
yield os.path.relpath(f, root), p
def test_payload_scripts_are_found(root):
assert payload_scripts(root), "no on-printer scripts found under payload/"
def test_some_absolute_paths_are_checked(root):
"""Guard against the regex silently matching nothing and the test passing."""
assert list(absolute_paths(root)), "no absolute paths extracted -- regex broken?"
@pytest.mark.rootfs
def test_absolute_paths_exist_on_the_printer(root, rootfs):
missing = ["%s: %s" % (script, p)
for script, p in absolute_paths(root)
if not os.path.exists(rootfs + p)]
assert not missing, \
"paths that do not exist on the printer:\n " + "\n ".join(missing)
@pytest.mark.rootfs
@pytest.mark.parametrize("binary", [
"/sbin/ifconfig", "/sbin/udhcpc", "/usr/sbin/wpa_supplicant", "/usr/sbin/wpa_cli",
])
def test_wifi_binaries_exist(rootfs, binary):
"""Named explicitly: S50wifi's connect() never runs in any simulation."""
assert os.path.exists(rootfs + binary), "%s missing from the printer rootfs" % binary
+218
View File
@@ -0,0 +1,218 @@
"""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 once per model, with a printer
context built from that model's real config file.
Needs nothing proprietary: python3, jinja2 and the configs in the repo.
"""
import glob
import os
import pytest
jinja2 = pytest.importorskip("jinja2")
from conftest import HEATER, MODELS
from ffcfg import sections
BASE = "SET_HEATER_TEMPERATURE_BASE"
def klipper_env():
"""Klipper's Jinja dialect: '{%','%}','{','}' -- NOT jinja2's defaults.
This is load-bearing, not decoration. See test_default_delimiters_are_blind.
"""
return jinja2.Environment("{%", "%}", "{", "}")
def chamber_cfg(cfgdir, machine):
return os.path.join(cfgdir, "printer.chamber.cfg.%s" % MODELS[machine])
@pytest.fixture(scope="session")
def macros(cfgdir):
return dict(sections(os.path.join(cfgdir, "ff-chamber.cfg")))
@pytest.fixture(scope="session")
def has_heater(cfgdir):
return {m: HEATER in dict(sections(chamber_cfg(cfgdir, m))) for m in MODELS}
def render(macros, has_heater, section, params, rawparams=""):
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 = klipper_env().from_string(
macros["gcode_macro " + section]["gcode"]).render(
printer=printer, params=params, rawparams=rawparams,
action_respond_info=action_respond_info)
return out, said
# --------------------------------------------------------------- parsing ----
def gcode_bodies(cfgdir):
"""(file, section, body) for every gcode block we ship.
The per-model variants are installed under their real name by
bin/patch.sh, so they are Klipper config too and are swept as well.
"""
files = sorted(glob.glob(os.path.join(cfgdir, "ff-*.cfg"))
+ glob.glob(os.path.join(cfgdir, "ff-*.cfg.*")))
for path in files:
for name, opts in sections(path):
if opts.get("gcode") is not None:
yield os.path.basename(path), name, opts["gcode"]
def test_configs_exist(cfgdir):
assert list(gcode_bodies(cfgdir)), "no ff-*.cfg gcode bodies found to parse"
def test_every_macro_parses(cfgdir):
"""Absorbed from test-macros.py, with the delimiters corrected."""
broken = []
for path, name, body in gcode_bodies(cfgdir):
try:
klipper_env().parse(body)
except jinja2.TemplateSyntaxError as e:
broken.append("%s [%s] line %s: %s" % (path, name, e.lineno, e.message))
assert not broken, "macros do not parse as Klipper Jinja:\n " + "\n ".join(broken)
def test_default_delimiters_are_blind():
"""Why test-macros.py was theatre, pinned so it cannot come back.
It used jinja2.Environment() -- default '{{' expressions. Our configs
contain no '{{' at all, only single-brace expressions, so it validated
'{% %}' block structure and never looked inside an expression.
"""
broken = "M104 S{params.S|default(0)|floatm(( }"
jinja2.Environment().parse(broken) # the old test saw nothing wrong
with pytest.raises(jinja2.TemplateSyntaxError):
klipper_env().parse(broken)
def test_no_double_brace_expressions(cfgdir):
"""If this ever fails, Klipper's dialect assumption above needs revisiting."""
for path, name, body in gcode_bodies(cfgdir):
assert "{{" not in body, "%s [%s] uses '{{', which Klipper does not" % (path, name)
# ------------------------------------------------------- per-model layout ----
def test_no_variant_matches_the_plain_glob(cfgdir):
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())]
assert not leaked, "per-model variants leaking into the plain glob: %r" % leaked
def test_every_per_model_file_exists_for_every_model(cfgdir):
bases = {os.path.basename(f).rsplit(".", 1)[0]
for s in MODELS.values()
for f in glob.glob(os.path.join(cfgdir, "*." + s))}
assert bases, "no per-model config variants found at all"
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)))]
assert not missing, "per-model files missing: %s" % ", ".join(missing)
def test_pro_declares_the_heater(has_heater):
assert has_heater["Creator5Pro"]
def test_non_pro_declares_no_heater(has_heater):
assert not has_heater["Creator5"]
# ------------------------------------------- the non-Pro keeps the reading ----
# Absorbed from test-base-cfg.py, whose stock-drift comparison moved into
# bin/unpack.sh -- the only place a pristine stock tree exists, since
# bin/patch.sh overwrites the copy in work/software that the test read, so it
# had been comparing our file against itself.
def test_non_pro_declares_no_heater_section_at_all(cfgdir):
np_ = dict(sections(chamber_cfg(cfgdir, "Creator5")))
heaters = [n for n in np_
if n.startswith("heater_generic") or n.startswith("verify_heater")]
assert not heaters, "Creator 5 must declare no heater section: %r" % heaters
def test_non_pro_keeps_the_chamber_sensor(cfgdir):
np_ = dict(sections(chamber_cfg(cfgdir, "Creator5")))
assert "temperature_sensor chamber" in np_, sorted(np_)
def test_non_pro_sensor_matches_the_pro(cfgdir):
"""Same pin, type and bounds as the heater section it replaces."""
sensor = dict(sections(chamber_cfg(cfgdir, "Creator5")))["temperature_sensor chamber"]
ref = dict(sections(chamber_cfg(cfgdir, "Creator5Pro")))[HEATER]
for k in ("sensor_type", "sensor_pin", "min_temp", "max_temp"):
assert sensor.get(k) == ref.get(k), \
"%s: %r != %r" % (k, sensor.get(k), ref.get(k))
# ------------------------------------------------------- the actual point ----
def test_non_pro_refuses_a_chamber_target(macros, has_heater):
"""A chamber target on a machine with no element for it.
Left unguarded this reaches FlashForge's patched verify_heater, which
hardcodes chamber_heater params, and klippy shuts down mid-print ~15
minutes later with "not heating at expected rate".
"""
out, said = render(macros, has_heater["Creator5"], "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "50"},
"HEATER=chamber_heater TARGET=50")
assert BASE not in out, out.strip()
assert said, "refused silently -- the user gets no explanation"
def test_pro_allows_a_chamber_target(macros, has_heater):
out, _ = render(macros, has_heater["Creator5Pro"], "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "50"},
"HEATER=chamber_heater TARGET=50")
assert BASE in out and "TARGET=50" in out, out.strip()
@pytest.mark.parametrize("machine", sorted(MODELS))
def test_chamber_off_always_passes_through(macros, has_heater, machine):
"""Our own macros and the stock app send TARGET=0 unconditionally."""
out, _ = render(macros, has_heater[machine], "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "0"},
"HEATER=chamber_heater TARGET=0")
assert BASE in out, out.strip()
@pytest.mark.parametrize("machine", sorted(MODELS))
def test_hotends_are_untouched(macros, has_heater, machine):
out, _ = render(macros, has_heater[machine], "SET_HEATER_TEMPERATURE",
{"HEATER": "extruder", "TARGET": "220"},
"HEATER=extruder TARGET=220")
assert BASE in out and "extruder" in out, out.strip()
def test_non_pro_m191_does_not_wait_forever(macros, has_heater):
"""TEMPERATURE_WAIT on a sensor that never reaches target hangs the print."""
out, _ = render(macros, has_heater["Creator5"], "M191", {"S": "60"})
assert "TEMPERATURE_WAIT" not in out, out.strip()
def test_pro_m191_waits_within_wait_band(macros, has_heater):
out, _ = render(macros, has_heater["Creator5Pro"], "M191", {"S": "60"})
assert "TEMPERATURE_WAIT" in out and "MINIMUM=58" in out, out.strip()