18 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Opus 5 83a4600df8 Stop naming things that are not there any more
A sweep for comments and messages that describe machinery this tree no longer
has. Each one read as current and was not.

  * bin/verify.sh, retired two commits before this branch, was still named as
    the thing `make release` and docs/building.md were explaining the absence
    of. The absence needs no explaining -- `make qa-replica` is the gate.
  * `make test-recovery` does not exist. docs/hardware-testing.md told owners
    it "used to" prove the stock rollback; the useful half of that -- nothing
    tests the rollback today, treat it as unverified -- is kept and the dead
    target name is not. runFirmwareExe.sh pointed at it too.
  * run-append.sh in bin/build-payload.py, case-install.sh in
    docs/printer-replica.md: both files are gone.
  * `make test`/`make test-py` over test/, and the note that both suites run
    in CI "until a case-*.sh has a green replacement" -- test/ was deleted and
    there are no case scripts left to replace.
  * docs/building.md still said a package carries the software component.

CI's shellcheck globs are the one real defect this turned up. Two of the four
-- pkgs/*/payload/init.d/S* and pkgs/*/prog/*.sh -- match nothing: the recipes
stopped using both paths, and payload/prog/ is where those scripts live now.
shellcheck given a pattern that expands to nothing writes usage to stderr and
exits 1, so that step has been failing for its globs rather than for the code.
Fixed to the paths that exist, with a pointer to
qa/static/test_shell_syntax.py, which addresses the same files and refuses a
glob that matches nothing -- the guard this step did not have.

Verified: the corrected invocation runs clean over the 21 files it now
matches, and 112 static tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:38:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 56b5b6c334 The printer image is the only replica; make rootfs was a second way nothing took
`make rootfs` unsquashed rootfs.squashfs out of the stock package into
work/rootfs, and a thin container bind-mounted it with PROG_DUMP supplying
/usr/prog from the factory image. Every caller set PRINTER_IMAGE anyway --
both workflows wrote it into test.env BEFORE running `make rootfs`, so CI
unpacked a 93MB package, unsquashed a rootfs and downloaded a 182MB factory
image on every push and read none of it. build-printer-image.sh never used
work/rootfs either: Dockerfile.full does its own extraction from the same
public firmware and needs no stock package at all.

So the fallback went, and PROG_DUMP with it -- it was only ever read when the
replica was built locally, and both implementations already passed it empty
otherwise. Gone: the target, extract-rootfs.py, gates.extract_rootfs and its
two tar helpers, the thin printer/Dockerfile, the `prebuilt` flag threaded
through both Replica implementations, and Replica.start's want_output, whose
only message was that the local build was about to be slow.

PRINTER_IMAGE is pinned to 1.9.7-1.2.9-20260810 rather than :latest. It is
now the entire substrate the brick-safety gates run on and the only input
nothing checksums, so a floating tag would let it change under a green suite
-- the drift the sha256 pins in both workflows exist to prevent.

THREE THINGS THAT COULD NOT HAVE PASSED, found while doing it:

  * Neither workflow built the feed. bin/patch.sh has refused to start
    without one since a1b4ea6, 45 commits after ci.yml was last touched.
    Both ran unpack/patch/pack straight into that guard.

  * `make release` ran bin/build.sh on $(RUN), which has no docker socket,
    while patch.sh assembles the payload inside the replica. `build` has
    used RUNBUILD all along; `release` was left behind. It also built no
    feed. It now builds one, once, outside the per-model loop -- nothing in
    it is model-specific.

  * ci.yml's shellcheck gate named pkgs/*/payload/init.d/S* and
    pkgs/*/prog/*.sh, neither of which exists any more; shellcheck exits 2
    on a path it cannot open. It is a `find` with an asserted count now,
    because the opposite failure is worse: a glob that quietly matches
    nothing checks nothing and still reports green.

Both workflows now go through `make` instead of hand-rolling `docker run`.
Hand-rolling meant CI exercised entry points no human uses, which is exactly
why the `release` runner bug could sit there unseen; it also ran as root and
left a work/ that the later `make release` could not write to.

ci.yml's test job builds the feed before the static lane. A good part of that
lane asks what is inside an .ipk and those questions do not fail without one
-- they return, and the lane reports a pass having never opened a package.
build-packages.sh needs no stock firmware, which is what makes that possible.

Docs, and the packages skill, which still documented pkg_buildopkg and
work/.opkg-host as how the payload is assembled -- the host opkg went in
dcd037c and the printer's own installs the feed now -- and still claimed
qa/static holds an ABI gate over every ELF in every package. It does not:
qa/replica/test_abi.py is the only one, and qa/static asserts a seventh does
not grow back.

Verified: qa/static is 122 passed / 110 errors here, identical to master --
every error is `vendor/opkg-utils is missing` in a checkout that has not run
`make vendor`. mkdocs build --strict passes. The new shellcheck gate was run
as written. The workflows themselves were not executed.

tools/replica/printer/*.sh live inside the published image, so those edits
take effect on the next `make printer-image`; behaviour is unchanged either
way, because nothing sets PROG_DUMP any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrhjKabxDihvVjNQGGT4MK
2026-08-30 14:56:26 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 57c776ee63 test/ is deleted; qa/ is the only suite
The eight files were already gone from the working tree. What this commit does
is everything that had to follow them, because nothing else had been updated
and three of the four things pointing at test/ were on a build or release path.

WHAT WAS STILL WIRED TO IT

  pkgs/klipper/build.sh   ran test/test-chelper.py under `|| pkg_die`. With the
                          file gone `make packages` would have died on every
                          klipper build.
  bin/verify.sh           ran the same script and read a non-zero exit as
                          "c_helper.so does not match the shipped klippy tree
                          (stale build)" -- a false red on every release verify
                          rather than an error about a missing file. Its
                          ship-boundary check also globbed `find bin test
                          docker`; that is `find bin docker` now.
  pytest.ini              testpaths = test, so a bare `pytest` collected
                          nothing at all. It is `qa` now, which is what the
                          comment above it was already describing.
  qa/static/              PY_GLOBS listed test/*.py and test/integration/*.py,
    test_shell_syntax.py  and _files() makes an empty glob a collection error
                          on purpose -- so the whole static lane failed to
                          collect, taking 93 unrelated tests with it.

Also dropped: the `pytest -- the host-side tree` step in ci.yml, the `test-py`
target with its .PHONY entry and two help lines, and qa/conftest.py's opening
paragraph explaining why qa/ sat beside test/ rather than inside it.

THE CHELPER SYMBOL CHECK IS GONE, NOT MOVED

It compared the .so's dynamic symbols against what klippy cdefs, and it was the
only check for a c_helper.so built from older sources than the klippy tree
beside it -- the failure that shipped once and bricked klippy startup on
hardware, because cffi resolves lazily and a stale .so imports cleanly.
qa/replica/test_install.py covers the ELF arch and nan2008 and nothing else.

What stands in its place is structural rather than a gate: pkgs/klipper
compiles the .so from the chelper sources of the very tree it ships, so the
mismatch is not something the recipe can produce. The comments in
pkgs/klipper/build.sh and docs/building.md now say that is the whole defence.

DOCS

docs/testing.md's "what is left in test/" section is now "test/ is gone", and
it names what went uncovered rather than leaving it to be found: the tool-frame
arithmetic in ff_toolchange, the chamber macros' per-model branching, the stamp
discipline in ff-startup.py, and the verification G-code against the shipped
macros. docs/hardware-testing.md said `make test-py` keeps the two
gcode/creator5-*.gcode files honest; it now says nothing does, and to read them
by hand when a macro is renamed. Paths corrected in docs/building.md,
docs/printer-replica.md and docs/qa-migration.md's intro.

Verified: `pytest` collects 366; qa/static is 136 passed, up from 43 because
the lane could not collect before this. Its 110 errors are this checkout's
missing vendor/opkg-utils, the same cause before and after. bin/verify.sh,
pkgs/klipper/build.sh and the Makefile parse; ci.yml is valid YAML.

Not touched, and stale before this commit: tools/python/README.md and
tools/supervisor/README.md still document test/integration/printer/case-*.sh
and a printer-exec.py that earlier commits removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bLGYpVAeVzAX6NeAxcU42
2026-08-30 01:10:24 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c185ef3285 The replica is a build tool; test/ is 28 unit tests
Two changes that turned out to be one.

MOVED, NOT DELETED. `test/ffsim/` and `test/integration/printer/` are
`tools/replica/`. They were never tests: bin/patch.sh calls
bin/build-payload.py, which starts a replica and assembles the payload by
running the PRINTER'S OWN opkg against / inside it -- so the "test harness"
was on the build path, and deleting it as asked would have broken `make
build`. qa/lib/replica.py drives the same directory unmodified. No content
changed; what changed is qa/lib/replica.py's build directory, three launcher
sys.path lines, the Makefile targets and the CI shellcheck glob.
tools/replica/README.md says who drives it and why it is not under test/.

CUT TO 28. The five surviving modules were 136 tests; they are 28, chosen on
one question: would this failure be SILENT? What is kept is the mistakes that
run perfectly and are wrong --

  test_startup       8  no stamp unless the values are verifiably saved (a
                        stamp written early is a printer that has lost its
                        factory calibration for good), and the handover order
  test_tool_transform 7 every one is a move to the wrong place: a sign, a
                        frame left applied while probing, a stale gcode_move
                        cache, a config a calibrated printer cannot parse
  test_ffscreen      5  the four ways it can do harm rather than look wrong
  test_chamber       4  the chamber mistakes that cost a print before anyone
                        sees them, plus the parse gate they rest on
  test_gcode         4  a renamed macro, and the safe file being cold

What went: the panel narration, the geometry probe and parse_geometry, the
z_adjust and per-job-Z bookkeeping, the config layout checks, the G-code
bounds checks. Each is either a wrong number shown to a person, or a failure
the printer reports loudly the first time -- and qa/replica/test_boot_screen.py
already renders the real frames on the real machine.

The rewrite is not a delete: assertions were folded so a kept test asks its
question in every direction it can be got wrong (both models, both rotations,
all four ways the migration can fail), and each file's header now says what it
dropped and why, where the next person will open it.

Also gone: conftest's `rootfs` fixture, orphaned two commits ago. Nothing under
test/ needs the firmware, docker, or a network any more.

28 host-side tests pass in 0.15s. qa/static unchanged at 218. `make rootfs`
re-verified end to end from its new path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzLebCrvc8ssWR5JuEsha4
2026-08-29 23:27:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 6aa622d2ff 6,280 lines of case scripts reported 13 bits. They are 118 tests now.
test/run-tests.py is gone, and with it the bespoke harness that wrapped
pytest, re-parsed its JUnit XML, and drove thirteen replica case scripts to
get one boolean back from each. Every gate it ran is either a module under
qa/replica/ or deliberately retired.

PORTED. case-install.sh (579 lines, 1 bit) is test_install.py, 22 tests, asked
of the machine the printer's own app_startup.sh built rather than one the
harness assembled. case-upgrade.sh is 21, case-supervisor.sh 31,
case-boot-screen.sh 17, case-mcu-bringup.sh 8, and case-nginx.sh plus
case-moonraker313-s6.sh are test_web.py's 7.

RETIRED, subject deleted by the s6-rc switch: case-services.sh, case-nginx.sh,
case-moonraker.sh, case-moonraker313-s6.sh, case-camera.sh and
case-priority.sh drove $MODDIR/init.d/S* or sourced anvil-service.sh, neither
of which exists. Decided against real output -- `make test-services` failed
with "the payload ships no anvil-service.sh". case-pyext.sh and
case-moonraker313.sh went too: no gate, no target, no CI step, never run once.

RETIRED as a coverage decision: case-python.sh (test_web.py runs real Moonraker
on that interpreter, which is harder), case-libpath.sh, and case-recovery.sh.
The last is the brick-safety rollback and has no replacement; it is recorded in
docs/qa-migration.md rather than left to be discovered.

THREE ASSERTIONS HAD STOPPED MEANING ANYTHING, and each had been green for
months:

  * case-mcu-bringup.sh's negative control had INVERTED. It asserted the
    bring-up fails without LD_LIBRARY_PATH; CPython is --disable-shared now and
    needs no library path, so the case's own skip branch was the one firing.
  * case-upgrade.sh's headline assertion was VACUOUS. run-append.sh:150 does
    `rm -rf $MODDIR/init.d` unconditionally, outside the manifest branch, so
    "the update removed the stale S60web" passed regardless of the manifest
    logic it was written to test.
  * case-boot-screen.sh CONFIRMED ITS OWN ARITHMETIC, computing expected pixel
    packing by calling ffscreen._pixel inside the replica -- a swapped R/B
    matched itself.

case-install.sh's boot-log grep is not reproduced for the same reason: the log
line names every service in the boot set including the ones that then fail, so
grepping it cannot go red. The compiled database is asked instead.

TWO REAL BUGS FOUND ON THE WAY.

assemble.sh attached /stick.img with `losetup` and wrote the device to
/stick.loop, which NOTHING HAS EVER READ. Loop devices are the host's, not the
container's, so every USB_STICK=1 run leaked one permanently and a WSL2 kernel
has thirteen. Once they were gone every replica run failed with "failed to
setup loop device", which looks like a docker fault and is not. Detached now in
entrypoint.sh on the way out, and in qa's stop() for the held-open containers
that never take that path.

test_startup.py was 43 tests red: it built its argparse.Namespace by hand and
had never been taught --only-bringup / --no-bringup, and its FakeKlipper still
modelled start.sh plus a separate stop when klipper is restarted with one
`s6-svc -wr -t`.

CI AND RELEASE. The replica job was getting its package as a side effect of
run-tests.py running unpack/patch/pack; that is an explicit step now, as the
comment there predicted it would have to be. release.yml tested the exact files
it ships through sim-install.py, whose exit status the loop was discarding --
it is `REAL_PKG=<pkg> pytest ./qa/replica` per model now, 22 named results each.

WHAT IS NOT TESTED ANY MORE: bin/unpack.sh, patch.sh, pack.sh and verify.sh.
The packaging build on a synthetic stock package went with the runner and has
no counterpart in qa/. That is the path producing the .tgz a user flashes.
make-stock-fixture.sh is kept as the input a replacement needs.

Measured: qa/static 246 passed, qa/replica 110 passed and 8 blocked on hardware
the replica has not got (no /dev/ttyS*, no /dev/video*, so ok-all is
unreachable), test/ pytest 185 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BTAXgjtF8GdhqSxvdXCFW
2026-08-29 22:51:50 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8994b461b0 Every service starts the same way, and Moonraker stays on /usr/data
Moonraker is no longer copied to /usr/prog. The payload already extracts
that exact tree to /usr/data/anvil/moonraker, so the installer was making a
second identical copy onto the small firmware partition -- the one install
step that could fail on space, whose failure was "this printer has no
working web UI". Worse, /usr/prog is what a stock flash overwrites while
/usr/data survives, so a firmware update silently reverted Moonraker to
FlashForge's 2022 build and left the rest of the mod in place: exactly the
"which Moonraker is this printer running?" ambiguity the previous commit set
out to end. Neither of the two things thought to pin it there holds -- the
moonraker-env virtualenv is not on sys.path (imports resolve from
Python-3.8.2/lib/python3.8/site-packages, checked on the real image) and
moonrakerDaemon is never invoked. FlashForge's tree is left alone, unused.

S60web became S60nginx and S62moonraker. nginx and moonraker fail for
different reasons and are now stopped, started and diagnosed separately;
MOD_WEB still governs both.

payload/anvil-service.sh is the shape all six services now share. They had
been written one at a time and had arrived at four different answers to "is
it running" and two to "restart", none of them decisions. Two real bugs were
living in that drift, and both are fixed here:

  * busybox start-stop-daemon -K returns before the process is dead and
    never removes the pidfile, so restart raced its own start -- stop
    returned, start found the pidfile still held, and the service ended up
    stopped rather than restarted. The `sleep 2` in front of restart was
    standing in for that wait; svc_stop_daemon does it properly and the
    sleep is gone.
  * klippy does not unlink /tmp/uds on exit. supervise() knew that; stop()
    did not, so status reported a dead klippy as running and restart
    declined to do anything at all.

run-append.sh now clears $MODDIR/init.d before extracting. Without it the
rename above would leave a stale S60web beside the two new scripts on any
printer that already has the mod, and firmwareExe runs every executable
S* in filename order -- starting nginx and moonraker twice, from scripts
disagreeing about where moonraker lives.

bin/patch.sh stages anvil-service.sh. It is sourced, so no chmod +x.

test/integration/printer/case-services.sh is a new replica gate: it runs
every service on the printer's own busybox and checks the contract they
share rather than grepping them for how they are spelled. case-moonraker.sh
now asserts moonraker runs from /usr/data, that nothing under /usr/prog is
executed, that restart yields a genuinely new pid, and that stopping
moonraker leaves nginx up -- the point of the split.

The .modold rollback recovery in docs/how-it-works.md described a code path
removed in the previous commit and is rewritten for what happens now.

Verified in the replica (real rootfs.squashfs, qemu-mipsel): case-moonraker,
case-services, case-mcu-bringup and case-boot-screen all pass; pytest 181
passed, 5 skipped. case-install.sh and case-recovery.sh are syntax-checked
only -- they need a stock FlashForge package, which is not in this checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:32:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c7a821bb9d ff_mcu_bringup.py is importable by name
The filename was the only reason for the importlib shim: a module called
ff-mcu-bringup cannot be written as an import, so ff-startup.py loaded it from
a spec and hand-rolled the failure handling. Renaming it removes all of that
and leaves a plain guarded import beside the one ffscreen already had. start.sh
still runs it as a script, by path, on the fallback route -- a rename costs
that nothing.

What the shim was hiding is worth keeping visible, though: these imports
resolve only because python puts a SCRIPT's own directory on sys.path, which
is true when the wrapper runs the file by path and false the moment anything
loads it another way. So there is now a --selftest that reports what it can
actually reach, and the replica gate calls that instead of loading the module
itself -- which it was doing before, and which would have passed while the
real path was broken. It is worth having on a printer over ssh too.

The unit tests were making the same mistake in the other direction: loading
ff-startup.py through importlib with payload/bin absent from sys.path, so its
siblings were missing and the selftest correctly said so. The loader now puts
that directory on the path, which is what the runtime looks like, and a
separate test runs the script from another directory entirely to prove the
premise rather than assume it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:23:45 +03:00
Oleksandr ShyshatskyiandClaude Fable 5 8e51af61a4 The replica doc stops implying /usr/prog is unobtainable
"A factory image that no update package contains ... as close as the
packages allow" described the stub era, and both halves aged badly: the
factory image is published, the replica builds its prog partition from that
real dump with the installer laying the update on top, and the same doc
already said so two sections earlier. Now the framing matches -- the replica
is assembled the way a real printer is, not approximated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:50:41 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 ac558f6686 Document the Moonraker swap honestly, and finish its doc coverage
The reversibility claim was the strongest promise in the README and a40bc86
quietly invalidated it. Moonraker is not in the update package -- it ships only
on the factory image -- and run-append.sh deletes the stock tree it moved aside
once the copy succeeds. So a stock reflash cannot restore it and nothing local
can either. README and how-it-works now say so plainly, with BUILD_MOONRAKER=0
as the way to opt out and the ssh one-liner that repairs an interrupted swap.
Per the decision taken, the swap itself is left as it is.

Two claims that rested on it are corrected rather than repeated:
bin/common.sh no longer offers a stock reflash as a whole uninstall, and
testing.md's test-recovery row says which three files it actually compares
instead of "back to stock byte-for-byte".

Moonraker was also missing from eight doc surfaces that still described a
two-asset world: the pipeline box, the third-party section and its bump recipe,
MOONRAKER_TGZ as a local-copy override, the versions.env line in the layout
tree, `make vendor`'s help and the comment above it, config.env.example, the CI
step name, and printer-replica's "genuine from the factory image" list.

Other cross-document contradictions found this pass:

* versions.env carried the same wrong bump recipe that was fixed in
  building.md last pass -- it needs the sha256 set to SKIP first.
* docs/building.md said `make build` is "all four" while listing five scripts;
  build.sh does not run verify.sh.
* toolchange.md's image resolved to docs/docs/molten-toolhead.jpg -- broken on
  GitHub -- and still said ff-legacy.cfg is included "only for the import step,
  then remove", which printer.base.cfg contradicts.
* hardware-testing.md stated the random root password unconditionally, in the
  one document a person reads while standing at the machine.
* notes 30 and 00 kept claims their siblings now contradict (servo_min/max,
  motor_value), and 47 pointed at a note that does not exist.
* "nozzle-to-eddy-trigger gap" survived in two places after being corrected in
  a third; the station is not the eddy coil.
* ff-toolchange.cfg's restore_axis note had the hazard backwards -- parking
  zeroes X and Y, not Z -- and called `xy` the default when the option is unset.

54 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d05565284d Sections E, F, G: wrong values, stale claims, overclaims
E -- values and counts. The START_PRINT prologue has no M400 and an idle
timeout of 864000, not 1800000. The nozzle-clean temperature comes from the
material table FIRST, so NOZZLE=240 with no per-tool temp cleans at 220 --
documented backwards before. TOOLS is the paired 0:220,2:240 form. The wipe Z
is the raw eddy frame, not the homed one. The runout M117 strings are
"T<n> clog" / "T<n> out of filament", not E-codes. Grab-verify raises
E0051+tool, not E0143. Docks are at X~297; 250/280 are staging waypoints. The
grab sequence is documented in the order _grab actually emits it. ff_print
exposes seven status fields, not nine, and never emits TOOLS=. Package sizes
reconciled against the real artifacts (62MB built, ~93MB stock, ~370MB of CI
assets, ~60s not 90s). testing.md's sample output block matched no header the
code prints, so it is replaced with the real ones; its pytest row listed two
of six suites.

F -- stale claims. _FF_PREFLIGHT is not "missing". SDCARD_SET_CHANNEL is not
still issued (764af2a). The app and Moonraker do not coexist; the app is gone.
TOOLCHANGE_STATUS has nothing to compare against since the JSON reader was
removed. The JSON is re-imported every boot until a nozzle is saved, not once.
The 23-check render harness was deleted in 91604c2. PURGE now wipes by
default. 50-print-lifecycle.md gained the section on how the sequences are
actually entered now -- [ff_print], not the slicer. The dead "profiles"
concept is gone from three files, one of which printed it on every replica run.

G -- overclaims. The README no longer says a stock reflash puts the printer
back "exactly as it was": it says what is restored and what survives inert,
which is what test-recovery actually asserts. The random ssh password is
conditional on ROOT_PW_HASH being unset. verify.sh no longer warns "ssh will
not start" on every build for a dropbear we deliberately do not ship -- the
check now reads the way round it means. case-install.sh's read-only block says
it asserts nothing. An uncalibrated tool contributes z_adjust, not zero, in
three operator-facing places. offset_base does not cancel out in X/Y. The
safe-moves file's Z50 promise is scoped to its own lines, with the implicit
prepare's G1 Z10 spelled out.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:43 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 e0dad3513c Rewrite the host half of the harness in Python, so a broken gate fails
The shell suite decided "this gate did not run" by grepping the child's
stdout:

    if "$@" >"$out" 2>&1; then
        if grep -qE '^[[:space:]]*SKIP:|[0-9]+ skipped' "$out"
        then skip "$name"; else pass "$name"; fi

Text on stdout cannot tell you what happened inside a process. A launcher that
had ALREADY failed reached that same line by another road: the file that
sources config.env failed to load, so STOCK_TGZ_* was never set, so the
launcher concluded there was nothing to test, printed those five characters
and exited 0 -- on a machine with docker and the firmware sitting right there.
That is how five broken launchers shipped and stayed broken.

So the host half is Python and a skip is an exception. Output cannot imitate
one, and a gate that fails cannot claim it was skipped.

  test/ffsim/            config loading, docker plumbing, gate reporting
  test/run-tests.py      the orchestrator
  test/integration/      sim-install.py, sim-roundtrip.py, printer-exec.py,
                         extract-rootfs.py

test/integration/printer/*.sh and the whole payload stay shell. They are
executed by the printer's own busybox ash under qemu, and surviving that is a
large part of what the suite proves; rewriting them would test a Python the
printer does not have. build-printer-image.sh and make-stock-fixture.sh stay
too: both are `set -euo pipefail`, neither is a gate, and one is a release
tool run by hand.

Four things that used to report as skips are now failures -- a config.env that
does not parse, a missing case script, a missing package, a missing baseline.
A genuinely absent precondition is still a skip, because that is still true.

Three more bug classes are gone by construction rather than by being tested
for. The repo root is found by searching upward for bin/common.sh instead of
counting `..` from $0, so a launcher can move to any depth. Sourcing a config
with a syntax error is checked -- `.` returns non-zero and does NOT stop the
shell, so the old code carried on with half a file applied, which is exactly
the broken-looks-absent confusion again. And pytest counts come from the JUnit
XML rather than from grepping "N skipped".

test_script_roots.py becomes test_harness.py: the `..` check now has two
remaining shell targets rather than seven, and it gains a byte-compile of the
whole harness. That last one matters -- sim-install.py and friends are only
executed by `make test-install`, which needs docker and the firmware, so
without it a typo would wait quietly until release day. It also pins the
property that replaced the `..` counting, from four different depths.

VERIFIED, and the verification is the point, because this machine has no
docker daemon and these are exactly the scripts that cannot run here:

  - Stubbed the docker binary and diffed the recorded `docker run` argv,
    shell against Python, across five paths: install with a prebuilt image,
    install with a local build, local build plus PROG_DUMP, roundtrip, and
    printer-exec. IDENTICAL in all five.
  - Ran the old orchestrator and the new one in the same tree: both give
    6 passed, 1 failed, 2 skipped, exit 1, with the same gates in the same
    order. (The failure is shellcheck, which lives in the build image.)
  - pytest: 24 passed / 5 skipped bare, 29 passed against a real extracted
    rootfs.
  - Compared failure modes directly. The four cases above go from "exit 0,
    reported skipped" to "exit 1, named". Case 2 is the sharpest: with a
    missing case script the shell launcher exited 0 in silence, having cp'd
    nothing and mounted a directory over /case.sh.

Still unproven here: the gates that need a live daemon. `make test-install`,
`test-mcu` and `test-recovery` want one run on a docker-capable machine. The
argv diff says they will be handed the same command as before; it cannot say
what docker does with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:28:47 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 2ae9736e97 Name the two lanes unit and integration, and fix what the move broke
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>
2026-08-24 13:23:18 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8337359a68 Split test/ into the two lanes it already had
The suite has run in two halves for a long time, and the rest of the repo is
built around that: RUN vs RUNSIM in the Makefile, `test` vs `printer-sim` in
CI, two halves in run-tests.sh. Only test/ itself was flat, so which half a
file belonged to was something you learned by reading its header.

    test/repo/      needs nothing but this checkout
    test/replica/   needs the printer's real rootfs
    test/           run-tests.sh, and the fixtures both lanes share

The axis is the precondition, not the runner. test/replica is everything that
cannot run without the proprietary package -- the qemu'd sim launchers and the
replica image, but also test_paths.py, which only reads the extracted rootfs
as files and needs no docker at all. Grouping by "does it use docker" would
have put that one in the wrong place; grouping by what has to exist first puts
every file where the question "can this run on a public checkout?" is answered
by the directory name.

The `rootfs` pytest marker goes with it. It existed to let run-tests.sh pass
`-m 'not rootfs'` in the PR lane and `-m rootfs` in the replica lane; the
directories say the same thing without anyone having to remember to tag a new
test, and a file in the wrong lane now shows up in `git status`. The two
lanes are `pytest ./test/repo` and `pytest ./test/replica`.

pytest.ini is new and is load-bearing: without a pinned rootdir, naming a lane
on the command line makes pytest take that lane as the root and never load
test/conftest.py, so the fixtures both lanes share would vanish depending on
which one you asked for.

Renames only, plus the path references: test/fixtures/make-stock-fixture.sh ->
test/repo/, the sim launchers and test/printer/ -> test/replica/, and
test_applets.py -> test/replica/test_paths.py, which is what it has actually
checked since the command-word half was dropped.

Verified: 19 in test/repo and 7 in test/replica against a real extracted
rootfs, the same 26 as before the split; run-tests.sh reports 7 passed /
1 failed / 1 skipped, identical to the pre-split run on this host (shellcheck
is absent here and lives in the build image, and no stock package is
configured in this worktree); every referenced path in the repo resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:13:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 91604c2dfb Cut the suite to four gates, and rewrite the Python half as pytest
Twelve gates, most of them the weaker copy of a gate that survives. What is
left: pytest for the Klipper config, and three replica gates -- the end-to-end
install, MCU bring-up, and stock recovery.

Fix first, because the cut depends on it: a skip counted as a pass. Every
launcher exits 0 when it decides it cannot run, and sub() reported that as
`ok`. That is how test-abi.sh sat in the suite printing green while checking
nothing -- run-tests.sh deleted work/modpayload immediately before it ran and
CI set KLIPPER_FORK="", so it had no targets on any run, ever. Skips are now
counted apart, the summary says so, and the suite exits non-zero unless
ALLOW_SKIP=1 says the gap is deliberate. With four gates instead of twelve, a
gate that silently does not run is the failure mode that matters most.

Dropped, each because something else already does the job properly:

  test-abi.sh              bin/patch.sh refuses to build a non-nan2008
                           c_helper.so, bin/verify.sh re-checks the packaged
                           one, and case-install.sh reads the ELF header of
                           the file that actually landed on the machine.
  test-ash-conformance.sh  case-install.sh runs `sh -n` over every installed
                           script with the same qemu'd busybox and then greps
                           the boot log for "Syntax error".
  test-model-gate.sh       verify.sh SS8b/SS9 checks the same invariant and
                           pack.sh exits on MODEL MISMATCH before a package
                           exists. Its header claimed it proved the two models
                           ship different files; no such check was in it.
  test-macros.py           It used jinja2.Environment() -- default '{{ }}'.
                           The configs have no '{{' at all, only single-brace
                           expressions, so it checked '{% %}' block structure
                           and never looked inside an expression. Folded into
                           the chamber tests, which had the delimiters right.
  test-base-cfg.py         It read work/software/klipper/config/printer.base.cfg
                           as its stock reference -- the file bin/patch.sh
                           overwrites with ours. It was diffing our file
                           against itself: green on a cold tree, red on a
                           second run, blind to real drift either way. The
                           comparison moves to bin/unpack.sh, where a pristine
                           tree exists, as a warning rather than a gate.
  lint-danger.sh           Its structural greps were satisfiable by comments:
                           firmwareExe:44 and S80ui:16 *mention* helix and
                           SAFE-MODE, so they passed with the logic deleted.
  case-ui.sh               Goes with the helix-only decision.
  applets.allow            Ten entries, none of which the extractor had asked
                           about in a long time.

The command-word half of test-applets went too. It extracted only the first
word of a simple unprefixed command, so `if timeout 5 foo; then` yielded
['if','echo','fi'] -- the one failure its docstring named was invisible to it.
The absolute-path half survives: S50wifi's binaries sit behind a
[ -d /sys/class/net/wlan0 ] guard no simulation ever reaches, so nothing else
checks they exist.

The Python half is pytest now. Real assertions instead of hand-rolled
PASS/FAIL counters, underscored filenames so the modules import normally
(ffcfg keeps only the parser; the dash in test-macros.py was why one test used
to reach the other's parser through importlib), and a `rootfs` marker that
splits the tests needing the proprietary package from those that do not --
deselected in the PR lane, run in the replica half, so neither lane reports
skips it did not expect. test_default_delimiters_are_blind pins the jinja2 bug
so it cannot come back.

The config gate also moves out of the replica half into the half that runs on
a plain pull request. It needs python3, jinja2 and files in the repo, and it
is the only safety-relevant check in the suite -- a chamber heater declared on
a Creator 5 that has no element for it -- so it had no business being gated
behind the proprietary firmware.

Verified: pytest 21 passed / 5 rootfs-marked; the fixture pipeline
(unpack, patch, pack, verify) passes end to end; injecting a broken
single-brace expression fails test_every_macro_parses with the exact line, and
parses clean under the old defaults; the skip path reports SKIP and exits 1,
and ALLOW_SKIP=1 accepts it.

Also drops the CI `abi` job, which ran on a bare checkout with no binaries and
so was permanently green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:00:12 +03:00
Oleksandr Shyshatskyi 12bbd1e7dc Retire the probe profile: there is one package now
The probe profile was a bring-up aid. It set BUILD_APPLY=0, reinstalled
the stock component byte-for-byte and wrote a report to the stick, so you
could prove the update chain worked on your own machine -- the filename
glob, the DES3 key, the md5 gate, run.sh execution -- before flashing
anything that changed a file. That rehearsal has served its purpose.

With it gone there was exactly one profile left, so the whole PROFILE
indirection goes too: profiles/ is deleted and its six BUILD_* flags are
plain defaults in bin/common.sh, which also means config.env now
overrides them instead of being silently overridden. make default folds
into make build; all-profiles and RECOVERY_PROFILE are gone with it.

What is NOT gone is the undo button: flashing the stock FlashForge
package still restores every file the mod touches, and case-recovery.sh
still proves it. What is gone is the dry run.

Note the loss: PROG_MB/DATA_MB used to come out of the probe report.
The docs now point at df -h over ssh, which you only have after a
successful flash.
2026-08-23 22:15:26 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5bdf78109a Retire firmwareExe: own the web stack, and say what goes with it
The mod replaces FlashForge's application outright, so the reasoning that
still framed the web stack as coexisting with it was wrong on its own terms.

* nginx and moonraker are documented as the only web stack on the printer.
  MOD_WEB=0 is a debugging switch that leaves ssh and the touchscreen, not a
  way back to stock -- that is a stock FlashForge package. The own-prefix rule
  stays, for the reason that is actually true: /usr/prog is the firmware
  partition and every FlashForge package reinstalls it, so a config written
  there is undone by the next stock flash.

* Configs the mod ships now update when you have not edited them. A live file
  still byte-identical to the one the last package wrote is replaced; one you
  changed is kept and the new version lands beside it as .mod-new.
  $MODDIR/config-installed holds that last-written copy. Without this a config
  we own could only ever be written once: it exists on the second flash, so it
  would land as .mod-new forever and updates would never reach the printer.

* Dropped the three dead /server/files/ alias blocks from nginx.conf. The regex
  location above them matches /server/ too and nginx gives regex locations
  precedence, so they never fired -- and had they fired, serving those paths
  from disk would 405 the DELETE and POST that Mainsail sends to the very same
  URLs.

* Recorded what dies with firmwareExe, in docs/notes/25 and in the README: the
  :8898 REST API (FlashPrint, Orca's FlashForge profile, the mobile app) and
  the :8080 camera stream. The camera is the one that looks restorable and is
  not -- the MJPEG server is compiled into firmwareExe (its own accept loop,
  `GET /?action=stream`, boundarydonotcross) rather than being mjpg-streamer,
  so there is no daemon left to point a [webcam] at. assets/moonraker.conf
  carries the block, commented, for when a streamer is built for this board.

Also in this change: the MOD_UI switch and the stock-UI path are gone, the
chamber-heater model gate arrives with ff-model.cfg/ff-chamber.cfg, and the
tests follow.

make test: 32 passed, 1 failed. The failure is the probe end-to-end, which
dies at "mount: failed to setup loop device for /stick.img" -- leaked loop
devices on this build host, not the package. Probe installs nothing, and the
default profile's install -> re-install -> boot passes in the same run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:50:32 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 22d16f984e Run the printer's own boot script for the update test, and cut the suite to 5m
The install test used to re-implement app_startup.sh: it pulled the glob,
MACHINE and PID out of it with sed, then called the installer itself. A
mistake in our reading of the boot script could never be caught that way.

The package now sits on a real FAT filesystem on a loop device exposed as
/dev/sda1, and app_startup.sh runs verbatim through three boots -- stick in,
stick still in, stick pulled. The last one asserts what actually matters:
nginx and moonraker up, klipper launched, the UI in the foreground where
app_startup.sh's own 5-second ps watchdog can see it.

One substitution, declared in .SIMULATED: the printer's kernel has nls_cp936
and a container kernel does not, so /bin/mount retries without codepage= if
the real options are rejected.

Speed. The image carried the partitions but not the stock install, and
test-env.sh sourced the config files after the environment -- so PRINTER_IMAGE
on the command line was overwritten by the empty value in test.env and the
published image was never used. Measured, per replica case: 22s to unpack the
factory image, 37s to install the stock package, 0.7s with the image. The
stock install is now baked in at image build time (bake.sh -- it needs binfmt
and chroot, so it runs privileged and the result is committed) and recorded in
/usr/prog/.BASELINE; a run asking for a different package reinstalls.

make test: ~20m -> 5m26s. Every section header now carries elapsed seconds.

Dropped four checks that could not fail:

  * test-abi's execution half was `qemu "$f" -h || [ $? -lt 126 ]`, which
    accepts nearly every exit status
  * test-ash's md5sum -s probe printed "inconclusive" on every path
  * run-tests.sh's per-file `sh -n` "POSIX" loop ran bash, and its bashism
    grep flagged `local`, which busybox ash supports
  * CI's shellcheck ended in `|| true`

And one that mattered more: the release workflow's final sim-install loop
ignored every exit status, and sim-install.sh exits 0 on a skip -- so the last
gate before publishing could not fail. It now stops on the first failure and
runs with REQUIRE_PRINTER_SIM=1, which turns skips into failures throughout.

Also: the profile lists in run-tests.sh, RECOVERY_PROFILE and release.yml
still said ssh/web/full/helix and would have failed the release job; work/.sim
was shared, so two suites at once truncated each other's packages; and the
fixture half left a 380KB package in work/out where make test-install picks it
up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 16:36:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a5300bde12 Split the repo into ship / build / test lanes, and add the printer replica
Two strands, both landing here because they had grown into each other in the
working tree.

The printer replica: the printer's real rootfs.squashfs chrooted under
qemu-mipsel, with /usr/prog and /usr/data from the factory image, so a package
is installed by FlashForge's own updater running on the printer's own busybox,
tar, md5sum and unTar. There are no stubs -- if the real prog partition is
missing the harness refuses to run rather than test against mocks. A release
workflow is blocked on it.

The lanes: a file's directory now says whether it can ever reach a printer.

  payload/, assets/   ship
  bin/, profiles/     build the package
  test/, docs/        neither, ever

  * the replica's own infrastructure moved out of bin/ and docker/ and in
    beside the harness it belongs to (test/extract-rootfs.sh,
    test/build-printer-image.sh, test/printer/Dockerfile{,.full})
  * the Makefile has two runners: only RUNSIM gets the docker socket and the
    replica's variables, so a build cannot reach the docker daemon at all
  * replica settings moved to test.env, which nothing in a package reads
  * build-time flags renamed BUILD_* (APPLY, REPORT, KLIPPER, TOOLCHANGE,
    MAINSAIL, HELIX). MOD_* now means only the runtime switches written into
    anvil.conf and re-read by the printer at every boot
  * verify.sh fails if any file in a package is byte-identical to one in
    bin/, test/, docker/ or profiles/ -- by content, because a name blacklist
    would trip over whatever Mainsail happens to call common.sh

Fixed while mapping it:

  * BUILD_REPORT was read only inside the BUILD_APPLY=0 branch, so four
    profiles declared it and got nothing. It is honoured on both paths now,
    and off outside probe: report.sh copies /etc, passwd and shadow onto the
    USB stick, which is a diagnostic, not something to hand out.
  * three checks in the brick lint had been quietly dead. Two were guarded on
    a payload/boot.sh replaced by the firmwareExe wrapper long ago; the third
    was the file glob itself, which matched neither firmwareExe nor the
    init.d/S* scripts -- the UI wrapper and every service went unscanned. A
    missing file is now a failure rather than a silent pass.
  * run-tests.sh overwrote config.env and copied it back, leaving the config
    you edited one crashed run away from being a fixture. It uses CONFIG_ENV.
  * bin/make-uninstall.sh does not exist and never did; README, run-pre.sh and
    full.env all pointed at it. Recovery is the stock package, which is what
    test-recovery actually proves.

README is now for someone who wants to flash their printer: what it is, which
file, how to flash, what to do when it goes wrong. The rest moved to
docs/how-it-works.md, docs/building.md and docs/testing.md.

make test: 89 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:30:09 +03:00