Testing
Tests are tiered by what they need to run: pure C, pure JavaScript, the engine under a headless JavaScript host, a C compiler, a packed npm package. Pick the tier by what the change touches.
The engine: CTest
cmake -S engine -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
The suites live in engine/tests/, one folder per area: layout (the flexbox solver, including a
Yoga-parity suite whose expected rectangles come from the real yoga-layout package), text,
rendering (arc, gradients, images, opacity, occlusion, display: none, modal damage, multi-buffer
replay, damage rectangles, content padding, transformed-animation damage), animation (curves,
interpolation, layout animation), input (hit-testing and the responder system), scroll,
scene and resources. They run against the host software path, so they test the engine, not a
board's backend.
Two habits the suites depend on:
- Assert ink before asserting equivalence. A stubbed
blend_rectpaints no anti-aliased pixels at all, and a "before equals after" assertion passes against two blank screens. Check that something was painted first. - The first commits after
er_resetrepaint everything. Settle with two commits, and poison the framebuffer before checking that a change repainted only its own rectangle.
CI runs the suite six times with different compile-time flags (gradients on, two render workers
with full transforms and the on-screen keyboard, occlusion culling off, 3D transforms, shadows with
a decoupled transform scratch), because a flag that is off by default is code that is otherwise
never compiled. A new ERUI_* flag needs a pass.
The bridge: C tests and smoke
cmake -S bridges/quickjs -B bridges/quickjs/build -DCMAKE_BUILD_TYPE=Release
cmake --build bridges/quickjs/build
ctest --test-dir bridges/quickjs/build --output-on-failure
./bridges/quickjs/build/er-bridge-quickjs-smoke
The bridge's own CTest covers heap accounting: that the allocator reports block sizes and the
collector runs, the bug that looks like a leak on bare metal. It also runs the runtime/ container
test, which checks the CRC and that a malformed container is rejected without reading past it
(cmake -S runtime -B runtime/build runs that one alone). The smoke binary is a link check. CI
builds the bridge twice, with the native allocator and with the bare-metal size-prefix one, and
runs the JavaScript runtime tiers against both.
The JavaScript package: three tiers
From packages/embedded-react:
| Command | What it runs | Needs |
|---|---|---|
npm test | Vitest over every __tests__/*.unit.test.* in the package: pure JavaScript, no engine. The style splitter, prop helpers, the AOT compiler's unit and smoke tests, and a parity test that keeps the type declarations, the bridge's prop tables and the AOT in step | Node |
npm run test:runtime | test/runtime/*.runtime.test.{jsx,js}, each bundled and run inside QuickJS plus the real engine in a headless harness (no window), with a check()/report() API the C runner reads | The er-bridge-quickjs-runtest binary |
npm run test:bytecode | The same suite, each bundle precompiled to .qbc bytecode and loaded through JS_ReadObject: the path a device takes | Also er-bridge-quickjs-compile |
Build the harness once, without SDL:
cmake --build bridges/quickjs/build --target er-bridge-quickjs-runtest er-bridge-quickjs-compile
Set ER_BRIDGE_BUILD_DIR to use a bridge build elsewhere. Choose the tier by what the code touches:
marshalling and pure logic go in a co-located unit test; anything that exercises the reconciler
through to the engine goes in a runtime test.
Runtime tests have their own traps. NativeUI.tick() advances time but never paints; use
NativeUI.commit() before reading pixels. The root has no background, so put a backdrop under what
you measure. And esbuild drops a call whose result is unused, so assert that something throws
through a call whose value is consumed, or the call vanishes from the bundle. The unit tier must
not call compileToBytecode: the simulator wasm is not built in CI's unit job.
The AOT compiler
aot/__tests__/ runs under npm test: compile covers what the compiler refuses case by case, date-now the
64-bit time rules, typescript the TS entry path, package the package it writes, layouts how it finds an app's
layouts, and demos.smoke compiles the thermostat and the watch face and the two starter templates.
The starters are a guardrail: a newcomer's first build must compile ahead of time and use only baked font sizes, so the smoke test asserts both. Changing the template means keeping those green.
Flow B packages against golden images
npm run erb:frames compiles small test apps in test/erb-frames/fixtures to Flow B packages, and tools/erb-frames builds a headless host around
the Flow B runtime. It renders through the software backend with no window, runs the scenario's taps, and must end on
the scenario's image in test/erb-frames/golden, with each channel allowed to be off by one, since float rounding
differs between machines. A frame that differs is written as PNGs (frame, golden, and a diff) under
tools/erb-frames/build/out, which CI uploads when the job fails. When a change to the frames is intended, check them,
then run npm run erb:frames:record [filter]. Each package's resource report is printed as it is built. Some scenarios build the
package with no size named, or with named panels, then run that one package at each size. Packages are
debug builds, so the runtime checks each one's debug table as it loads it; a few scenarios build for
release instead. It needs CMake and a C compiler, and CI runs it.
Each app exercises one feature and never changes, so a frame that differs means the compiler, runtime or engine changed. The demos stay out of it, since they change. A new feature, or one no app covers, gets a new app and new scenarios; check its images by eye before committing them.
The compiler's copy of the package format is held to runtime/erb_format.h, runtime/erb_props.c
and er_scene.h by aot/__tests__/package-format.unit.test.js.
Fuzzing the package loader and VM
tools/erb-fuzz is a libFuzzer target that mutates real packages, loads them, and runs whatever passes
on the engine and the software backend, with tapping, typing, and reloads, under ASan and UBSan. CI runs
it for five minutes on every push and pull request, and any crash, hang, or sanitizer report fails the job.
The corpus is in the repo. Its seeds are both demos and each frame-test app; npm run erb:corpus rewrites
them after a format change, and the job fails when one stops loading. A failure's input goes in
corpus/regressions/ with its fix. The tool's
README covers
running it and replaying a failure, which works on a Mac too.
Parity
npm run parity renders the same demo through both flows and asserts the framebuffers match
within a small per-pixel tolerance. It opens real SDL windows, so it needs a display and does not run
in CI. Flow A packs the demo into a container and renders it through the desktop host; Flow B compiles it to a package, which the AOT desktop host loads and renders. Optional taps
drive both through the same interaction, so the dynamic state is compared too. Because the engine, fonts,
and backend are shared, a correct demo renders byte-identically, and any difference is a real Flow A
to Flow B divergence; the harness is how an animated-transform binding bug was once caught. Each
responsive scenario feeds one screen size to both paths.
Known residue: a small subpixel divergence in dial and text rendering sits under the harness's tolerance, and Flow A gets no baked assets unless it loads from a container, which is why the harness packs one.
Consumer smoke
node tools/consumer-smoke.mjs packs the npm package, installs it into a throwaway project, and
runs the consumer commands: the AOT build, the TypeScript template's typecheck, and (when the
prebuilt wasm is present) the Flow A container build. It guards the bugs the repository's own tests
cannot see because they only appear once the package is packed and installed elsewhere: a file
missing from the files whitelist, ESM-versus-CJS resolution, the bytecode compile through the
prebuilt wasm. Every consumer regression the project has shipped would have been caught here, which
is why CI runs it.
On hardware
Nothing above runs on a board. For a change that touches a backend, the frame loop, memory sizing,
or anything timing-related, build and flash the relevant example and say so in the pull request:
what board, what you saw. The perf overlay gives numbers to quote (on the ESP32-S3 it is always on, and
idf.py -DER_PERF_DETAIL=1 adds the engine's frame timings), and the ESP32 examples log free RAM
after boot. Host-side benchmarks have misled this
project more than once (a pixel-move scroll that measured 12 to 28× faster on a laptop gained 15%
on the board), so a claim about speed is a claim about a board.
What CI runs
Every push and pull request runs six jobs, all required to merge: JS tests + version drift
(npm test, npm run typecheck and sync-version --check), Engine build + ctests (the six flag passes), QuickJS
bridge build + heap-accounting test (two allocator builds, each with the runtime and bytecode
tiers), Flow B golden images (npm run erb:frames), Fuzz the package loader and VM, and Consumer smoke. The docs site has
its own workflow that builds on a pull request and deploys on master.