Files
firmware/docs/qa-migration.md
Oleksandr ShyshatskyiandClaude Opus 5 f8b925e48f Replace opkg with apk, and sign the feed
docs/notes/85-packaging.md chose opkg in 2026-08 on one argument: every package
is cross-built here anyway, so the winner is whichever format is cheapest to
PRODUCE -- and .ipk had an upstream tool where .apk meant abuild, a keypair and
an Alpine host. Two lines of that comparison were wrong.

apk mkpkg is an applet of apk itself: -I key:value metadata, -F directory, no
keypair, no abuild, no Alpine host. The "making a package" row described
apk-tools 2. And the on-device client was counted a wash when it is better than
one -- openssl and zlib were already cross-built for CPython, and libarchive
existed in this tree only for opkg, so adopting apk REMOVES a cross-build.

What made it possible at all is that apk resolves its database relative to
--root and uses the same root as the file destination, while / here is a
read-only squashfs -- measured: neither /lib/apk nor /etc/apk can be created.
pkgs/3rdparty/apk-tools/prefix.patch splits the two. Upstream already carries
root_fd and dest_fd separately and merely aliases them in context.c, so the
patch defaults the database root to $MODDIR and leaves files extracting at /.
That is opkg's compile-time VARDIR plus `dest root /`, in apk's spelling.

THE FEED IS SIGNED NOW, which it never was: opkg signed the index and
bin/build-packages.sh signed nothing, so nothing was verified on a printer.
apk verifies with raw PEM public keys, not X.509 -- measured on the machine,
where every certificate directory is empty. ./bin/apk-keygen.sh makes an RSA
pair, APK_SIGN_KEY in config.env points at the private half, and the public
half both seeds the USB stick (so the first install can verify) and ships in
anvil-core (so the printer keeps trusting). An empty APK_SIGN_KEY still builds
an unsigned feed, deliberately: `make packages` has to run on a bare checkout
or the packaging lane stops being a CI gate.

WHAT IT COST is a carried patch, against an internal with one consumer, on a
branch mid v2->v3 transition, on an architecture with no upstream apk port to
share the rebasing. That is the standing risk and it does not go away.

Four bugs this shook out, all silent, all found by a gate rather than by
reading:

  * The prune deleted seventeen packages it had just built. The all -> noarch
    mapping went into the emitter and not into pkg_pkgfile, so the prune looked
    for a name nothing wrote. `make packages` exited 0.
  * Whitespace-separated Depends dropped nineteen packages. opkg's pkg_parse.c
    splits that field on ',' and nothing else, so the whole list became one
    dependency whose name contained spaces and resolved to nothing -- and opkg
    neither warned nor failed. `make build` shipped a payload with no Tornado
    in it. test_the_payload_is_dependency_closed is what catches that now.
  * Ownership was recorded as `nobody`. apk resolves an owner to a NAME against
    <--root>/etc/passwd and the build lane runs --user with no passwd entry, so
    every file in every package would have installed as uid 65534, with no
    error at build or install time. build-packages.sh writes a two-line
    synthetic passwd naming the build user root and points --root at it.
  * Four recipes carried versions apk cannot parse -- two spelled a commit
    +git<sha> where the grammar's hash slot is ~, x264 had a snapshot name
    whose -2245 reads as a malformed release, and helixscreen had
    0.99.115.creator5.3 where apk allows one trailing letter and then only a
    suffix from a closed set of nine words. All four were valid opkg versions,
    which is why nothing had noticed. pkg_version_ok gates every recipe now.

A v3 .apk begins with ADBd magic and is not a tar stream, so there is no apk
equivalent of the `ar -p | gunzip | tar` that hand-unpacked anvil-opkg: you
cannot open a package without a package manager. bin/build-payload.py puts the
cross-built binary on the stick at /mnt/apk instead. It is the same build
anvil-apk-tools contains, that package installs normally, and the bootstrap
copy never lands.

Two gates retired rather than moved. apk's installed database is plain text
(F: sets a directory, R: names a file in it), so the payload-ownership check
reads one file where it used to glob info/*.list. And it carries no clock at
all -- apk records no install time and mkpkg sets no build-time -- so the
Installed-Time normalisation case-build-payload.sh did on every build is gone,
and the test asserts the ABSENCE of a t: line instead. Clock-free by
construction rather than by repair.

Also removed: PKG_SECTION, which 45 recipes declared and nothing read once
apk's schema had no field for it, and the `make apk-host` target -- the
packager is built on demand by bin/build-packages.sh and stamped on the pin,
the way pkg_toolchain and pkg_buildpython already work.

Verified from a cold work/host and work/packages: 47 packages built and signed,
`make build` installs 35 and verifies them against the shipped public key,
`make qa-static` 195 passed, `make qa-replica` 135 passed -- the printer
installing off a simulated USB stick and booting. The installed set was checked
against the declared closure of the eleven roots: 35 expected, 35 installed,
nothing extra and nothing missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 14:32:07 +03:00

36 KiB

The qa suite, and what it replaces

qa/ is the test suite. It was built beside test/ rather than inside it, and the migration is now done: every case-*.sh has been ported or retired, and test/ has since been deleted outright. The machinery that builds the replica moved to tools/replica/, because it was never a test.

This document says why it exists, what it proves, and what is still owed.

The problem it is answering

Three complaints, one cause.

Two frameworks. test/run-tests.py and test/ffsim/ are 1,657 lines that reimplement what pytest already does — skip accounting, gating, reporting, and then parsing pytest's own JUnit XML back out — and they invoke pytest as gate #5 of about twenty.

Assertions in the wrong language. test/integration/printer/case-*.sh was 6,280 lines of POSIX sh executing inside a qemu chroot, and between them they reported 13 boolean bits. case-moonraker.sh was 1,059 lines that collapsed to one pass/fail. A failure tells you a case went wrong, not which of its forty assertions did.

No parallelism, no selection. Each replica gate is its own docker run --privileged, re-registering binfmt and re-assembling the mount layout. Thirteen of those, strictly in sequence, all or nothing.

All three follow from one decision: the replica container is treated as a test runner instead of as a fixture. That is what pushes assertions into ash, which yields one bit per container, which then needs a bespoke framework above pytest to aggregate the bits.

Two rules

1. Shell inside the chroot performs actions. Python on the host makes assertions.

Shell in qemu is irreplaceable for doing things — the printer's busybox, its tar, its OpenSSL, and the fact that our scripts survive its ash is a large part of what the suite proves. It is a bad place to judge things. Where a fact is only observable inside the replica, qa/lib/replica.py exposes a typed probe for it, and a case script never branches on it.

qa/replica/actions/ is what remains of the shell — three files that drive the machine and assert nothing. No ok/bad, no counter, no verdict.

2. The machine under test is one the real installer produced.

The printer fixture is a replica with the package installed by the printer's own /usr/prog/app_startup.sh, off a genuine FAT filesystem on /dev/sda1, exactly as a user installs it from a USB stick.

An earlier version of this tree had install-payload.sh, which copied payload/*.sh and pkgs/anvil-core/payload/init.d/S* into $MODDIR by hand — the same shape case-services.sh used. Two things are wrong with it:

  • It is a second implementation of the install, so everything downstream asserts against a layout the harness built rather than one the installer produced. The real installer could break and nothing here would go red.
  • It can only place what a recipe keeps in the repo, so anything the build produces is missing. The cross-compiled s6 is the obvious one: it lives in work/.s6 and bin/payload.sh stages it into the package, so a hand-placed payload has no supervisor at all — which is why the tests had to carry a stand-in scanner, and why one of them had to ask whether it was testing the stand-in before it could mean anything.

Installing for real fixes both at once, and deletes the stand-in. Compare what lands in /usr/data/anvil:

hand-placed really installed
anvil-env.sh anvil-service.sh anvil.conf init.d etc/s6 VERSION anvil-env.sh anvil-service.sh anvil.conf bin config etc helixscreen init.d lib libexec moonraker nginx www

bin, lib and libexec are the cross-built s6 and CPython 3.13. No amount of copying a recipe's files produces them.

This repo has learned this once already, in case-install.sh's own header:

An earlier version of this file replayed app_startup.sh by hand — which meant a bug in our reading of it could never be caught.

What the machine is

Unchanged. qa/ reuses test/integration/printer/'s Dockerfile, entrypoint.sh, assemble.sh, binfmt.sh and seed-prog.sh as they are, and the docker run argv is deliberately the same one test/ffsim/replica.py builds, minus --rm and plus -d. Same image, same --privileged, same read-only squashfs root and writable prog/data partitions, same chroot onto the printer's own busybox under qemu.

This matters more than it looks: while both suites run, they have to be testing the same machine, or neither proves anything about the other.

The only new trick is how the container is held open. entrypoint.sh ends by running the case script it was given; the one qa/ gives it is qa/replica/actions/hold.sh, which touches a marker and then sleeps. Setup runs to completion exactly as it always has, and the container simply does not exit. The marker is also the readiness signal, because setup ranges from under a second (a prebuilt PRINTER_IMAGE) to over a minute (unpacking the factory image and installing the stock package under qemu) and a constant would either waste the fast case or fail the slow one.

Lanes

Lane Needs Marker
static nothing but the checkout @pytest.mark.static
replica docker + qemu + the firmware @pytest.mark.replica
make qa            # both
make qa-static     # seconds, any machine, any clone
make qa-replica    # the gates that decide whether a package bricks

The replica lane needs two things.

A base replica. The image, which carries the firmware, /usr/prog and /usr/data with the stock package already installed — put it in test.env (see test.env.example) or pass it per-run:

PRINTER_IMAGE=monstrofil/creator5-printer:1.9.7-1.2.9-20260810 make qa-replica

There is no second source. The lane used to build a replica locally from work/rootfs when PRINTER_IMAGE was unset, which needed make rootfs and the stock package and cost about a minute of setup per case; that path is gone. make printer-image builds the image from the same public firmware.

A built package, in work/out/*.tgz — because the lane installs it for real. make build produces one; the newest by mtime is the one used, and the run says which. It is never built on demand: make build cross-compiles s6 and CPython and takes minutes, and a fixture that silently started that would turn "the suite is slow today" into a mystery.

The installed image

The install is the machine's own shell under qemu, so it is baked into an image once per package and cached under a tag derived from the package's md5 — the same trick build-printer-image.sh uses for the stock baseline:

qa: baking creator5-printer-anvil:ce5f8271ccca from monstrofil/creator5-printer:latest
    (Creator5Pro-anvil-20260827b.tgz) -- once per package, then cached
install: booting /usr/prog/app_startup.sh with the package on /dev/sda1
install: the machine's own updater installed the package
qa-bake: mod installed, VERSION anvil-env.sh anvil-service.sh ... lib libexec moonraker nginx www

Rebuild the package and the md5 changes, so the next run bakes again rather than testing yesterday's build. That is deliberately not a hand-written staleness check, because those are the ones that eventually get it wrong.

qa/replica/actions/bake.sh refuses to run with USB_STICK unset, and fails if /parts/data/anvil is absent afterwards — a bake whose chroot unmounted early would otherwise commit a stock machine under a name saying it has the mod, and every test built on it would assert against firmware it was not told it had.

Selection is pytest's: -k S62moonraker, -m static, or a single test id.

Parallel runs must use --dist loadscope:

python3 -m pytest ./qa -n 4 --dist loadscope

The printer fixture is module-scoped and xdist's default --dist load hands out individual tests, so four workers pulling from one module would assemble four replicas of the same machine. qa/conftest.py refuses the default rather than letting the suite get mysteriously slower when it is parallelised.

Skips

A missing tool, daemon or firmware image is a failure, raised at the point that needs it, with the fix in the message. There is no flag, no environment variable and nothing to remember to pass.

This replaces REQUIRE_PRINTER_SIM and ALLOW_SKIP, and it preserves the discipline they existed for — a gate that did not run must never look green — by removing the mode in which it can. There is no configuration in which running this suite without shellcheck, or without a replica, is a complete run, so nothing needs to be told that.

An earlier version of this tree had a --strict-skip=<lane|all> flag. It was the wrong shape and it lasted one commit. It was ALLOW_SKIP inverted, and ALLOW_SKIP's defect was never the direction it named things in — it was that the decision lived in the CI invocation. An accept-list has to be edited whenever a gate moves; a strict-list has to be remembered whenever a job is added. Both degrade silently when the command line is wrong, which is the same disease as the printer-sim job that was gated on an unset secret and therefore never ran once in its entire existence.

pytest.skip stays available for what it is actually for: a question that does not apply to this configuration, as opposed to one this machine happens to be unequipped to ask. A test that only means something on a Creator5Pro would earn it. "I could not find the tool" does not.

There are three skips, and they do not meet that bar. qa/static/test_packages.py reads work/modpayload-root, which only exists after bin/payload.sh has run, and skips when it is absent. That is "this machine is not set up", which the rule above says must fail -- but making it fail would put a stock FlashForge package in the way of a lane whose whole point is that it needs nothing.

So make qa-static reads 246 passed on a tree that has been built and 243 passed, 3 skipped on one that has not, with nothing at the end saying which kind of run you just had. Either the payload questions move to a lane that can require a build, or they fail with make build in the message the way the replica lane does.

What is proven so far

The migration is done apart from one gate. Every case-*.sh is either ported or retired, and test/run-tests.py no longer drives a single replica case.

Lane File Tests From
static test_shell_syntax.py 7, parametrised over every script run-tests.py's check_shell_syntax, check_no_bashisms, check_undefined_names -- now deleted
static test_packages.py 32 new -- the packages, the layout and the ABI string
static test_recipe_layout.py 9 new -- a recipe's fixed shape
static test_probes.py 17 new -- the harness's own parsers
replica test_install.py 22 case-install.sh, 579 lines and 1 bit
replica test_supervisor.py 31 case-supervisor.sh, 166 lines
replica test_upgrade.py 21 case-upgrade.sh, 223 lines
replica test_boot_screen.py 17 case-boot-screen.sh, 210 lines
replica test_s6rc.py 12 new -- what the printer does
replica test_mcu_bringup.py 8 case-mcu-bringup.sh, 129 lines
replica test_web.py 7 case-nginx.sh + case-moonraker313-s6.sh, 1,124 lines and 2 bits

Measured on monstrofil/creator5-printer:latest, off a real package built from this tree:

qa/static 246 tests, 8s, no docker
qa/replica 118 tests, 3m40s -- 110 pass, 8 blocked on absent hardware
test/ pytest 28 tests, 0.1s

test/run-tests.py is 322 lines and test/ffsim/ 483, from 1,657 between them. What is left of the old harness runs the packaging build on a synthetic stock package and calls pytest; it holds no replica gate at all.

Three ports found assertions that had stopped meaning anything

Worth recording, because each had been green for months while asserting nothing:

  • case-mcu-bringup.sh's negative control had inverted. It asserted the bring-up FAILS without LD_LIBRARY_PATH. Our CPython is built --disable-shared now and needs no library path, so the case's own skip branch was the one firing. The port asserts the direction that is true today -- it runs under env -i -- which still goes red if a future build reintroduces the dependency.
  • case-upgrade.sh's headline assertion was vacuous. It proved an update removes init.d/S60web; the installer did rm -rf $MODDIR/init.d unconditionally, so it would have passed on an installer whose entire deletion logic had been removed. The port makes that claim against a path the deletion actually governs, and gives the unconditional removal a test of its own.
  • case-boot-screen.sh confirmed its own arithmetic. It computed the expected pixel packing by calling ffscreen._pixel inside the replica, so a swapped R/B would have matched itself perfectly. The port computes the expected bytes on the host.

Two rakes in the replica, recorded so nobody steps on them twice

  • The install log double-counts. FlashForge's run.sh runs under set -x and run-append.sh is spliced into it, so every echo appears twice -- once as the + echo ... trace and once as output. Counting a string in /usr/data/anvil-install.log says two on one occurrence.
  • /usr/bin/od takes neither -A nor -t, and busybox od resolves to the same binary, so the usual od -An -tx1 returns a usage error -- which, parsed as bytes, is an empty list rather than a loud failure. xxd -p is present and does the job.

The replica cannot boot the whole tree, and that is hardware

Eight of test_s6rc.py's twelve need the WHOLE boot set and cannot pass on a replica. Measured, in this order:

  • there are no /dev/ttyS4,5,7, so mcu-bringup fails and klippy never gets past disconnected;
  • there is no /dev/video*, so camera hits its 40s timeout-up and s6-rc reports command exited 99;
  • ff-startup then waits out its own timeout-up of 300000ms for klipper -- holding the s6-rc lock, which is why a poll during that window gets unable to take locks: Device or resource busy rather than a short list of what did come up.

nginx and moonraker do come up, and moonraker reaches ready -- which is what test_web.py asserts against, by bringing up those two alone before anything takes the lock. So the blocker is not the boot graph; it is that two services need hardware the replica has not got. Closing it is the simulated-MCU lane in Not in scope below, not a longer deadline in the fixture.

The four that ask only about what was installed pass, and one of them was failing for a reason worth recording: s6-rc-init's default deadline is TAIN_INFINITE_RELATIVE, which does not fit this printer's 32-bit time_t, so it died with EOVERFLOW before reaching its subject. Every s6-rc-init in the suite now passes -t, the same fix firmwareExe carries.

test_probes.py has no counterpart in the old suite and is worth explaining: Printer.ps() and Printer.listening() are parsers, and a parser is where a harness goes quietly wrong. A listening() that never matches makes every readiness assertion pass by the absence of contradiction. Neither failure looks like a failure — they look like a green suite. So they are tested against captured output, in the static lane, with no docker involved.

What phase 8 did to both suites

docs/notes/80-s6-migration.md phase 8 deleted payload/init.d/ and payload/anvil-service.sh and made the boot a compiled s6-rc database. That is not a refactor the suites can be patched through: a large part of what they assert no longer exists to be asserted.

Retired. qa/replica/test_services.py is deleted. It was the port of case-services.sh and its subject was the wrapper contract -- seven S* scripts, each loading a shared library, answering status, naming itself, and rejecting an unknown verb. There are no wrapper scripts. Three of its assertions survived the move and live in qa/replica/test_s6rc.py: the scanner is the cross-built ELF we shipped, its log is empty, and every run in the scandir is executable. The rest describe a machine that cannot be built any more.

Replaced by qa/replica/test_s6rc.py, which asks what the printer DOES -- init, one transition, a killed daemon coming back.

There was a static half as well, test_s6rc_source.py: it compiled the source tree and read the graph, bundle, timeouts and generated shebang back out with s6-rc-db, never out of the source tree, because the compiler is free to reject, rewrite or silently drop what it was given. (down is the one that bit us: s6-rc-compile accepts a down file in a definition directory and discards it, producing a byte-identical servicedir.)

It is gone, and what it cost is worth stating. It needed an s6-rc-compile that runs on the build host, and the only way to get one was a second NATIVE build of skalibs, execline, s6 and s6-rc -- 110 lines of bin/payload.sh whose entire hazard was that both stacks had to be --prefix=$MODDIR or the #! baked into the oneshot runner pointed at the build host's execline. The database is compiled in the replica now, by the s6-rc-compile the payload ships, so that hazard cannot exist. The price is that a boot-order mistake is caught by the replica lane rather than in a second, and that the timeout and edge assertions are not currently made anywhere. The oneshot runner's shebang was the one part a host could still check and bin/verify.sh kept it; that file is gone too (see bin/verify.sh is retired below), and qa/replica/test_s6rc.py covers the same ground by booting the database rather than reading it.

The gates phase 8 killed have been retired

These case-*.sh asserted deleted architecture -- they drove $MODDIR/init.d/S* or sourced anvil-service.sh, neither of which exists. The decision was made against real output, not against a reading of the scripts:

make test-services  ->  FAIL  the payload ships no anvil-service.sh -- every service aborts
make test-nginx     ->  FAIL  the payload has no etc/s6/nginx/ -- nothing to supervise
                        cp: can't stat '/tmp/payload/anvil-service.sh'
retired lines asserted where that lives now
case-services.sh 360 the seven-script wrapper contract, force-start nowhere -- the contract is deleted
case-nginx.sh 416 the MOD_S6=0 unsupervised fallback nowhere -- the fallback and the flag are deleted
case-moonraker.sh 1,059 the same fallback, plus S62moonraker nowhere
case-moonraker313-s6.sh 708 Moonraker on 3.13 started by S62moonraker needs a port once the replica can boot
case-camera.sh 467 S40s6 and S65camera verbs the readiness question survives; see below
case-priority.sh 108 svc_start_daemon niceness unasserted; the surviving path is -N inside etc/s6-rc/source/klipper/run

Retired in the same pass, for a different reason -- no gate, no Makefile target, no CI step, never run once: case-pyext.sh (567) and case-moonraker313.sh (487). Both were phase-6 spikes whose questions are settled. A file that looks like coverage and is not is worse than no file.

With them went gates.py's services, camera, nginx, moonraker and moonraker313_s6 (and _prefix_tarball / _moonraker_tarball, which nothing else used), their run-tests.py calls, six make test-* targets, and sim-moonraker313.py. About 4,200 lines of shell; the old suite's 13 replica bits are 8.

Two of the three assertions they owned were carried across, into qa/replica/test_web.py: nginx coming back from a kill and staying stopped after a deliberate stop, and Moonraker answering on :7125 on the cross-built 3.13 rather than FlashForge's 3.8.2. Seven named tests where case-nginx.sh and case-moonraker313-s6.sh were 1,124 lines and two bits.

That module brings up only nginx and moonraker rather than running the whole boot, for the reason in the next section, and it says so in its header. Bringing up a subset is a departure from run the real boot, do not reimplement it; it goes through the same compiled database and the same s6-rc, so only the transition's argument differs, and the alternative was asserting nothing about supervision until somebody builds the MCU lane.

The third -- s6-svwait -U readiness gating rather than meaning "forked", which case-camera.sh asked -- is still owed, and cannot be asked here: the camera service needs a /dev/video* the replica has not got.

case-install.sh was stale, not dead -- and it was the one that mattered

It was the last gate to go red, and the sweep above missed it because its stale assertions name the wrapper scripts WITHOUT the init.d/ prefix, so the grep that classified every other case did not catch them. Its subject -- the printer's own app_startup.sh installing a real package off a real FAT filesystem -- was entirely alive, so it was ported, not retired:

was now, in qa/replica/test_install.py
grep -q helix on firmwareExe -- the wrapper launched the UI itself the wrapper starts s6-svscan and asks s6-rc for a transition; the UI is a service in the compiled database
[ -d $MODDIR/init.d ], else "no service dir exists to start Klipper" s6-rc-db list services names klipper, and s6-rc-db dependencies klipper names mcu-bringup
S60nginx S62moonraker S70klipper S80ui each appear in the boot log not replaced by a grep for the new names -- see below

That last row is the one to be careful about. The boot log line reads s6-rc: bringing up: camera klipper moonraker nginx ui ff-startup wifi and names every service in the boot set INCLUDING THE ONES THAT THEN FAIL, so grepping it for a service name yields an assertion that passes on a machine where that service never started -- green, and unable to go red. The compiled database is asked instead, which is a fact about what was installed rather than an intention.

Most of the 579 lines were DRIVING the install -- attach the stick, run app_startup.sh, wait, repeat. None of that survives, because the printer fixture already is the result. What is left is the part that was always the point: the assertions.

Two of its three boots could not come across. The re-install (boot 2) needs a second package on a stick and the bake deletes /stick.img once the install lands -- but the property it existed for, an update keeps what the user edited and drops only what the last package shipped, is asserted directly in test_upgrade.py. Boot 3's UI liveness needs ok-all, which is unreachable on a replica; its no-stick half -- app_startup.sh must not go looking for an update that is not there -- did come across, since the baked machine IS the stick-pulled case.

What was stale in the pytest half

test_startup.py built its argparse.Namespace by hand and had not been taught --only-bringup / --no-bringup, so 43 of its tests failed with AttributeError inside run() -- the legacy suite was already red on this branch. Its FakeKlipper also still modelled start.sh plus a separate stop; klipper is restarted with one s6-svc -wr -t on its live servicedir now, so the fake models that instead and FF_SKIP_MCU_BRINGUP -- recorded but never asserted -- is gone. test/ pytest is green again: 185 passed, 1 skipped.

run-tests.py's shell globs had rotted the other way, and this one had teeth: pkgs/*/prog/*.sh and pkgs/*/prog/firmwareExe matched nothing, because prog/ moved under payload/. firmwareExe -- the boot -- and start.sh were being syntax-checked and bashism-checked by nothing at all. check_shell_syntax guards only the union of its patterns, so it stayed green while covering less than it claimed. qa/static/test_shell_syntax.py had the paths right already and refuses any glob that matches nothing, which is the discipline that would have caught it.

What is left

Nothing. test/run-tests.py and test/ffsim/report.py are deleted, and with them the JUnit-XML round trip that turned 185 pytest results into one gate. make test is gone; make qa is the suite.

Three gates were dropped rather than ported, as a coverage decision:

dropped was consequence
the pytest gate run-tests.py ran pytest and re-parsed its XML none at the time -- make test-py and CI ran pytest directly; both went when test/ was deleted
the rootfs extraction ran unpack.sh + extract_rootfs inline none, and now moot -- extract_rootfs and make rootfs are both gone, and the replica image does its own extraction at build time
the packaging build on a synthetic stock package make-stock-fixture.sh, then unpack/patch/pack/verify.sh real: bin/unpack.sh, payload.sh and pack.sh now have no test at all. verify.sh has since been retired outright -- see below

That last row is the one to be uneasy about, and it is recorded here rather than buried in a commit message. The build path is what produces the .tgz a user flashes, and nothing exercises it end to end any more. qa/static/test_packages.py covers package building -- the .apk layout, the ABI string, reproducibility -- but nothing runs the outer build. The CI job with no firmware has lost most of its content as a result.

test/integration/make-stock-fixture.sh was kept for a while as the input a replacement would need -- a synthetic FlashForge package built from the versions.env pins, so the build could be tested with no proprietary firmware -- and has since been dropped: it fed a gate that no longer exists, and a fixture generator with no consumer rots exactly as silently as a test that asserts nothing. Whoever ports the build path will have to write it again, and should know two things it had learnt. make_fixture staged into work/.fixture INSIDE the repo on purpose: sibling containers resolve their mounts host-side, so a path under the build container's /tmp does not exist as far as the daemon is concerned. And the shape it reproduced -- the software component's run.sh ending in tar -xf $WORK_DIR/klipper/chelper.tar -- is what bin/payload.sh depended on while the klippy tree still went out on the firmware partition; the tree lives at $MODDIR/klipper now and neither that section nor the chelper.tar exists. Recover both from git history rather than from a fresh reading of a stock package.

bin/verify.sh is retired

Forty checks, run on the build host against a decrypted copy of the .tgz. Four survive, in qa/replica/test_what_ships.py. The rest went because of what the word simulate was doing in the file's own description -- "simulate every check the printer performs".

The replica lane does not simulate the install. qa/replica/actions/install-package.sh puts the package on a genuine FAT filesystem at /dev/sda1 and runs the machine's own /usr/prog/app_startup.sh over it, verbatim, under qemu. So the decrypt, the plain-tar components, md5sum.list, the MACHINE= gate and the /mnt/<Model>-*.tgz glob are all preconditions of the lane now rather than assertions in it: break any of them and the install fails, installed_image() raises with the install log attached, and every replica test goes red at once. A host-side re-reading of the same rules is a second implementation, and a second implementation can agree with itself while disagreeing with the printer -- the lesson qa/replica/conftest.py records this repo learning twice about hand-placed payloads.

A second block was asking what the suite already answers, and answers of an installed filesystem rather than of a tar -t listing: the klippy tree and c_helper.so (test_install.py, test_abi.py), the compiled s6-rc database and the oneshot runner's execline (test_s6rc.py), s6 and s6-ftrigrd (test_supervisor.py), libsodium's bare .so symlink and the chamber configs (static/test_packages.py), and every shipped script's syntax (static/test_shell_syntax.py).

The four that moved are the ones nothing else asks, and each got stronger for the move -- a listing cannot tell you that a shared object loads, or what a name resolves to on a PATH:

  • the shipped interpreter imports sqlite3, lmdb and _cffi_backend, rather than a grep finding three .so files. This is the check that catches a cross build resolving to an x86_64 manylinux wheel, which is present on disk and raises on import.
  • nothing in $MODDIR/bin shadows FlashForge's python3, asked with command -v after sourcing anvil-env.sh -- which is the file that prepends $MODDIR/bin to PATH and therefore creates the hazard.
  • CPython's dev half (headers, pkgconfig, config-3.13-*) did not ship.
  • the ship boundary: no file on the installed printer is byte-identical to one under bin/ or docker/. Asked of the machine rather than of the .tgz, so it also sees anything a maintainer script wrote as root at install time.

What genuinely left with it, and is recorded here rather than in a commit message: there is no longer any check you can run on the build host, in seconds and without docker, against a package you are about to flash. In particular nothing warns you before you copy the file to a stick that it was built from the wrong model's stock package. The printer still refuses it ("Firmware does not match machine type") and make qa-replica still catches it, but the fast host-side warning that make verify gave the hardware checklist is gone.

What survived in test/, and then did not

Two things did, for a while. Both are gone now and test/ with them:

  • test/integration/test_*.py -- 28 host-side unit tests in five files, down from 186; see The second cut below. Deleted, with nothing taking over the tool-frame arithmetic, the chamber macros' per-model branching, the stamp discipline in ff-startup.py, or the verification G-code.
  • test/test-chelper.py -- in neither suite; pkgs/klipper/build.sh and bin/verify.sh called it directly at build time. Deleted with the tree, and its callers with it. What keeps a stale c_helper.so out now is that pkgs/klipper compiles the .so from the chelper sources of the tree it ships.

And two things that were under test/ and are not tests, now at tools/replica/:

  • ffsim/ -- no longer a test framework and now smaller again, with extract_rootfs and its two tar helpers gone along with make rootfs. Replica is what make boot-screen-sim and, more to the point, bin/payload.sh use -- the payload is assembled by running the printer's own apk inside a replica, so this is build-path code that happened to live in the test tree.
  • printer/ -- the Dockerfile.full, entrypoint.sh, assemble.sh, binfmt.sh and seed-prog.sh that BUILD the replica. qa/lib/replica.py uses them unmodified; they were never the problem. Moving them out of test/ changed no content, only qa/lib/replica.py's build directory and three launcher paths.

The second cut

The migration left test/ at 186 tests across twelve files. Seven of those files tested our own Python against fakes so thoroughly that the fake, not the printer, was the subject, and were dropped on that ground:

dropped tests asserted what is lost
test_ff_legacy.py 6 FF_IMPORT_FIRMWARE_CONFIG against a hand-built klippy stub the command's JSON reading is unasserted
test_ff_mcu_bringup.py 5 the port tuple and the every-port-fails path nothing asks about either; the handshake was never covered anywhere -- see One thing found on the way
test_config_ownership.py 5 the DO-NOT-EDIT banner on mod-owned configs, and its absence on moonraker.conf a banner can go stale without anything noticing
test_harness.py 4 repo_root, that every harness file compiles, and that every shebanged script is executable in git the compile half is covered better by qa/static/test_shell_syntax.py's pyflakes pass; the executable-bit check is not covered anywhere -- qa/ checks only qa/replica/actions/*.sh
test_includes.py 8 printer.base.cfg's include set and order a new ff-*.cfg can be shipped and never included
test_paths.py 7 every literal /bin,/sbin path in a payload script exists in the real rootfs the branches no replica reaches -- S50wifi's udhcpc, wpa_supplicant -- are unasserted. It was also the last user of the rootfs fixture, now gone from test/conftest.py
test_tool_offset_sampling.py 15 the SAMPLES/SAMPLES_TOLERANCE parameter set in ff_tool_offset._estop the defaults-match-the-fork property is unasserted

The four rows in bold-ish -- the executable bit, the include set, the rootfs paths -- are static questions that a replica cannot answer any better, so if they come back they belong in qa/static, not here.

The five survivors were then cut from 136 tests to 28 -- the 20% worth keeping -- on one question: would this failure be silent?

file was now what the survivors are
test_startup.py 49 8 the stamp discipline, and the handover order. A stamp written before the values are verifiably saved is a printer that has lost its factory calibration for good, and no later boot will try again
test_ffscreen.py 33 5 the four ways it can do harm -- scribble outside the buffer, scribble a format it does not understand, raise into the migration, or turn the picture
test_tool_transform.py 24 7 every kept test is a move to the WRONG PLACE: a sign, a frame left applied while probing, a stale gcode_move cache, a klippy that will not parse its config on a calibrated printer
test_chamber.py 20 4 the three chamber mistakes that are silent until they have cost a print, plus the parse gate they rest on
test_gcode.py 10 4 a renamed macro, and the safe file's promise that it is cold and stays above Z50

What went with them, in one sentence each: the panel narration (words a person reads while waiting, and qa/replica/test_boot_screen.py renders the real thing on the real machine); the geometry probe and parse_geometry (a panel that looks wrong, not a printer that breaks); z_adjust staging and the per-job Z term (wrong numbers reported to a person); the config layout checks (a config that does not load, which the printer says loudly the first time); and the G-code bounds checks (Klipper refuses the move at the first offending line, before the toolhead has gone anywhere).

Each of the five files says in its own header what it dropped and why. That is deliberate: a test file that has been cut hard should say so where the next person opens it, not only in a document they may not read.

Still owed

  • The build path, above. The largest gap. Its fixture generator, make-stock-fixture.sh, has been dropped too; recover it from git history.
  • The rollback, case-recovery.sh's subject: install the mod, flash the stock package, be back byte-for-byte. Retired without a replacement.
  • s6-svwait -U readiness on the shipped camera definition: the service needs a /dev/video* the replica has not got. test_supervisor.py asks the same question of a service it builds itself, which is close but not the same.
  • The MCU lane, which would unblock the eight test_s6rc.py tests and let test_install.py assert a booted machine rather than an installed one.

One thing found on the way

case-mcu-bringup.sh:4 says the handshake is "tested elsewhere against a pty". There is no pty anywhere in the repo. test_ff_mcu_bringup.py checks the port tuple and the every-port-fails path, and its own comment concedes it. Every documented past bug in ff_mcu_bringup.py lived in the code that comment excuses from testing — and the missing /dev/ttyS* is now also what stops the replica booting, so one lane would close both.

Not in scope

A third lane — real klippy in batch mode against a simulated MCU — would close the gap docs/testing.md names: "the replica cannot, because it never starts klippy." The fork already ships the machinery (scripts/test_klippy.py, klippy's -i/-o/-d batch mode) unused. It is a separate bet with its own unknown (an MCU data dictionary for four MCUs, with no hardware to capture one from), and it was deliberately left out so this change has one variable.

Note for whoever picks it up: work/klipper-fork/klippy/chelper/c_helper.so is a MIPS binary. Host klippy cannot load it. Extract the fork tarball to a separate tree and let klippy build the host one there; never build it inside work/klipper-fork, whose artifact is a gated build output.