Files
MonstrofilandClaude Opus 5 f095075985 Put FlashForge's chelper back where our Klipper replaced it (#22)
chelper/__init__.py and c_helper.so are one unit: the first declares the cffi
cdefs, the second is what they describe. FlashForge's 1.9.7 declares

    void extruder_set_pressure_advance(struct stepper_kinematics *sk
        , double pressure_advance, double smooth_time);

and ours takes a fourth argument. A stock flash restores c_helper.so -- it is
in the chelper.tar their software component carries -- and restores nothing
else in that directory. Their klippy.py, toolhead.py and mcu.py come back too;
gcode.py, bed_mesh.py and chelper/__init__.py do not, because their overlay is
thirty files against the three hundred and twelve in the factory tree.

So a printer that took v20260827 or v20260827b, whose software component
carried our klippy, comes back from a stock flash with our cdefs against their
library and their extruder.py calling it their way: "expects 4 arguments, got
3", at the first extruder, on a machine that has already been flashed back to
stock and has nothing left to flash.

$MODDIR/prog/stock-chelper is FlashForge's directory, off a 1.9.7 factory
/usr/prog, and anvil-link-prog.sh restores it over ours. The whole directory
rather than the two files that disagree: their __init__.py is upstream's,
check_build_code and all, so a .c newer than c_helper.so sends klippy to
mips-linux-gnu-gcc, which no printer has. c_helper.so is touched last for the
same reason.

ONLY UP TO 1.9.7. That is the firmware this copy came off; a newer FlashForge
release may pair a different __init__.py with a different library, and
overwriting it would be this bug pointed the other way. An unreadable version
is left alone too. The version is theirs to state -- no package of ours ships
a software component, so /usr/prog/PROGRAM/software still names FlashForge's.

It rides in anvil-klipper rather than anvil-core, which qa/static caught: a
MIPS c_helper.so in a package declaring Architecture: all installs on anything.
That recipe gets a postinst of its own for the same reason anvil-klipper-config
has one -- anvil-core's pass runs before this package's payload has landed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:07:25 +03:00

593 lines
28 KiB
Python

"""What the machine's own installer produced, asked of the machine it produced.
The port of `case-install.sh` (579 lines, one bit) -- the gate that decides
whether a package bricks a printer.
WHY THIS MODULE LOOKS SO DIFFERENT FROM THE CASE SCRIPT. Most of that file was
DRIVING the install: attach the stick, run app_startup.sh, wait for it to
settle, do it twice more. None of that is here, because the `printer` fixture
already IS the result -- the package installed by `/usr/prog/app_startup.sh`,
verbatim, off a genuine FAT filesystem on `/dev/sda1`, exactly as a user
installs it from a USB stick (see qa/replica/conftest.py). What is left is the
part that was always the point: the assertions.
That is also why nothing here replays the install by hand. The case script's
own header records that lesson being learned once already:
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.
THREE OF THE CASE'S ASSERTIONS WERE STALE AND ARE NOT REPRODUCED. They are why
the gate was red, and each is replaced by the question that means the same
thing today:
* `grep helix firmwareExe` -- the wrapper used to exec HelixScreen itself.
It does not: it starts s6 and the UI is the `ui` service. Replaced by
test_the_wrapper_starts_the_supervisor and
test_the_ui_is_a_service_in_the_compiled_database.
* `[ -d $MODDIR/init.d ]`, else "no service dir exists to start Klipper" --
there is no init.d. Replaced by test_klipper_is_a_service_in_the_database.
* grepping the boot log for `S60nginx S62moonraker S70klipper S80ui`.
NOT replaced by grepping it for the new names. That 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 would pass on a machine where klipper never started -- a
green assertion that cannot go red. The database is asked instead, which
is a fact about what was installed rather than an intention.
WHAT A REPLICA CANNOT ANSWER, so it is not asked here:
* boot 2, the re-install: the bake deletes `/stick.img` once the install has
landed, so there is no stick to install from a second time. The property
that matters -- an update keeps what the user edited and drops only what
the last package shipped -- is asserted directly in test_upgrade.py, which
drives runFirmwareExe.sh over two payloads.
* boot 3's UI liveness: `ok-all` is unreachable on a replica (no
/dev/ttyS4,5,7, no /dev/video*), so "the UI is running and the stock
watchdog is satisfied" cannot be asked. The static half of it -- that the
wrapper holds the foreground and keeps its name -- is in test_s6rc.py.
"""
import re
import pytest
pytestmark = pytest.mark.replica
MODDIR = "/usr/data/anvil"
# FlashForge's config directory. The mod seeds its own from this one once and
# then leaves it alone, so that a printer flashed back to stock still has a
# config graph that resolves.
STOCK_CONFIG = "/usr/data/config"
FE = "/usr/prog/PROGRAM/software/firmwareExe"
# The other two stock paths anvil-link-prog.sh owns.
START = "/usr/prog/klipper/start.sh"
DAEMON = "/usr/prog/klipper/klipperDaemon"
# FlashForge's own, off a factory /usr/prog. anvil-core ships this copy at
# $MODDIR/prog/stock-klipperDaemon and restores it; a machine holding
# anything else at that path is running a script no printer shipped with.
STOCK_DAEMON_MD5 = "773741f6a2df3231cd52a3f441014037"
APP = "/usr/prog/app_startup.sh"
SOURCE = MODDIR + "/etc/s6-rc/source"
DB = MODDIR + "/etc/s6-rc/compiled/current"
# The klipper s6-rc service execs $FF_PYTHON against $MODDIR/klipper/klippy.
# /usr/prog/klipper/klippy still holds FlashForge's stock tree; nothing reads it.
KLIPPY_DIR = MODDIR + "/klipper/klippy"
INSTALL_LOG = "/usr/data/anvil-install.log"
@pytest.fixture(scope="module")
def config_dir(box):
"""Where THIS machine's klippy reads its config, read off the machine.
NOT a constant, and that is the point of the fixture. The directory moved
-- FlashForge's /usr/data/config was replaced by one the mod owns -- and
this file asks what the installed package produced, whichever side of that
move it was built on. A constant here would make every question below a
question about which package happens to be in work/out.
The answer comes from the one place that decides it: PRINTER_CFG in the
klipper s6-rc service, which is the command line klippy is exec'd with.
"""
run = box.file(SOURCE + "/klipper/run")
assert run.exists, (
"no klipper/run in %s -- the service that starts klippy is not "
"installed, so nothing here can be answered" % SOURCE)
found = re.search(r"^PRINTER_CFG=(\S+)", run.text, re.M)
assert found, (
"klipper/run names no PRINTER_CFG, so there is no way to tell which "
"config directory this machine reads:\n%s" % run.text)
printer_cfg = found.group(1)
assert printer_cfg.endswith("/printer.cfg"), (
"klipper/run starts klippy on %r, which is not a printer.cfg"
% printer_cfg)
return printer_cfg.rsplit("/", 1)[0]
@pytest.fixture(scope="module")
def box(printer):
"""The installed machine, checked once for the one thing that would make
every assertion below meaningless."""
if not printer.file(MODDIR).is_dir:
pytest.fail(
"there is no %s, so the install did not land. The `printer` "
"fixture bakes an image by running the machine's own "
"app_startup.sh over a package from work/out -- `make build` "
"first." % MODDIR)
return printer
# ------------------------------------------------- the stock boot chain is stock
def test_app_startup_is_unmodified(box):
"""Replacing firmwareExe owns the whole userspace boot, which is what lets
app_startup.sh, rcS and the init chain stay stock and never be patched. A
mod marker in here means that claim has quietly stopped being true."""
text = box.file(APP).text
assert text, "no %s -- there is no stock baseline on this machine" % APP
for marker in ("anvil", "MODDIR", "/usr/data/anvil"):
assert marker not in text, (
"%s carries the mod marker %r -- the boot scripts must stay stock"
% (APP, marker))
def test_app_startup_still_parses(box):
"""Under the printer's own busybox ash, not the host's shell."""
parsed = box.sh("sh -n %s 2>&1" % APP)
assert parsed.ok, "BRICK: %s has a syntax error: %s" % (APP, parsed.text)
def test_app_startup_still_loads_the_kernel_modules(box):
"""The insmod lines are what bring up wifi and the touch panel. An
installer that trimmed them would leave a printer with no screen."""
assert "insmod" in box.file(APP).text, (
"BRICK: no insmod lines left in %s" % APP)
# ------------------------------------------------------------- the wrapper
def test_the_wrapper_is_installed(box):
"""A shebang, not an ELF. The stock binary still sitting here means the
install did not take -- and that must fail rather than be tolerated,
because tolerating it turns "the install produced nothing" into a pass."""
head = box.sh("head -c 2 %s" % FE).out
assert head == "#!", (
"BRICK: %s is not the mod wrapper (starts %r) -- the install did not "
"replace it" % (FE, head))
def test_the_wrapper_is_executable(box):
assert box.file(FE).executable, "BRICK: %s is not executable" % FE
def test_the_wrapper_parses(box):
parsed = box.sh("sh -n %s 2>&1" % FE)
assert parsed.ok, "BRICK: wrapper syntax error: %s" % parsed.text
def test_the_wrapper_starts_the_supervisor(box):
"""Replaces the case script's `grep helix`. The wrapper no longer execs a
UI: it starts s6-svscan and brings the boot set up, and the UI is one
service in that set. Asking for HelixScreen by name here would be asking
about an architecture that has been gone since phase 8."""
text = box.file(FE).text
assert "s6-svscan" in text, (
"BRICK: the wrapper starts no supervisor -- nothing would bring the "
"printer up")
assert "s6-rc" in text, (
"BRICK: the wrapper never asks s6-rc for a transition, so no service "
"would start")
def test_the_stock_paths_are_symlinks_into_the_payload(box):
"""The property the whole seam rests on since the software component
stopped carrying firmwareExe and start.sh.
anvil-core installs all three at $MODDIR/prog/ and anvil-link-prog.sh points
the stock paths at them -- from runFirmwareExe.sh on a flash and from the
post-install/post-upgrade script on `apk upgrade`. A REGULAR FILE at any of these means
the link step did not run, and the printer would go on executing whatever
the last install happened to leave there while an upgrade quietly rewrote
$MODDIR.
The two paths the mod owns. FlashForge's klipperDaemon is not one of
them and is asserted separately below.
"""
for path in (FE, START):
target = box.sh("readlink %s 2>/dev/null" % path).out.strip()
assert target, (
"%s is not a symlink -- anvil-link-prog.sh did not run, so an "
"`apk upgrade anvil-core` would not change what this printer "
"executes" % path)
assert target.startswith(MODDIR + "/prog/"), (
"%s -> %r, which is not under %s/prog -- anvil-core is not what "
"this printer runs" % (path, target, MODDIR))
assert box.file(path).exists, (
"%s -> %r dangles: the link is there but the payload is not"
% (path, target))
def test_klipperdaemon_is_still_flashforges_own(box):
"""The mod does not touch it, and that is what makes a stock flash work.
WHAT IT GUARDS. A stock package restores firmwareExe and start.sh,
because its run.sh copies its own over them. It has no klipperDaemon to
copy -- not in the software component, not in its md5sum.list, no line in
run.sh -- so whatever sits at that path survives every stock flash, and
stock's start.sh calls it on each boot. A link into $MODDIR there is a
printer that comes back from a downgrade with a working screen and no
Klipper, permanently.
Not run, only inspected: executing FlashForge's `start` here would fork an
unsupervised klippy beside the s6-supervised one and leave it running for
every test after this. What matters is whose file it is.
"""
link = box.sh("readlink %s 2>/dev/null" % DAEMON).out.strip()
assert not link.startswith(MODDIR), (
"%s points into %s (%r). That link survives a stock flash and stock's "
"start.sh then starts no Klipper at all -- the printer comes back with "
"a UI and nothing behind it." % (DAEMON, MODDIR, link))
daemon = box.file(DAEMON)
assert daemon.exists, (
"%s is gone. Stock's start.sh calls it, so a printer flashed back to "
"stock would have no way to start Klipper." % DAEMON)
assert "start-stop-daemon" in daemon.text, (
"%s is not FlashForge's own script -- it has no start-stop-daemon "
"line: %r" % (DAEMON, daemon.text[:400]))
def test_a_link_left_at_that_path_is_replaced_with_flashforges_own(box):
"""An upgrade puts FlashForge's file back, so the machine can go to stock.
A printer that took a release which owned that path carries a symlink into
$MODDIR, and nothing in a stock package can undo it -- their package has no
klipperDaemon to copy over it, and the copy this machine shipped with is
gone. $MODDIR/prog/stock-klipperDaemon is the only copy left, and
anvil-link-prog.sh restores it on every install and every `apk upgrade`.
The link is planted here rather than waited for, because the machine this
runs on installed a release that never made one. What is under test is the
script in the payload, run the way an upgrade runs it.
"""
link = "%s/prog/klipperDaemon" % MODDIR # ships nowhere; dangles
planted = box.sh("rm -f %s && ln -s %s %s && readlink %s"
% (DAEMON, link, DAEMON, DAEMON))
assert planted.ok, "could not plant the link: %s" % planted.text
run = box.sh("sh %s/bin/anvil-link-prog.sh" % MODDIR, timeout=300)
assert run.ok, "anvil-link-prog.sh failed: %s" % run.text
kind = box.sh("if [ -L %s ]; then echo link; elif [ -f %s ]; then echo file; "
"else echo missing; fi" % (DAEMON, DAEMON)).out.strip()
assert kind == "file", (
"%s is %s after the upgrade, so this printer still cannot be returned "
"to stock. What the script said:\n%s" % (DAEMON, kind, run.text))
got = box.sh("md5sum %s" % DAEMON).out.split()[0]
assert got == STOCK_DAEMON_MD5, (
"%s was replaced with something that is not FlashForge's script: md5 "
"%s, expected %s" % (DAEMON, got, STOCK_DAEMON_MD5))
def test_our_chelper_is_replaced_with_flashforges(box):
"""chelper/__init__.py and c_helper.so are one unit, and a stock flash
restores only the second.
Their `extruder_set_pressure_advance` takes three arguments and ours takes
four, so a printer left with our cdefs and their library fails at the first
extruder -- after it has been flashed back to stock, with nothing left to
flash. Their __init__.py is also upstream's, `check_build_code` and all: a
.c newer than c_helper.so sends klippy to a compiler the printer does not
have, which is why the whole directory goes back rather than two files.
Planted here, because the machine this runs on took a release that ships
no software component and so never had ours.
"""
live = "/usr/prog/klipper/klippy/chelper"
ours = "%s/klipper/klippy/chelper" % MODDIR
shipped = "%s/prog/stock-chelper" % MODDIR
planted = box.sh("rm -rf %s && cp -a %s %s && ls %s/*.c >/dev/null"
% (live, ours, live, live))
assert planted.ok, "could not plant our chelper: %s" % planted.text
before = box.sh("md5sum < %s/__init__.py" % live).out.strip()
theirs = box.sh("md5sum < %s/__init__.py" % shipped).out.strip()
assert before != theirs, (
"the planted chelper is already FlashForge's, so this test would pass "
"without the restore doing anything")
run = box.sh("sh %s/bin/anvil-link-prog.sh" % MODDIR, timeout=300)
assert run.ok, "anvil-link-prog.sh failed: %s" % run.text
after = box.sh("md5sum < %s/__init__.py" % live).out.strip()
assert after == theirs, (
"%s/__init__.py is still not FlashForge's, so their c_helper.so would "
"be called with our cdefs. What the script said:\n%s" % (live, run.text))
# c_helper.so must be the newest file in there, or their own __init__.py
# decides the library is stale and tries to build one.
newer = box.sh("find %s -newer %s/c_helper.so -type f" % (live, live)).out.split()
assert not newer, (
"these files are newer than c_helper.so, so klippy would shell out to "
"mips-linux-gnu-gcc on a printer with no compiler: %s" % newer)
def test_a_newer_flashforge_chelper_is_left_alone(box):
"""The copy shipped here is 1.9.7's. A printer on something newer keeps
what it has -- overwriting it would be this same bug, pointed the other
way."""
live = "/usr/prog/klipper/klippy/chelper"
ours = "%s/klipper/klippy/chelper" % MODDIR
software = "/usr/prog/PROGRAM/software"
box.sh("rm -rf %s && cp -a %s %s" % (live, ours, live))
marker = box.sh("md5sum < %s/__init__.py" % live).out.strip()
planted = box.sh("mkdir -p %s/9.9.9" % software)
assert planted.ok, "could not plant a newer version directory"
try:
run = box.sh("sh %s/bin/anvil-link-prog.sh" % MODDIR, timeout=300)
assert run.ok, "anvil-link-prog.sh failed: %s" % run.text
after = box.sh("md5sum < %s/__init__.py" % live).out.strip()
assert after == marker, (
"the restore ran on a printer reporting FlashForge 9.9.9 and "
"replaced files newer than the copy this package carries:\n%s"
% run.text)
assert "9.9.9" in run.text, (
"it left the directory alone but said nothing about why, so an "
"owner reading the log cannot tell this from a no-op:\n%s" % run.text)
finally:
box.sh("rm -rf %s/9.9.9" % software)
# Leave the machine as the rest of this module expects it.
box.sh("sh %s/bin/anvil-link-prog.sh >/dev/null 2>&1" % MODDIR)
def test_every_installed_script_parses_under_the_printers_busybox(box):
"""Every shell script the mod put on the machine, with the shell that will
actually run it. A bashism here is a service that dies at boot."""
listed = box.sh(
"find %s -name '*.sh' -type f 2>/dev/null; "
"find %s -type f 2>/dev/null" % (MODDIR, SOURCE))
scripts = [s for s in listed.out.split() if s]
assert scripts, "found no installed scripts to parse -- the find is wrong"
broken = box.sh(
"for f in %s; do head -c 2 \"$f\" | grep -q '#!' || continue; "
"sh -n \"$f\" 2>&1 >/dev/null || echo \"BAD $f\"; done"
% " ".join(scripts))
assert "BAD" not in broken.text, (
"syntax errors under the printer's ash:\n%s" % broken.text)
# ------------------------------------------------ what actually starts klipper
def test_klipper_is_a_service_in_the_database(box):
"""Replaces `[ -d $MODDIR/init.d ]`. Asked of the COMPILED database rather
than of the source tree, because s6-rc-compile is free to reject or drop
what it was given -- a `down` file in a definition directory is accepted
and discarded, which is how this bit us once."""
listed = box.sh("%s/bin/s6-rc-db -c %s list services 2>&1" % (MODDIR, DB))
assert listed.ok, "could not read the compiled database: %s" % listed.text
assert "klipper" in listed.out.split(), (
"nothing starts Klipper -- the UI would boot with no motion and no "
"heaters. Services in the database: %s" % listed.out.split())
def test_the_ui_is_a_service_in_the_compiled_database(box):
"""The other half of the retired `grep helix`: the UI still gets started by
something, it is just the `ui` service now rather than a line in the
wrapper."""
listed = box.sh("%s/bin/s6-rc-db -c %s list services 2>&1" % (MODDIR, DB))
assert "ui" in listed.out.split(), (
"BRICK: no `ui` service -- the printer would boot with no interface. "
"Services: %s" % listed.out.split())
def test_klipper_depends_on_the_mcu_bringup(box):
"""The ordering the whole s6-rc graph exists for: the boards must be out of
their bootloaders before klippy opens the ports, on EVERY start, which is
what having it as a dependency buys over calling it once at boot."""
deps = box.sh("%s/bin/s6-rc-db -c %s dependencies klipper 2>&1"
% (MODDIR, DB))
assert deps.ok, "could not read klipper's dependencies: %s" % deps.text
assert "mcu-bringup" in deps.out.split(), (
"klipper does not depend on mcu-bringup, so klippy could open the "
"ports before the boards are handed over. Dependencies: %s"
% deps.out.split())
# --------------------------------------------------------------- klippy itself
def test_klippy_is_present(box):
"""In the payload, which is the only copy: anvil-klipper installs it and
the s6 service execs it there. There is no component copy to fall back
on any more, so this failing means the printer has no Klipper at all."""
assert box.file(KLIPPY_DIR + "/klippy.py").exists, (
"%s/klippy.py missing -- anvil-klipper did not install and there is "
"nothing to start" % KLIPPY_DIR)
# THE CHELPER'S ABI IS NOT ASKED HERE ANY MORE. Two tests used to read its
# ELF header a byte at a time through `xxd` -- 32-bit, little-endian, MIPS,
# then e_flags for nan2008 -- for one file out of the several hundred ELF
# objects an install puts on this machine. qa/replica/test_abi.py sweeps all
# of them on the same fixture and names c_helper.so explicitly, so what was
# here is a strict subset of what runs there.
# ------------------------------------------------------- the mod's own config
def test_the_config_include_set_is_wired_up(box, config_dir):
"""printer.base.cfg must include every ff-*.cfg the package shipped, and
each one must be there. A shipped-but-unincluded file is a feature that
silently does nothing."""
base = box.file(config_dir + "/printer.base.cfg")
assert base.exists, "no printer.base.cfg -- the mod's config is not wired up"
included = set(re.findall(r"\[include\s+(ff-[\w.-]+\.cfg)\]", base.text))
assert included, "printer.base.cfg includes no ff-*.cfg at all"
shipped = set(box.sh("ls %s/config/ff-*.cfg 2>/dev/null" % MODDIR).out.split())
shipped = {p.rsplit("/", 1)[-1] for p in shipped}
assert shipped, "the package shipped no ff-*.cfg"
assert shipped <= included, (
"shipped but never included: %s" % sorted(shipped - included))
missing = [name for name in included
if not box.file(config_dir + "/" + name).exists]
assert not missing, "included but not installed: %s" % missing
def test_every_include_resolves_not_just_ours(box, config_dir):
"""The other four.
The test above deliberately reads only `ff-*.cfg`, which is the mod's own
set -- and for a long time that was the whole check, so the FOUR
FlashForge includes at the top of printer.base.cfg (printer.filament.cfg,
printer.probe.cfg, printer.mesh.cfg, printer.vibration.cfg) were never
asked about at all. They are not ours and never will be: they arrive from
the stock firmware and we merely include them.
That is exactly why they need a gate. Klipper treats an [include] that
matches no file as FATAL, so a missing one is not a degraded printer, it is
a printer that does not start -- and it would be missing for a reason
nothing else here would notice, because no package of ours ships it and no
recipe of ours could.
printer.vibration.cfg is the one with teeth: it declares
[stepper_resonance_tester], which is why anvil-klipper depends on
anvil-python-numpy at all. See qa/replica/test_klippy_extras_import.py and
docs/notes/44-vfa-calibration.md.
"""
base = box.file(config_dir + "/printer.base.cfg")
assert base.exists, "no printer.base.cfg -- the mod's config is not wired up"
included = set(re.findall(r"\[include\s+([^\]]+)\]", base.text))
included = {name.strip() for name in included}
assert len(included) > 4, (
"printer.base.cfg declares only %d includes (%s) -- that is fewer than "
"the four FlashForge ones alone, so this file is not the one the "
"printer reads" % (len(included), ", ".join(sorted(included))))
# `ls` rather than File.exists, because Klipper accepts a glob here and one
# of ours is a directory of per-model chamber configs.
missing = []
for name in sorted(included):
listing = box.sh("ls -1 %s/%s 2>/dev/null" % (config_dir, name))
if not listing.out.strip():
missing.append(name)
assert not missing, (
"printer.base.cfg includes %s, and no such file is in "
"%s. Klipper treats a missing include as fatal, so this "
"printer would not start. If these are FlashForge's own configs, the "
"replica's /usr/data was seeded without them -- see "
"tools/replica/printer/seed-prog.sh." % (missing, config_dir))
def test_the_user_printer_cfg_was_not_clobbered(box, config_dir):
"""The one file on the machine that is the owner's, not ours.
Both copies of it: the one klippy now reads, seeded from the machine's own
at install time, and FlashForge's, which the install must leave where it
found it.
"""
cfg = box.file(config_dir + "/printer.cfg")
assert cfg.exists, "no printer.cfg in %s -- the install seeded nothing, so "\
"klippy has no config to start from" % config_dir
stock = box.file(STOCK_CONFIG + "/printer.cfg")
assert stock.exists, "user printer.cfg was clobbered by the install"
# ------------------------------------------------------------ the install log
def test_the_installer_said_it_installed(box):
"""The anti-vacuity guard. Every assertion above describes a machine the
installer built; if the installer never ran, they would be describing
something else entirely and several would still pass."""
log = box.file(INSTALL_LOG)
assert log.exists, (
"no %s -- runFirmwareExe.sh never ran, so nothing here is asserting "
"against an install" % INSTALL_LOG)
assert "mod payload installed" in log.text, (
"the install log does not say the payload was installed:\n%s"
% log.text[-1500:])
def test_the_installer_left_no_payload_in_the_scratch_directory(box):
"""/usr/data/update is where app_startup.sh unpacks the package, and where
runFirmwareExe.sh finds anvil.tar.xz beside itself. MEASURED: the directory
itself survives the
install, empty, so its mere existence is not the question the case script
thought it was asking. What must not survive is its CONTENTS: an ~80MB
half-unpacked tree on the data partition that a later install could trip
over."""
left = box.sh("ls -A /usr/data/update 2>/dev/null").out.split()
assert not left, (
"the installer left its unpacked payload behind in /usr/data/update: %s"
% left)
def test_the_root_password_was_dealt_with_exactly_once(box):
"""A random root password is generated on the FIRST install only. Doing it
again on every update would mean a printer whose password changes under its
owner each time they upgrade."""
# `+`-prefixed lines are skipped because the software component's run.sh
# still runs under FlashForge's `set -x` and its trace lands in the same
# log. None of OUR echoes can appear that way any more -- the installer is
# its own file and is not spliced into anything -- but a trace line that
# merely quotes the string would still be counted, and this stays cheap.
log = box.file(INSTALL_LOG).text
generated = len([ln for ln in log.splitlines()
if "root password set (random" in ln
and not ln.lstrip().startswith("+")])
assert generated <= 1, (
"the installer generated a root password %d times -- an update must "
"preserve it, not reroll it" % generated)
# ------------------------------------- no stick, no update (the case's boot 3)
def test_a_boot_with_no_stick_does_not_try_to_update(box):
"""case-install.sh's third boot. The bake removes /stick.img once the
install has landed, so this machine IS the "stick pulled" case:
app_startup.sh must go straight to a normal boot rather than hunting for a
package that is no longer there.
`find update file` is app_startup.sh's own marker for entering the update
block -- the same string the case script keyed on, rather than a fuzzy
search for the word "update", which appears in a healthy boot too.
firmwareExe is neutered for the run, because the real one holds the
foreground forever by design. What is under test is the update block's
decision, not the boot that follows it.
The stub goes in by MOVING THE SYMLINK ASIDE, not by writing to $FE: that
path is a link into $MODDIR/prog now, so a redirection would write through
it and leave the packaged wrapper stubbed for whatever runs next. `mv`
moves the link itself, and moving it back restores it exactly.
"""
box.sh("mv -f %s /tmp/fe.link && printf '#!/bin/sh\\nexit 0\\n' > %s "
"&& chmod +x %s" % (FE, FE, FE))
try:
box.sh("sh %s > /tmp/boot-nostick.log 2>&1; echo rc=$?" % APP,
timeout=300)
log = box.file("/tmp/boot-nostick.log").text
finally:
box.sh("rm -f %s && mv -f /tmp/fe.link %s" % (FE, FE))
assert log.strip(), "app_startup.sh produced no output at all"
assert "find update file" not in log, (
"app_startup.sh entered the update block with no stick present:\n%s"
% log[-1500:])
def test_a_boot_with_no_stick_hits_no_missing_commands(box):
"""The boot log from the run above. A `not found` here is a command the
payload assumed and the printer has not got -- which on a real machine is
a step of the boot silently not happening."""
log = box.file("/tmp/boot-nostick.log").text
assert log.strip(), (
"no boot log -- test_a_boot_with_no_stick_does_not_try_to_update must "
"run first; it is the one that produces this")
bad = [ln for ln in log.splitlines()
if "not found" in ln or "Syntax error" in ln]
assert not bad, "missing commands or syntax errors in the boot:\n%s" % (
"\n".join(bad[:5]))