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
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/.s6andbin/payload.shstages 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.shby 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 withoutLD_LIBRARY_PATH. Our CPython is built--disable-sharednow and needs no library path, so the case's ownskipbranch was the one firing. The port asserts the direction that is true today -- it runs underenv -i-- which still goes red if a future build reintroduces the dependency.case-upgrade.sh's headline assertion was vacuous. It proved an update removesinit.d/S60web; the installer didrm -rf $MODDIR/init.dunconditionally, 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.shconfirmed its own arithmetic. It computed the expected pixel packing by callingffscreen._pixelinside 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.shruns underset -xandrun-append.shis spliced into it, so everyechoappears twice -- once as the+ echo ...trace and once as output. Counting a string in/usr/data/anvil-install.logsays two on one occurrence. /usr/bin/odtakes neither-Anor-t, andbusybox odresolves to the same binary, so the usualod -An -tx1returns a usage error -- which, parsed as bytes, is an empty list rather than a loud failure.xxd -pis 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, somcu-bringupfails and klippy never gets pastdisconnected; - there is no
/dev/video*, socamerahits its 40stimeout-upand s6-rc reportscommand exited 99; ff-startupthen waits out its owntimeout-upof 300000ms for klipper -- holding the s6-rc lock, which is why a poll during that window getsunable to take locks: Device or resource busyrather 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,lmdband_cffi_backend, rather than a grep finding three.sofiles. 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/binshadows FlashForge'spython3, asked withcommand -vafter sourcinganvil-env.sh-- which is the file that prepends$MODDIR/bintoPATHand 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/ordocker/. 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 inff-startup.py, or the verification G-code.test/test-chelper.py-- in neither suite;pkgs/klipper/build.shandbin/verify.shcalled it directly at build time. Deleted with the tree, and its callers with it. What keeps a stalec_helper.soout now is thatpkgs/klippercompiles 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, withextract_rootfsand its two tar helpers gone along withmake rootfs.Replicais whatmake boot-screen-simand, more to the point,bin/payload.shuse -- the payload is assembled by running the printer's ownapkinside a replica, so this is build-path code that happened to live in the test tree.printer/-- theDockerfile.full,entrypoint.sh,assemble.sh,binfmt.shandseed-prog.shthat BUILD the replica.qa/lib/replica.pyuses them unmodified; they were never the problem. Moving them out oftest/changed no content, onlyqa/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 -Ureadiness on the shipped camera definition: the service needs a/dev/video*the replica has not got.test_supervisor.pyasks 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.pytests and lettest_install.pyassert 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.