6. Testing
The sim oracle
Zinc’s testing story rests on one property: the sim target runs your program on Node.js and is the oracle —
every native target must print exactly the same bytes. Fixed-point, f32 and f64 results are bit-identical across
targets by design (ADR 0005). So “does my program behave the same on the PS1 as on my Mac?” is a byte comparison, not
a guess.
zinc run app --target sim # the reference behaviour
zinc run app --target macos # must match sim, byte for byte
zinc run app --profile ps1 --target sim # oracle under a different number/size profile
zinc test
zinc test runs the conformance suite in tests/conformance/: each program is run on sim
and on the target, and both must equal the recorded .out file. It also checks golden --emit=hir/--emit=mir dumps
in tests/golden. Every run is deterministic: the programs see a virtual clock, not the machine’s.
zinc test # sim + host native
zinc test --target rpi1 # sim vs ARMv6 under QEMU
zinc test --profile ps1 # fixed-point profile (gradual-only programs are skipped)
zinc test --debug # under ASan/UBSan
zinc test --update # re-record .out files (after an intended change)
zinc test --update-golden # re-record HIR/MIR golden dumps
zinc test --pixels # visual regression (below)
Expected-output filenames encode the profile: foo.out, foo.f32.out, foo.640x448.out, so one program can have a
per-representation golden. A program can require the gradual profile with a // zinc-test: gradual first line (it is
skipped in strict profiles); // zinc-test: deterministic marks a program that needs deterministic mode (skipped on
esp32, ps1, ps2 and wasm, which have no environment to turn it on).
Your project’s tests: zinc test <dir>
zinc test <dir> works like tjs test or elsa test. It finds every test-*.ts(x) and *.test.ts(x) under the
directory, skipping build/ and node_modules/.
- How each file runs. It is built and run as its own program on the target, in deterministic mode.
- Pass or fail. A file passes when it exits with 0.
zinc:assertthrowsAssertionErroron a failed check, and an uncaught error exits with 1. - Timeout.
ZINC_TEST_TIMEOUTbounds each program, in milliseconds (default 60000).
// tests/test-cart.ts
import * as assert from 'zinc:assert';
import { total } from '../src/cart';
assert.equal(total([2, 3]), 5);
assert.deepEqual([1, 2].map((x: number) => x * 2), [2, 4]);
assert.throws(() => { total([-1]); }, 'negative');
zinc test tests # on the host (native build)
zinc test tests --target sim # on Node, fast
zinc test tests --target rpi1 # ARMv6 under QEMU
zinc:assert provides ok, equal / notEqual (===), deepEqual (compares the JSON), throws(fn, substring?),
rejects(asyncFn, substring?) and fail. A worked example is in
examples/testing/tests.
zinc compat: standards conformance
zinc compat measures how much of the standards Zinc covers, against reference engines. It runs pinned external suites
through Zinc (sim and native, gradual and strict profiles) and through Node, Deno, Bun and QuickJS: the WPT files of
the WinterTC Minimum Common Web API, a test262 sample, curated Node
API tests and quickjs-ng’s own tests. The suites are fetched at the commits pinned in tests/compat/manifest.json into
the git-ignored tests/compat/cache/ (offline afterwards); results go to tests/compat/results/<date>.json and the
table reports/compat.md.
zinc compat # everything, 4 jobs (~20 min)
zinc compat --suite test262 --filter String/prototype/at # a slice, not recorded
zinc compat --engines zinc-sim,node # fewer engines
zinc compat --check # exit 1 when a Zinc pass of tests/compat/baseline.json regressed
zinc compat --update-baseline # record this run's Zinc passes as the baseline
Conformance-style tests for your own app
To compare targets rather than check values, reuse the oracle. Split the core of your app into functions that print, run both sides in deterministic mode, and compare sim against your target:
ZINC_DETERMINISTIC=1 zinc run myapp --target sim > expected.out
ZINC_DETERMINISTIC=1 zinc run myapp --target rpi1 > got.out
diff -u expected.out got.out # empty diff = behaviour matches
For a UI app, bound the run with ZINC_FRAMES, drive it with the ui test hooks or a recorded tape,
then compare frames (zinc capture, zinc test --pixels) or the inspector dump(). To fold your
programs into zinc test, drop them in tests/conformance/ with a recorded .out (generated by zinc test --update).
Determinism
A Zinc run is a function of the program, its input and the frame count: run it twice, get the same bytes and the same
pixels. Deterministic mode makes that true for everything the runtime owns. It is on with ZINC_DETERMINISTIC=1, and
implied by ZINC_RECORD / ZINC_REPLAY, zinc test (both sides) and zinc capture. It guarantees:
- Virtual clock.
Date.now(),performance.now(),sys.clock(),console.timeand timers read a clock that starts at 0 and only moves when the program does: bydtat the start of each frame, or, for a program without a frame loop, straight to the next timer (no waiting). While sockets or other pollers have work in flight, it moves 1 ms per real millisecond, like the real loop. - Fixed frame time.
dtisZINC_FIXED_DT, else 1/60 s, on every HAL (display drivers included); frames are not paced by the wall clock, and the window system cannot run extra frames (live resize redraws are skipped). - Strict timer order. Timers fire in (due time, creation) order, each one followed by all its microtasks, then the
frame callback runs. The sim applies the same rules to the program’s timers (
sim/zinc.mjs), so its output is the oracle for native runs. - Seeded randomness.
Math.randomis a xorshift32 with a fixed default seed on every target (Math.seed(n)sets it), in or out of deterministic mode. - No live input. The mouse, keyboard, touch and pen of the machine are ignored; the program sees only a replayed
tape or the
uitest hooks (ui.pointerAt,keyDown,typeText…). - Fixed surface. The SDL window keeps its logical size (
fillresize is off) and the clipboard is process-local. - Same float results. Every native build uses
-ffp-contract=off: no fused multiply-add, so arithmetic rounds like the sim (IEEE, one operation at a time) on arm64, x86-64, ARMv6 and Xtensa alike.
What is still not deterministic:
- I/O timing: network replies (
zinc:net, MQTT, OSC), files changed by others, the gphoto2 camera, remote-view beacons. Their results arrive when the OS delivers them; stub them out for a reproducible run. - Threads: video decoding (
plugins/video) runs on its own thread and paces playback with the real clock; its shuffle seed is taken from the clock too. - Plugins that read the OS clock themselves (
hal_time_us, notzrt::now_ms): the e-ink emulators’ refresh timing, remote display pacing. The map plugin’s per-frame tile budget uses the runtime clock: in deterministic mode every visible tile is rendered in the first frame. - Performance counters:
stats.frame_us, telemetryframe_ms, the dev-mode timings. - Recording gaps: tapes carry
HalInput(keys, text, buttons, pointer, wheel, touch) but not pen samples or window resizes, and are raw structs for the host that wrote them (little-endian, samehal.h). - Console targets: esp32, ps1, ps2 and wasm have no environment to switch the mode on. Their frame time is fixed already (1/60, esp32 1/30), but timers follow the hardware clock.
- Pixel density: frames are rasterized at the window’s physical size (
ZINC_SCALE, Retina × zoom); captures pinZINC_SCALE=1so goldens do not depend on the screen. - Window renderer: on macOS the window shows the software raster through Metal, the frame in memory the GPU reads (
ZINC_RENDERER=auto, the default: three buffers of the window’s size where OpenGL held nine, about 80 MB for a 1180x760 window at density 2 instead of 210;tools/window-memorymeasures it;--simulatekeeps OpenGL, which turns the picture;ZINC_PRESENT=texturetakes SDL_Renderer). On Linux the window draws the frame’s commands with OpenGL when the machine has a GPU (ZINC_RENDERER=auto, the default;glforces it, on macOS too,cpukeeps the software raster; Mesa’s llvmpipe counts as no GPU) and waits for the display between frames (ZINC_VSYNC=0to measure without it).ZINC_GL_STATS=1names the renderer and prints the replay’s counters at exit, with the GPU time per frame on macOS (a timer query). Transparent windows and display plugins use the software raster. Captures, goldens and headless runs always use the software raster: it is the reference the GL output is compared with (tests/t1/gl_renderer.sh).
Captures
The runtime rasterizes frames itself (runtime/raster.cpp), so it can save the one on screen on any HAL with a file
system (SDL window, headless null HAL, docker/QEMU targets), at its physical size, as PNG (uncompressed stored blocks,
no dependency) or, for a path ending in .bmp, BMP:
ZINC_FRAMES=120 ZINC_SHOT=last.png zinc run myapp # the last frame when the program ends
ZINC_SHOT=out.png ZINC_SHOT_FRAMES=1,30,60 zinc run myapp # out-1.png, out-30.png, out-60.png
ZINC_SHOT=out.png ZINC_SHOT_EVERY=10 ZINC_FRAMES=100 zinc run myapp # every 10th frame
Frames are numbered from 1, like ZINC_FRAMES counts them. From code, gfx.capture('frame.png') saves the frame on
screen (false when it cannot be written; always false on the sim, which draws nothing). Display-driver emulators keep
their own ZINC_SHOT picture of the emulated device (BMP); on ps1 the emulator saves it (targets/ps1/shot.lua).
zinc capture does it for any program, headless and deterministic (SDL’s dummy video driver, ZINC_SCALE=1),
and writes compressed PNGs:
zinc capture examples/breakout --frames 1,60 --out shots/ # shots/breakout-1.png, shots/breakout-60.png
zinc capture myapp --every 30 # up to ZINC_FRAMES (60), into build/shots/
zinc capture myapp --target rpi1 --frames 120 # docker/QEMU: --out must be inside the project
zinc capture myapp --replay session.tape --frames 300 # with recorded input
Record and replay
ZINC_RECORD=session.tape runs the program deterministically but keeps the live input, and writes every frame whose
input changed to the tape. ZINC_REPLAY=session.tape plays it back: same frames, same input at the same frame
numbers, so the same pixels. A bug report becomes program + tape + frame number.
ZINC_RECORD=bug.tape zinc run myapp # play until the bug shows, close the window
ZINC_REPLAY=bug.tape ZINC_FRAMES=900 zinc run myapp # the same session, again
zinc capture myapp --replay bug.tape --frames 850,900 # the frames around it, as PNG
Under zinc dev (ZINC_RECORD=... zinc dev), each reload starts a new tape: it holds the session of the version on
screen.
Visual regression: zinc test --pixels
Programs in tests/visual/ render headless in deterministic mode; the frames named by a
// zinc-test: frames 1,30 line (default 60) must equal their golden PNGs tests/visual/<name>-<n>.png
(<name>-<n>.fx12.png, <name>-<n>.1280x720.png… for other profiles) pixel for pixel. A failure keeps the frame
and a diff (differences in red over the faded expected image) under build/visual/<target>-<profile>/:
zinc test --pixels # macos
zinc test --pixels --profile ps1 # fixed-point profile, its own goldens
zinc test --pixels --target rpi1 # the same goldens, rasterized on ARMv6 under QEMU
zinc test --pixels --update # re-record the goldens after an intended change
tests/visual/clock.ts draws from an interval, Date.now and Math.random: with ZINC_DETERMINISTIC=0 its frames
no longer match, which is the point.
--debug: ASan and leak reports
--debug builds with AddressSanitizer + UndefinedBehaviorSanitizer and prints a leak report at exit; the target for a
clean run is 0 live objects. Use it to catch use-after-free, bad casts and reference-cycle leaks (which RC does not
collect — see the language).
zinc run myapp --debug # ASan/UBSan; watch for the live-object count at exit
zinc test --debug # the whole suite under sanitizers
sys.liveObjects() / sys.allocations() report runtime counts from inside the program.
Dev mode: red box and inspector
zinc dev (chapter 1, dev mode) is the interactive testing loop: hot reload on save, a full-screen
red box on any panic/uncaught error (with the Zinc call stack in dev builds), a LogBox-style banner for
console.warn/error, and a UI inspector for zinc:ui apps over the Chrome DevTools protocol on :9229
(Elements, computed styles, live class editing, console, native screenshots). scripts/cdp-check.mjs is a scripted
client that exercises the inspector. F12 in the program window saves the frame on screen to
<project>/build/shots/frame-<n>.png.
Benchmarks
Performance numbers vs QuickJS and Node are in reports/PERF.md (~27× QuickJS, ~70 KiB binaries,
~2 ms startup), with the benchmark programs in tests/.
Frame budgets. zinc bench [example dirs] [--target macos] (alias zinc test --bench; default: hero,
zed-editor, maps/navigation) builds each example whose zinc.json has a bench entry, runs its scripted scene
headless in deterministic mode with the profiler (dev mode, profiling), prints p50 / p99
per phase, and fails when the p99 of the frame work exceeds the budget:
"bench": { "demo": "bench", "frames": 600, "p99Ms": 25 }
demo is the ZINC_DEMO value that scripts the scene (it quits by itself or after frames). Timings are wall
clock: budgets keep headroom for a loaded machine.
For code, run a benchmark program directly and time it, or use console.time / performance.now() inside the
program:
console.time('parse'); doWork(); console.timeEnd('parse'); // "parse: 3.2ms"
Next: distribution.