The split existed so a plain pull request had something to run. This repo has one maintainer who always has the firmware to hand, so it was a boundary kept in sync for a contributor who never arrives. One directory now. The tests moved rather than went away. test_chamber.py is the only check in the suite that can catch a printer heating something it has no element for, and the replica cannot cover it: case-install.sh touches .cfg files at exactly two lines, both a grep for a sentinel proving the user config survived, and it asserts klippy.py exists and c_helper.so has the right ABI without ever starting klippy. Declaring [heater_generic chamber_heater] on the plain Creator 5 fails four of these tests, including M191 waiting on a target the machine can never reach; that same package installs, boots, and passes every replica gate. make-stock-fixture.sh came too, since the packaging pipeline is built on it. Ordering changed, and that is the part to look at. With two lanes, pytest ran twice -- once per lane, each with its own -m expression or path. With one it runs once, so it has to run at the point where it can see the most: the stock package is now resolved and the rootfs extracted BEFORE pytest rather than after it. On a configured machine that is 28 tests in one pass; on a bare tree the five that read the rootfs skip. config.env is read in a subshell while doing that. It is the BUILD config, and it is now consulted upstream of the fixture build, where the entire point is that the fixture config wins -- sourcing it into this shell would put MOD_NAME and TARGET_MACHINE where that build could see them. Verified by pointing a config.env at MOD_NAME=LEAKED and confirming the fixture still builds Creator5Pro-anvil-ci.tgz. One thing gets worse, and it is inherent to having a single lane: on a tree with no rootfs, pytest reports 5 skips, so sub() marks the whole gate SKIP rather than ok, and the summary reads 6 passed instead of 7. Those 23 passes are real and the line above the SKIP says so. This is the existing policy working as designed -- a gate that did not fully run must not read as success -- but the summary is coarser than it was when a lane existed that never skipped. Verified: 28 passed with a real extracted rootfs, 23 passed / 5 skipped without; the full suite gives 7 passed / 1 failed / 1 skipped with a rootfs present and 6 / 1 / 2 without, the failure being shellcheck, which lives in the build image; no test/unit reference is left outside the passages that explain why it is gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
Testing: how we know it does not brick
Everything lives in test/integration, and test/run-tests.sh runs it. There
is still a real seam inside it, though — what needs the proprietary firmware
and what does not — and it is worth knowing which side of that seam a failure
is on.
Without firmware. A synthetic fixture
(test/integration/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. This is roughly half the
gates, and it is the half that catches a chamber heater declared on a machine
that has no element for it — the replica cannot, because it never starts
klippy.
These used to live in a directory of their own, test/unit. The split was
there so a plain pull request had something to run; this repo has one
maintainer who always has the firmware to hand, so it was a boundary being
maintained for a contributor who never arrived. The tests moved rather than
went away.
With firmware — 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, when a rootfs has been extracted, 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/integration/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/integration/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