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>
11 KiB
Testing: how we know it does not brick
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, 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
the stock package, chrooted under qemu-mipsel, with /usr/prog installed by
FlashForge's own updater. The installer under test runs on the printer's
busybox, tar, md5sum and unTar — not on Debian stand-ins — with a read-only
root and writable prog/data partitions, exactly like the machine, and the
package reaches it the way it reaches a real printer: on a FAT filesystem that
the machine's own boot script finds and mounts. This is the
half that can catch a brick, it needs the stock package, and
.github/workflows/release.yml refuses to publish without it.
With PROG_DUMP (in test.env) pointed at a factory image, the replica has
essentially nothing invented left: the klipper daemons, nginx, python3,
moonraker and the printer's own OpenSSL 1.0.2d are all genuine, so
package decryption is verified against the real implementation. What remains
substituted is insmod/reboot/cmd_mcu, neutered because they would act on
the host kernel or real hardware, and the partition sizes.
See printer-replica.md for what is authentic, what is stubbed, and what it still cannot tell you.
The tests
make test # everything below
| Gate | What it does | Replica |
|---|---|---|
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 |
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:
| per run | |
|---|---|
unpack the 182MB factory image into /usr/prog |
22s |
| install the stock package into it, under qemu | 37s |
| the test itself (three boots, install, re-install) | ~30s |
make printer-image does both of those once, at build time, and publishes the
result. test/printer/bake.sh is the part that cannot be a docker build
step — the stock install needs binfmt_misc and chroot, so it runs in a
privileged container and the result is committed. The md5 of the package that
was installed is recorded in /usr/prog/.BASELINE; entrypoint.sh reinstalls
only if a run asks for a different one.
With PRINTER_IMAGE set in test.env, a replica starts in 0.7s and the
whole end-to-end update test takes ~70s. Without it, the same test spends
a minute on setup before it begins. That image contains proprietary FlashForge
firmware.
make test end to end, measured, with the image: 5m26s, and the sections
say where it goes — run-tests.sh stamps every header with elapsed seconds:
== python checks (klipper config) == [0s]
== build on the fixture == [1s]
== extracting the printer rootfs == [16s]
== python checks (rootfs) / MCU bring-up == [18s]
== end-to-end update on the replica == [111s]
== recovery: a stock package reverts the mod == [250s]
[326s]
What is left is real work: two full package builds (the payload is 55MB
through xz) and two replica runs. If it needs to get faster again,
that is where to look — not in the harness.
What was dropped, and why
Tests that cannot fail are worse than no tests, because they read as coverage:
-
test-abi's execution half. It wasqemu "$f" -h || [ $? -lt 126 ], which accepts nearly every exit status — it could only fail if qemu itself was missing. Running the binaries for real is what the replica does. -
test-ash'smd5sum -sprobe. It printed "inconclusive" on every path and could not fail. -
The per-file
sh -n"POSIX" loop inrun-tests.sh.shis bash on the build image, so it proved nothing thattest-ashdoes not prove properly with the printer's own busybox. Its bashism grep also flaggedlocal, which busybox ash supports. -
test-install's hand-written replay ofapp_startup.sh. It re-derived the glob,MACHINEandPIDwithsedand then called the installer itself — so a mistake in our reading of the boot script could never be caught. It now runs the boot script. -
The release workflow's final
sim-installloop ignored the exit status of every run, so the last gate before publishing could not fail. It now stops on the first failure and runs withREQUIRE_PRINTER_SIM=1, which turns a skip into a failure too. -
test-printer-db, all of it. It began as a re-implementation of PrinterDetector's scoring formula, mirroringprinter_detector.cppline for line, so a change to the HelixScreen fork's formula left the test passing while reality moved. Narrowing it to scoring-independent invariants kept it 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 dashknows the whole SC3xxx family and now stands in its place. The same pass gave every replica launcher one sharedtest/sim-image.shfor the docker plumbing, which also fixed twomake test-ashbugs: it ran without the docker socket (so it always silently skipped), and it never readtest.env, so it ignoredPRINTER_IMAGEand 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-abihad never asserted anything in CI.run-tests.shdeletedwork/modpayloadimmediately before it ran and CI setKLIPPER_FORK="", so it had no targets on any run, skipped, exited 0 and was printed asok.bin/patch.shrefuses to build a non-nan2008c_helper.soanyway, andtest-installreads the ELF header of the file that actually landed.test-macrosusedjinja2.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_blindnow pins that.test-ash-conformanceparsed the payload with the printer's busybox;test-installrunssh -nover every installed script with the same qemu'd busybox and then greps the boot log forSyntax error.test-model-gatechecked whatverify.sh§8b/§9 checks and whatpack.shalready refuses to build. Its header claimed it proved the two models ship different files; no such check existed in it.test-base-cfgcompared ourprinter.base.cfgagainstwork/software/.../printer.base.cfg— whichbin/patch.shoverwrites 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 secondmake test, and blind to real drift either way. The comparison moved intobin/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; thenyielded['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, becauseS50wifi's binaries sit on a branch no simulation reaches.lint-danger's structural greps were satisfiable by comments —firmwareExe:44andS80ui:16mentionhelixandSAFE-MODE, so the checks passed with the logic deleted.case-uiwent with the helix-only decision.
Bugs these caught
Not theoretical. Writing them caught real bugs before any hardware was involved:
- backups were taken after the stock
run.shhad already overwritten the files, so a restore would have restored the modified versions rm -rf $MODDIR/binwith an unsetMODDIRexpands torm -rf /bin- a crashed UI is a zombie, and
kill -0succeeds on a zombie — the crash detector called a dead UI healthy - the packer emitted both models' filenames from one build, which would have handed a Creator 5 the Pro's firmware
- three checks in the brick lint had been quietly dead for months: two were
guarded on a
payload/boot.shthat no longer existed, and the file glob never matchedfirmwareExeor theinit.d/S*scripts at all - a skip counted as a pass for the whole life of the suite, which is how
test-abisat in it for months checking nothing while printing green