Headless Rendering
Introduction
WIP. Wayland and XCB native screenshot support is incomplete.
The same binary that opens a desktop window can run without one. There is no display server, no OpenGL context, no visible window. Set an environment variable, the framework swaps in a CPU-only backend, and the rest of the pipeline (layout, callbacks, timers, re-renders) runs unchanged. Output is captured through the HTTP debug API.
AZ_BACKEND=headless ./my_app
This is the standard configuration for screenshot diffing in CI, smoke tests, and pre-rendering content for caching.
Selecting the backend
Two environment variables, set before the process starts:
AZ_BACKEND=headless. Forces the headless backend even when a display is available.AZUL_HEADLESS=1. Legacy alias for the same.AZ_DEBUG=<port>. Starts the HTTP debug server (event injection plus screenshot capture).
AZ_BACKEND=headless ./my_azul_app
AZ_BACKEND=headless AZ_DEBUG=8765 ./my_azul_app
A binary built for desktop runs unchanged in a server-side container with these flags set.
Capturing screenshots over HTTP
When the process boots with AZ_DEBUG=<port>, the debug server (covered in Debugging) exposes two screenshot ops:
take_screenshot. Returns a CPU-rasterised PNG of the current DOM, no window decorations.take_native_screenshot. Returns the current framebuffer with whatever the OS is drawing.
In headless mode there is no OS framebuffer, so prefer take_screenshot. Both return a base64 data URI in the data.value field of the response envelope:
AZ_BACKEND=headless AZ_DEBUG=8765 ./my_app &
sleep 0.2
curl -s -X POST http://127.0.0.1:8765/ \
-d '{"op":"take_screenshot"}' \
| jq -r '.data.value' \
| sed 's|^data:image/png;base64,||' \
| base64 -d > screenshot.png
This is the pattern the screenshot harness in scripts/screenshot.sh uses to render azul-render fences in the documentation.
Driving a headless app
Drive interactions through the same HTTP API documented in Debugging. A headless run blocks on a wait condition just like a normal window blocks on WaitMessage() / XNextEvent() / NSEvent, so an idle process uses zero CPU. Inject events, query state, capture pixels:
post() { curl -s -X POST "http://127.0.0.1:8765/" -d "$1"; }
post '{"op":"wait_frame"}'
post '{"op":"resize","width":1024,"height":768}'
post '{"op":"click","selector":".increment-btn"}'
post '{"op":"wait_frame"}'
post '{"op":"take_screenshot"}' | jq -r '.data.value' > out.b64
For repeatable scenarios crystallised into a JSON file, see End-to-End Testing.
Determinism and CI
CPU rendering is consistent in concept across platforms, but pixel-exact output differs at sub-pixel positioning, antialiasing, and font hinting boundaries. For CI screenshot diffing:
- Use
assert_screenshotwith amax_diff_ratiotolerance rather than byte-equality. - Pin the harness to one platform (Linux is the cheapest in CI) and treat baselines from other platforms as informational.
- Bundle a font (DejaVu, Noto) with your test assets and reference it explicitly via
AppConfig.bundled_fonts. Relying on the host's fontconfig produces different glyph metrics on different machines.
Platform notes
- macOS, Windows, X11: native screenshot capture works.
- Wayland: the compositor does not expose other windows' contents to applications. Use the headless backend rather than trying to capture a visible window.
The reftest harness in layout/tests/ is the reference implementation of these patterns.
Coming Up Next
- End-to-End Testing — Driving an Azul app from a script for tests
- Code Generation — How
azul-docregenerates bindings fromapi.json - Web Deployment — Building for the browser via WASM