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, runs the test scripts — the group scripts in its own
shell, each functional row in a process of its own (row-runner.sh, scheduled
by parallel-rows.inc) — 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,
optionally followed by tags (below). 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 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
6 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. The hash witness is written when the image is derived, so it cannot notice that jnext now derives a different image. The gate therefore also re-derives when the image's.recipesidecar, written by jnext, does not hold thesdcard::kFixedImageRecipeof the tree under test. - 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: one process per row, in phases¶
The functional rows used to be sourced into the driver one after
another, sharing its counters, its TMP_DIR and its SD clone, which
made them serial. Most of them wait on real time rather than on the CPU,
so a full run spent twelve minutes on a host that was 85% idle.
Now each row runs in a process of its own. row-runner.sh initializes the
suite library in row-process mode — with the row's own TMP_DIR
($TMP_DIR/rows/<row>) and RUN_DIR ($RUN_DIR/rows/<row>, which is also its
JNEXT_CONFIG_DIR, so preferences and the warm-start cache are private) handed
over by the driver — and then sources the row, inside the same counter guard
as before. It hands its tally back in a result file; a harness fault inside it
also leaves <result>.fault. The driver runs the rows in phases:
| Phase | Rows | Lanes |
|---|---|---|
| screenshots | regression_tests.conf |
JNEXT_TEST_JOBS, default nproc |
| parallel | untagged functional rows | JNEXT_TEST_JOBS, default nproc |
| quiet | rows tagged quiet |
JNEXT_TEST_QUIET_JOBS, default 1 |
| serial tail | rows tagged serial |
1 |
and prints each row's buffered output, in declared order within its phase, as soon as it and every earlier row have finished.
The tags carry what used to be an unwritten rule. quiet marks a row that
paces against real time — audio-underrun-func reports underruns on a loaded
box, screenshot-paused-func's control run takes about 55 s against a 60 s
timeout — and was measured to fail in the parallel phase; that is where the
old advice "pass JNEXT_TEST_JOBS=4, never raise it" went. serial is
sdcard-isolation-func, whose "the master was never touched" check must
follow every other row. private-sd marks a row that writes to the SD card,
which then boots a copy of its own; every other row boots the run's clone
through a hard link, and the driver fingerprints that clone after provisioning
and again at the end, so an untagged writer is a harness fault rather than a
row that quietly changes what its neighbours boot.
The driver keeps every accounting check and adds the ones a process boundary
needs: a row process that dies without a result, or outlives its 600 s bound,
is a FAIL naming it; a row's directory that already exists when it starts, is
still there when it exits, or is left over at the end, is a harness fault.
JNEXT_TEST_JOBS is now only a cap, for leaving CPUs to something else; do not
raise JNEXT_TEST_QUIET_JOBS to buy speed. Output is printed in declared
order, so a heartbeat line every 60 s names the rows still running.
Parallel rows exposed one shared-resource race the serial suite never could:
xvfb-run -a chooses an X display number by scanning lock files, so two rows
starting together could pick the same one, and the loser SKIPPED (a failure now). Every row now
uses xvfb-run -d, where Xvfb picks the display itself, and harness-selftest
HS-72 bans the racy forms.
A second Xvfb race hit the rows that poll with xdotool: without -noreset the
X server regenerates whenever its last client disconnects, and the row's own
xdotool search poll is such a client while the app starts, so an app
connecting mid-regeneration is dropped (SDL_Init: x11 not available, no
screenshot; 18/1000 connections failed under load, 0/1000 with
-noreset). Those rows start Xvfb with -noreset, HS-89 enforces it, and a
missing screenshot or non-zero app exit is a FAIL naming the run and cause (the
solo-confirm path), never a SKIP.
One full run at a time, and the stamp¶
A full run (no arguments, real manifests) takes a host lock through flock -o
before it sources anything, so a second one waits, saying who holds it, and no
child of the run can inherit the lock. It then waits, bounded, while the
1-minute load is above nproc. make regression also asks the run for a
stamp: on a green run of a tree with no uncommitted non-doc change,
test/regression-stamp.sh records the counts, the loads and the commit under a
key hashed from git ls-tree over every non-documentation path.
make regression-stamp-check prints the stamp covering the current tree, which
is how a batch gate on an unchanged tree avoids repeating the run, and
make regression-ci-check finds a green CI run with the same key, which is how
a release reuses CI. Only a run with no FAIL and no SKIP is stamped, and since 2026-10-06 regression.sh
itself exits 1 on a SKIP in every mode (full, named rows, confirm, --update;
a standalone row's standalone_summary too), naming the rows. A tool a row needs
is provisioned rather than assumed: make regression first runs make z88dk-gdb
(test/provision-z88dk-gdb.sh), which builds z88dk v2.4's client, sha256-pinned,
into ~/.cache/jnext/tools when none resolves. The two foreign readers of the reverse-engineering exports are provisioned
the same way by make skoolkit and make ghidra; see
7.4.7. A run whose
only FAILs are functional rows that failed on a loaded host is recorded as
pending, and make regression-confirm re-runs exactly those rows solo; if they
all pass the run is stamped, and the stamp names them. Targeted rows run through
make regression-rows ROWS="...", which builds the binaries they need. Its Qt
jnext is the non-PGO Release build (build/gui-release-non-pgo): a branch trains
no PGO profile, and the same code gives the same pixels. ROWS_PGO=1 runs the PGO
build/gui-release instead; make regression and regression-confirm always do. A row
name that neither manifest declares (or an empty one) is a harness fault, exit 2, in every
mode, so a mistyped name can never run nothing and pass.
A failure on a loaded host¶
Those two rows are examples, not the list. Rows that merely spawn short-lived
processes have also failed under a parallel-agent load and never reproduced
solo (subsystem-gain-func), and a concurrent duplicate build once
failed with undefined reference to main. A list of sensitive rows in a
document is therefore the wrong tool: a reader either chases a ghost, or —
worse — waves a genuine failure through as "probably contention" because the
row was not on the list.
So the harness says it itself. It prints the 1-minute load average at the start
and at the end of the run, marks every FAIL that happened with the load at or
above nproc on the spot, and after the results lists each failed row by name
with the load at the moment it failed, plus a warning when the run was loaded.
None of that changes a verdict: a FAIL is still a FAIL and the exit status is
unchanged. What it changes is the next step — re-run the row SOLO
(make regression-rows ROWS=<row> ROWS_PGO=1, the PGO binary the full run tested) before treating it as a
regression, and do not dismiss it until that solo run passes. The self-test
pins the behaviour both ways (HS-50..55).
No row script may install a trap¶
Every row script is still sourced — into its row-runner.sh shell — and
that shell already holds the one trap regression_cleanup EXIT/INT/TERM that
deletes the row's directories (in the driver, the same trap 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.
No row script may touch the counters¶
The same sourcing puts the harness's own pass, fail and skip in every
row's scope. script-replay-edge-func once used skip for a reason string;
that zeroed an earlier SKIP, so CI — where gdb-z88dk-func then skipped — reported 214
of 215 rows with an empty Skip:, while every local run, with nothing skipped,
stayed green. The driver now wraps each functional row in a guard
(row_counters_snapshot / row_counters_check, test-functions.inc) that
checks the EFFECT after the row, however it was done: the three counters are
still integers, none went down, and together they grew by exactly the row's one
result (none for a row a name filter left out). Anything else is a harness
fault naming the row. A runtime check, not a lint, because it also sees an
indirect write — read, printf -v, declare, a helper the row called — that
a grep would not. What it cannot see: a row that changes a counter and puts it
back, one that swaps results while keeping the sum, and any other harness
global. Give a row's own variables names of their own.
Every timeout must escalate to SIGKILL¶
timeout N cmd sends SIGTERM and nothing after it. A command that does not
act on SIGTERM keeps running; timeout waits for it and then reports 124, so
the bound is decorative and the status lies. Two jnext processes were once
found alive 9289 seconds after a timeout 120 in a row — reparented to
systemd, the worktree that spawned them long deleted, burning a core apiece
underneath the suite's pacing-bound rows. A runaway of that class fails nothing
itself; it makes other rows lie.
So every invocation uses the house form:
timeout --foreground --kill-after=5s 60s "$JNEXT" --headless ...
--kill-after is the requirement, and --signal=KILL satisfies it too:
SIGKILL cannot be ignored, so the bound is real. It costs nothing when the
command is well behaved, because it fires only if SIGTERM was already
disregarded. --foreground is not required and is not always right — without
it timeout gives the command its own process group and the signal reaches its
children, which is what you want when it spawns a process tree.
test/lint-timeouts.sh, row 3 of the suite, enforces it across every tracked
*.sh under test/ — a wider scope than the trap lint, because this hazard
has nothing to do with being sourced: a process a packaging test leaves behind
costs the same as one a regression row leaves behind. The rule has no
exception list, deliberately: whether a given program handles SIGTERM is
not statically decidable, and "this one is fine" is the reasoning that put the
bare timeout there in the first place. A sweep fixes only what it is pointed
at: the hand pass before the lint fixed five call sites in two files and left
three behind elsewhere, one of them seven weeks old in
test/packaging/packaging-test.sh — which is the case for a whole-tree scope.
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.
Platform runs: macOS and Windows under wine¶
regression.sh --platform (make regression-win, make regression-macos) runs
the screenshots and the functional rows an OS can run against that OS's
binaries. It reuses the whole driver: the manifest pins, the per-row processes,
SKIP = failure and the completeness accounting stay as they are; only the
selection, the binaries and the accounting change.
Declaring applicability. A row's line in functional_tests.conf may carry
os=<list>, a comma-separated subset of linux,macos,windows. linux is
mandatory (a Linux run runs every row; the Linux pins never change), no tag means
all three, and an unknown or empty element, or a list without linux, is a
harness fault. The reason sits in a # os: comment on the line above the row.
# expect-macos: N and # expect-windows: N beside # expect: pin how many rows
each OS runs; once any row is tagged both are required, and a mismatch in either
direction is a harness fault, so retagging a row is a deliberate edit. Absent rows
are printed by name; a named row the OS does not run is refused; an absent row
that reports is a harness fault. A platform run's total is 1 provision + the
screenshots + the admitted rows (no lints, no stamp).
The Windows runner. test/wine-run.sh --jnext runs jnext.exe as $JNEXT
(two one-line shims in the run's $TMP_DIR call it, so rows keep invoking
"$JNEXT" as one word). Each of these was measured to be necessary under wine
11:
- spdlog writes CRLF on Windows, to stdout and stderr; the runner folds it to LF
with an unbuffered
sed -u, one filter when both streams are the same file. A row cannot see a CRLF jnext writes there; it is the platform's documented EOL, not a contract any row asserts. Text files are another matter: jnext's trace files are written in binary mode so they are LF on every host. - wine drops
QT_*andSDL_*from the Unix environment and importsWINE<name>as<name>, so the runner exportsWINEQT_QPA_PLATFORMand the like. Without it a Qtjnext.exeuses thewindowsplatform and never exits. - The first wine process spawns a
wineserverthat inherits the output filters' pipes and never lets them see EOF, so rows hang. The driver starts one with stdio on/dev/nullbefore any row (wine-run.sh --serve) andregression_cleanupstops it (wineserver -k). The desktop process (explorer.exe /desktop) is the same hazard: wine starts it from whichever client first needs the desktop, on that client's stderr (a row's filter pipe), and it lives while any other wine process uses the desktop, so a 0.1 s step could be held to its 120 s bound by another row.--servetherefore also starts a keeper (wine start /wait cmd /c pause, stdio on/dev/null), verifies exactly one stableexplorer.exeof the prefix on/dev/nulland prints its pid; the driver records it and raises a harness fault if that process is not the same one at the end of the run. - The filter must be unbuffered: the rows' "listening on" polls time out on block-buffered output.
- A background job has stdin on
/dev/null; the runner passes stdin on explicitly. TERM and INT are forwarded to wine, and its exit status is the exit status. - bash and wine are called by absolute path (a row may set
PATHto an empty directory);DISPLAYis kept only for anxvfb-rundisplay, never a desktop. - wine cannot
CreateProcessan ELF, so the--recordrows (ffmpeg) are absent on Windows; the Windows spawn path is covered by the unit rows SF-*. - The prefix is ~1.7 GB and lives under the build tree
(
build/win-release-non-pgo/wine-prefix), never in/tmp. dzrp-paused-headless-funcmeasures jnext's own CPU: under a wrapper "the first child" is a shell, so the row finds the process namedjnext/jnext.exeamong the descendants (deepest one) and reads its CPU from/procor, off Linux,ps -o time=.- The runner sets
DBUS_SESSION_BUS_ADDRESSandDBUS_SYSTEM_BUS_ADDRESStodisabled:in every mode, next to theDISPLAYremoval. SDL's audio init makes wine load winealsa, whose pipewire plugin makes synchronous calls to the desktop's xdg-desktop-portal over the inherited session bus; a slow portal stalled a Qtjnext.exepast its row's bound. CI's container has no D-Bus, so this makes a local run equal CI. Both are overridden, not unset (libdbus falls back to the system bus). Nothing of this exists on Windows. - A stalled
jnext.exenames its blocked call. WithJNEXT_WINE_CAPTURE_DIRset (the driver exportsbuild/regression-artifacts/wine-stall/<run>/) the runner's watchdog fires once on a process at least 20 s old whose CPU time has been flat for 10 s and totals 2 s or less, and writeswine-stall-<time>-<pid>.txt:/procthreads, the prefix's processes, thewinedevice.exethreads,winedbg info thread, and oneBacktrace:per thread of that Windows pid (onewinedbg --filesession; neverbt all, which real winedbg refuses). TERM is forwarded at once. Before the first row the driver checks the capture on a realjnext.exeblocked on the SD prompt and treats a capture with noReadFilebacktrace as a harness fault. Every Windows run ends with awine prefix at the end:line and lists any captures.
On macOS the SD image comes from make sdcard-image, the per-run clone falls
back to a plain copy where cp --reflink=always does not exist (reported as
copy), the host load comes from sysctl vm.loadavg, and no host lock is taken.