mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-19 03:49:50 -05:00
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:
1 parent
3a28fdf65b
commit
91604c2dfb
27 files changed
+535
-1078
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 ]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user