Harness + headless mode

imgui/imgui_harness is the canonical wrapper for dasImgui examples and tests. It hides the GLFW/OpenGL backend boilerplate behind five helpers and adds a --headless CLI mode that runs the same script with no window and no GL context — for CI, smoke tests, and Playwright drivers without a display server.

This tutorial covers what --headless does to the harness internals, how to launch a script in either mode, and the limits of headless (screenshot / record_* and the live-API HTTP endpoint stay windowed-only).

Why headless

A dasImgui test today opens a real GLFW window and a real OpenGL context. That makes CI runners pick a display server (xvfb on Linux, an interactive session on Windows), and pins every smoke test to the cost of a windowed run. Most tests don’t need either — they just exercise widget logic, the boost layer, or live-command dispatch.

The harness keeps the same script source but lets the runtime pick:

  • Windowed (default) — open a window, run the GLFW + OpenGL chain, render to the back buffer. Identical to the pre-harness path.

  • Headless (-- --headless) — create an ImGui context with a CPU-only font atlas, no window, no GL. Render() runs and the resulting ImDrawData is discarded.

Running tests headless

The harness parses one CLI flag, lazily on first harness_* call:

# Windowed (default).
daslang.exe modules/dasImgui/examples/features/foundation.das

# Headless. Note the `--` separator: daslang's interpreter mode
# consumes everything after it as user-script argv.
daslang.exe modules/dasImgui/examples/features/foundation.das -- --headless

# Headless with a 600-frame auto-exit cap (useful for unattended CI
# smokes that have no out-of-band exit signal).
daslang.exe modules/dasImgui/examples/features/foundation.das -- --headless --headless-frames=600

In windowed mode the script’s loop terminates when the window closes (live_begin_frame calls request_exit()). In headless there is no window, so the script runs until either (a) something inside the script calls request_exit() or (b) --headless-frames=N is set and the counter hits N (the harness then calls request_exit() itself).

What –headless does internally

The harness exports five helpers; each branches on harness_is_headless() at call time:

harness_init(title, width, height)
  • Windowed: live_create_window + live_imgui_init.

  • Headless: CreateContext + StyleColorsDark + CPU font-atlas build + DisplaySize. No window, no GL context.

harness_begin_frame() : bool
  • Windowed: poll GLFW events, run boost begin_frame, then ImGui_ImplOpenGL3_NewFrame + ImGui_ImplGlfw_NewFrame.

  • Headless: feed ImGuiIO::DeltaTime from real wall-clock, run boost begin_frame, advance the headless frame counter. If --headless-frames=N is set and the counter reaches N, call request_exit() and return false (the script’s loop exits).

harness_apply_synth_io() (optional)

Drains the synth IO timeline (imgui_mouse_play, imgui_key_chord, …). Identical in both modes — windowed needs it to win the GLFW backend’s per-frame poll race; headless needs it because it’s the only IO source. Call between harness_begin_frame and harness_new_frame.

harness_new_frame()

ImGui::NewFrame(). Identical in both modes.

harness_end_frame()
  • Windowed: boost end_of_frame + Render + viewport/clear + ImGui_ImplOpenGL3_RenderDrawData + live_end_frame (swap buffers).

  • Headless: end_of_frame + Render. The resulting ImDrawData is discarded — there is no GL backend to consume it.

harness_shutdown()
  • Windowed: live_imgui_shutdown + live_destroy_window.

  • Headless: DestroyContext on the harness-owned ImGui context.

The C++ side uses two side-by-side shared modules:

  • imguiApp.shared_module — the windowed backend (GLFW + OpenGL, plus the imgui_synth_* event injectors that drive synth IO in either mode).

  • imguiAppHeadless.shared_module — three thin helpers (imgui_headless_init_fonts, imgui_headless_set_display_size, imgui_headless_advance_dt). No GLFW, no OpenGL, no gl3w. Loaded by the harness alongside imguiApp so runtime dispatch can pick.

Both DLLs link into every dasImgui binary, so headless still requires GLFW libs to be findable on the host (the OS DLL loader resolves imguiApp.shared_module’s deps regardless of whether --headless is active). Truly minimal-deploy headless (no GLFW libs at all) is a future build-flag concern.

Default-on lint: HARNESS001

Bundled with the harness (require imgui/imgui_harness_lint public from imgui_harness.das) is a default-on [lint_macro] that fires whenever a file requiring imgui/imgui_harness reaches into the windowed-backend modules directly. Forbidden modules:

  • glfw_boost / opengl_boost

  • glfw_live / opengl_live

  • imgui_live

The structural private-require gate in imgui_harness.das already hides those modules from harness consumers, so a clean file never trips the lint. The lint is the second line of defense — it catches files that explicitly re-add a backend require to bypass the gate. Diagnostic code is HARNESS001 (macro_error 50503).

Per-file escape, for the rare windowed-only test that needs a backend symbol directly (screenshot pipelines, custom GL clears, etc.):

options _allow_glfw_calls = true

This is scaffolding only. The target end-state for the migration is no opt-outs anywhere in examples/; the option will be removed from the registry once every harness-using file is clean. Same pattern as the _allow_imgui_legacy opt-out for imgui_lint.

Limits under –headless

  • screenshot and record_start / record_stop from opengl/opengl_live assume a GL framebuffer. They have no headless analogue and either no-op or panic depending on the path. Tests that capture screenshots or APNGs stay windowed.

  • The live-API HTTP endpoint at localhost:9090/command is installed by the daslang-live host. daslang.exe (the interpreter) runs the script without the HTTP server, so Playwright drivers that send JSON commands over HTTP need daslang-live — but the host runs the HTTP stack in either mode (windowed or headless), so the playwright flow (/status + /command + /shutdown) works under --headless too.

daslang-live forwards everything after its own -- separator to the script’s get_user_args() (daslang PR #2681), so daslang-live FILE.das -- --headless reaches harness_is_headless() inside the live-reload session. imgui_playwright forwards the flag from its own user-args to every spawned subprocess, so a single --headless on the dastest command line propagates through: daslang dastest.das -- --test integration --headless.

Tests that need the windowed-only surfaces (screenshot / record_*) opt out of the harness lint per-file (options _allow_glfw_calls = true) and run windowed regardless of the --headless flag — harness_is_headless() is a hint, not a hard switch.