Files
firmware/test/test-applets.py
T
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

136 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Every command the payload runs must actually exist on the printer.
The printer's busybox is a 1.31.1 build with a specific applet set. It has no
`timeout`, no `bash`, no `systemctl`. A script that reaches for one of those
fails at boot, on the machine, with the screen already blank -- so the check
belongs here, against the real extracted rootfs.
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RFS = os.path.join(ROOT, "work", "rootfs")
KEYWORDS = set("""
if then else elif fi for while until do done case esac in function select time
return exit local export readonly set unset shift eval exec trap break continue
true false echo cd test read printf source wait umask alias command type hash
getopts times ulimit jobs fg bg kill let
""".split())
SEPARATORS = re.compile(r"[;&|\n]|\$\(|`|\(\s|\bthen\b|\bdo\b|\belse\b")
WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*")
def strip_quotes_and_comments(text):
"""Blank out comments and quoted text so prose cannot look like a command."""
out, i, n = [], 0, len(text)
quote = None
while i < n:
c = text[i]
if quote is None and c == "#" and (not out or out[-1] in " \t\n;&|(`"):
while i < n and text[i] != "\n":
i += 1
continue
if quote is None and c in "'\"":
quote = c
out.append(" ")
i += 1
continue
if quote is not None:
if c == "\\" and quote == '"':
out.append(" ")
i += 2
continue
if c == quote:
quote = None
out.append(" ")
i += 1
continue
# keep $(...) and `...` inside double quotes: they are real commands
out.append(c if (quote == '"' and c in "$()`") else " ")
i += 1
continue
out.append(c)
i += 1
return "".join(out)
ARITH = re.compile(r"\$\(\([^()]*\)\)")
def command_words(text):
text = strip_quotes_and_comments(text)
while ARITH.search(text): # $((...)) is arithmetic, not a command
text = ARITH.sub(" ", text)
words = []
for segment in SEPARATORS.split(text):
segment = segment.lstrip(" \t()")
m = WORD.match(segment)
if not m:
continue
word = m.group(0)
rest = segment[m.end():].lstrip()
if rest.startswith("=") or rest.startswith(")"):
continue # assignment, or a case label
words.append(word)
return words
def main():
if not os.path.isdir(os.path.join(RFS, "bin")):
print(" SKIP: no printer rootfs -- run 'make rootfs' first")
return 0
avail = set()
for d in ("bin", "sbin", "usr/bin", "usr/sbin"):
p = os.path.join(RFS, d)
if os.path.isdir(p):
avail.update(os.listdir(p))
allow = set()
allow_file = os.path.join(ROOT, "test", "applets.allow")
if os.path.exists(allow_file):
for line in open(allow_file):
line = line.split("#")[0].strip()
if line:
allow.add(line)
files = []
for dirpath, _, names in os.walk(os.path.join(ROOT, "payload")):
for n in names:
if n.endswith(".sh") or n == "firmwareExe" or re.match(r"^S\d", n):
files.append(os.path.join(dirpath, n))
files.sort()
fail = False
for f in files:
src = open(f, encoding="utf-8", errors="replace").read()
funcs = set(re.findall(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\)", src, re.M))
rel = os.path.relpath(f, ROOT)
for w in sorted(set(command_words(src))):
if w in KEYWORDS or w in funcs or w in avail or w in allow:
continue
print(f" FAIL {rel}: \"{w}\" is not on the printer "
f"and not in test/applets.allow")
fail = True
# Absolute paths into the rootfs must exist. /usr/prog and /usr/data
# live on partitions the rootfs does not contain; the install
# simulation covers those.
for p in sorted(set(re.findall(
r"(?<![\w.])(/(?:bin|sbin|etc)/[A-Za-z0-9_./-]+|"
r"/usr/(?:bin|sbin)/[A-Za-z0-9_./-]+)", src))):
if not os.path.exists(RFS + p):
print(f" FAIL {rel}: {p} does not exist on the printer")
fail = True
if not fail:
print(" every command the payload uses exists on the printer")
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())