4.3 The regression suite¶
The regression suite is where the emulator is tested as a whole program rather
than as a set of linked classes. It launches the real jnext binary, headless,
and checks what comes out of it — mostly by comparing rendered frames against
committed reference images, and otherwise by exercising behaviour that only
exists once there is a real process, such as writing a file or opening an audio
device.
test/00regression/regression.sh is only the driver. It parses arguments,
validates the manifests, sources the test scripts in the declared order, and
enforces the end-of-run accounting. The test logic itself lives in
test/00regression/scripts/, which holds one script per functional test plus
three group scripts — 00-preflight-lint.sh, 01-sdcard-provision.sh and
screenshots.sh — with the shared helpers in test-functions.inc.
Two kinds of row, two manifests¶
Screenshot rows are declared in regression_tests.conf:
test_name machine_type nex_file screenshot_delay_frames [extra CLI args...]
nex_file may be the literal BOOT, which means "boot the machine and load
nothing". The delay is counted in emulated frames, never in wall-clock
seconds, so that a slow CI runner and a fast desktop capture identical machine
state. Each row launches jnext --headless with --delayed-screenshot, and the
resulting PNG is compared against the committed img/<name>-reference.png at a
default tolerance of zero pixels, using an exact any-channel difference mask. If
the two images have different dimensions the row counts as different rather than
being cropped down to the overlap.
One entry in the extra-args field is a harness sentinel rather than a jnext
flag: @private-sd is stripped by the launcher, which then gives that row its
own SD-card clone. Any row whose guest writes to the card needs it.
Functional rows are declared in functional_tests.conf, one name per line,
in run order. Each has its logic in scripts/<name>.sh and calls
begin_func <name> to register that its row really was reported.
Both files carry a # expect: N pin — currently 65 screenshots and 51
functional — and the driver faults if a pin and the declared lines disagree.
The independent witness¶
The completeness check compares rows reported against rows declared, but for
screenshots both of those sides are read from the same conf file, so on its own
that comparison is a tautology. The committed reference images supply the
witness from outside: every img/<name>-reference.png must have a conf
entry, which means truncating the manifest cannot silently shrink the suite.
This exists because a review round once deleted a screenshot row and its
reference image together, and got a green 59/0/0 — which happened to be the
project's own previous baseline. Nobody would have blinked at it.
The same both-directions rule covers the scripts directory. A declared
functional test with no scripts/<name>.sh could never report its row, and a
stray scripts/*.sh that nothing declares is a test that has been dropped from
the manifest.
The accounting assertion¶
At the end of a full run — that is, one not in --update mode — the driver
proves three things: that every declared functional test reported exactly
one row, that no undeclared row appeared, and that the grand total equals
2 lint + 1 sdcard-provision + screenshots + functional. Any mismatch is
reported as a harness fault, exit 2, and is explicitly not a pass.
Build artifacts that rows depend on — rewind_test and the SDL-only jnext —
are checked in the first second of the run rather than five minutes in. Running
with --preflight-only performs every guard and then exits, which is the seam
the harness self-test drives.
The SD image¶
No test row passes --sdcard. Every row relies on jnext's own default-location
lookup instead, and the suite provisions that image for itself in the
[sdcard-provision] row. Two separate mechanisms do two different jobs:
- A hash gate protects the machine-wide master under
~/.jnext/sdcard/from whatever happened to it between runs, re-deriving it only when it has drifted. This is not a theoretical concern: after an evening of manual NextZXOS booting the image had 11012 bytes changed, and a full run reported 91 pass / 5 FAIL on a branch that was green. - A per-run clone points
JNEXT_CONFIG_DIRat a private directory, so the run boots its own copy and every non-headless invocation starts from clean GUI preferences. That directory lives under$HOMErather than/tmp, becausecp --reflinkcannot cross a filesystem and--reflink=autowould quietly degrade to a real 1 GB copy into RAM.
Concurrency¶
JNEXT_TEST_JOBS caps the number of parallel jobs the screenshot launcher
runs. Do not raise it to buy speed. Some rows are bounded by real-time
pacing rather than by CPU: audio-underrun-func reports underruns when the box
is loaded, and screenshot-paused-func's control run takes about 55 s against
a 60 s timeout. Higher concurrency therefore makes the suite intermittently
lie. The variable defaults to nproc when unset, so pass JNEXT_TEST_JOBS=4
explicitly, exactly as CI does.
No row script may install a trap¶
The driver sources every row script into its own shell, and that shell
already holds the one trap regression_cleanup EXIT/INT/TERM that deletes the
1–2 GB per-run SD clone. Bash keeps a single handler per signal, so a second
trap ... EXIT in a sourced row silently replaces the harness's own. Because
INT and TERM are left alone, it is specifically the successful run that
then leaks its entire run directory while the counts stay green — a host
reached 93% full with 112/112 passing before anyone noticed.
lint-traps.sh, which is row 2 of the suite, bans trap in scripts/*.sh for
every signal and at any depth: behind builtin or command, inside eval,
and via a heredoc fed to source, . or eval, all of which run in this
shell. Matching is done on a syntax skeleton in which every quoted string
collapses to a single inert token, so a live string whose contents happen to
look like syntax stays clean. Its scope is bounded on purpose: it catches the
accidental trap, not deliberate obfuscation, which no static grep can. When a
row needs scratch files, put them under $TMP_DIR instead of installing a
cleanup handler — the harness trap already removes that directory.
Regenerating reference screenshots¶
$ bash test/00regression/generate-references.sh [test_name...]
This is a thin wrapper around regression.sh --update, which overwrites each
reference image with the frame just captured. It is not a routine action. In
--update mode the accounting is skipped and every row reports UPDATED
regardless of what it produced, so a genuine regression quietly becomes the new
baseline and every future run agrees with it. Regenerate only when a rendering
change is both intentional and understood, name the specific rows wherever you
can, and treat a reference diff appearing in a review as a claim to be checked.