test/repo -> test/unit, test/replica -> test/integration. The old names said
where the input comes from; these say what the tests are, which is the thing
you already have a word for.
The rename is also how the bug below surfaced, because "integration" is one
character-count away from nothing and running the launchers by hand is not.
Every launcher that moved out of test/ into a subdirectory opened with
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
and one `..` was correct at test/ and wrong a directory deeper. $ROOT became
test/, so the first thing each of them did -- source $ROOT/test/integration/
sim-image.sh -- went looking for test/test/integration/. Five scripts:
printer-exec.sh, sim-install.sh, sim-roundtrip.sh, build-printer-image.sh and
extract-rootfs.sh, which reached bin/common.sh the same way.
The previous commit shipped this. The path-existence sweep that was supposed
to catch it only checked paths written as literals; these are assembled at
runtime from $0. Nothing else caught it either, and that is the part worth
naming: the integration half cannot run without docker and the stock package,
so on any machine missing either -- this one, and a fork's CI without the
secrets -- five broken scripts still reported a clean SKIP.
So test/unit/test_script_roots.py now checks it statically: for every .sh
under test/, count the `..` after `dirname "$0"` and compare it against how
deep the file actually sits. It needs no docker, no firmware and no shell, so
it runs in the lane that always runs, which is the whole point -- the failure
it pins only appears in the lane that often does not.
Verified: the guard fails with "climbs 1, is 2 deep" against a reintroduced
bug and passes once it is reverted; all four launchers now reach their own
preconditions (docker daemon, config.env) instead of dying on a missing file;
21 in test/unit and 7 in test/integration against a real extracted rootfs;
run-tests.sh reports 7 passed / 1 failed / 1 skipped, the same as before the
rename (shellcheck lives in the build image, and no stock package is
configured in this worktree); no test/repo or test/replica reference is left
anywhere in the tree.
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. They
are two directories — test/unit and test/integration — so which half a
file belongs to is a fact about where it lives, not something you learn by
reading its header or by remembering to tag it. test/run-tests.sh is the
only thing in test/ that spans both.
Unit — test/unit, static and packaging. Needs no printer and no
proprietary firmware: a synthetic fixture (test/unit/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.
Integration — test/integration, 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 (test/unit) — plus, when a rootfs has been extracted, that every absolute path the payload names exists on the printer (test/integration) |
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