Headless smoke testing — proving a window rendered with no one watching

A raylib example opens a window and runs until you close it. That's fine for a human, useless for CI or an agent: nothing here can click a close button, and a test that blocks forever is worse than no test. Two environment variables turn every windowed example into something a machine can drive and verify — without changing a line of the example itself.

The two knobs

Both are read in net.b12n.raylib-jlt.raylib and consumed by the shared loop guards, so every example inherits them for free.

  • RAYLIB_APP_AUTO_QUIT_MS=<n> — close the window after n milliseconds. The example runs, renders real frames, then exits on its own.
  • RAYLIB_APP_SHOT=<name> — dump one frame to a PNG. Headless visual proof that a frame actually rendered, not just that the process didn't crash.

Auto-quit: a deadline the loop checks

auto-quit-deadline turns the env var into an absolute wall-clock deadline (or nil); keep-running? ANDs it with raylib's own close signal:

;; src/net/b12n/raylib_jlt/raylib.clj
(defn auto-quit-deadline []
  (when-let [v (System/getenv "RAYLIB_APP_AUTO_QUIT_MS")]
    (try (let [ms (Integer/parseInt v)]
           (when (pos? ms) (+ (System/currentTimeMillis) ms)))
         (catch Exception _ nil))))

(defn keep-running? [deadline]
  (and (not (window-should-close?))
       (or (nil? deadline) (< (System/currentTimeMillis) deadline))))

With the var unset, deadline is nil and keep-running? reduces to "while the window is open" — normal interactive behavior. Set it, and the loop ends on its own.

The canonical loop

Every example is the same shape (net.b12n.raylib-jlt.core, the basic window):

(defn -main [& _]
  (rl/window! :width 800 :height 450 :title "raylib [core] example - basic window")
  (rl/set-target-fps 60)
  (let [deadline (rl/auto-quit-deadline)]
    (loop [frame 0]
      (when (rl/keep-running? deadline)
        (rl/begin-drawing)
        (rl/clear-background rl/RAYWHITE)
        (rl/text! "Congrats! You created your first window!" :x 190 :y 200 …)
        (rl/maybe-screenshot! frame 10)
        (rl/end-drawing)
        (recur (inc frame)))))
  (rl/close-window))

frame is threaded through the loop so maybe-screenshot! can fire on a specific frame (here frame 10 — a few frames in, so the first render has settled).

Screenshot: flush the batch first, or you get an empty PNG

The subtle part. raylib batches geometry — DrawText, shapes, everything — and doesn't actually submit it to the framebuffer until EndDrawing. A naive TakeScreenshot mid-frame would capture the framebuffer before this frame's geometry lands, producing a blank or stale image. maybe-screenshot! flushes the active render batch first:

(defn maybe-screenshot! [frame at]
  (when (and shot-path (= frame at))
    (flush-batch)                 ; rlDrawRenderBatchActive — submit deferred geometry
    (take-screenshot shot-path)
    (binding [*out* *err*] (println "[net.b12n.raylib-jlt] SHOT" shot-path))))

Note: raylib writes the file's basename into the current working directory — it ignores any directory component of the path. So RAYLIB_APP_SHOT=out/x.png still writes x.png in CWD. Plan for that when collecting artifacts.

flowchart LR
  d["DrawText / shapes<br/>(batched, deferred)"] --> f["flush-batch<br/>rlDrawRenderBatchActive"]
  f --> t["TakeScreenshot(name)"]
  t --> p["name.png in CWD<br/>(real frame, not blank)"]

Driving it

One example, auto-quit + shot:

RAYLIB_APP_AUTO_QUIT_MS=2000 RAYLIB_APP_SHOT=shot.png joltc -M:run
# opens, renders ~2s, writes shot.png, exits

The whole suite as a demo reel / smoke test — bb run-all sets RAYLIB_APP_AUTO_QUIT_MS per example so each runs N seconds then advances:

bb run-all 3     # every example, 3s each, unattended

And the display-free check that belongs in CI — it compiles every example namespace without opening a window at all:

joltc -M:check   # "net.b12n.raylib-jlt: all example namespaces compiled OK"
bb check         # same, via babashka

One environment caveat

The screenshot path needs an active display — raylib/GLFW initializes a real GL context. On a Mac whose display has slept, window creation can fail with "Failed to determine Monitor" and then crash. joltc -M:check needs no display and always works; RAYLIB_APP_SHOT needs a live (awake) display.

A C-truthiness footnote

raylib's boolean-returning functions (WindowShouldClose, IsKeyDown) return a C int whose truth lives in the low byte; the upper bytes are unspecified. The predicates mask before testing so a dirty high byte can't read as "true":

(defn window-should-close? [] (not (zero? (bit-and (should-close-raw) 0xff))))
(defn key-down? [k] (not (zero? (bit-and (key-down-raw k) 0xff))))

See also