Skip to content

Testing an app

Lumen apps can run without a window and be driven from the command line. That gives you two things: a way to check that an app starts and stays up on a machine with no display, and a way to click through it from a script.

Run without a window

lumenc run myapp --headless

This is not a reduced mode. Layout runs, the app's renderer draws, scripts execute, and screenshots come out pixel-identical to the windowed path. The window is missing, so no compositor is touched and this is safe on a build machine and safe to run while you are working.

Hot reload is off: a headless run watches no files, so an edit shows up only after a restart. Set hot_reload = true under [runtime] in lumen.toml to turn it on for a headless run.

A machine with no GPU still renders. Under the default [render] backend = "auto", a run that finds no GPU renderer falls back to the CPU one and says so; set backend = "cpu" to render on the CPU every time, which gives the same pixels on every machine whatever its graphics hardware. A GPU run on a software rasterizer (Mesa's lavapipe on Linux, Direct3D's WARP on Windows) draws close to a GPU's pixels but not the same ones.

Hardware nobody is there to use is left alone too: an app that declares the audio module still gets the audio_* functions, the playback state, and the position signals, but no output device is opened, so a headless run never takes the machine's audio endpoint from whatever else is using it.

Ticks happen on demand. The app ticks when something asks it to (an animation, a pending change, a script timer coming due, an incoming command, an HTTP reply or a module event arriving) and parks otherwise, so an idle headless app costs nothing.

Flag Effect
--size WxH Logical viewport size. Defaults to [window] size, then 960x720.
--dpr N Scales the render target. Screenshot pixels are the logical size times this. Defaults to 1.
--ticks N Runs exactly N ticks back to back, then exits 0.

Ctrl+C and SIGTERM exit 0 through the same graceful-close path an ordinary window close takes, so close handlers still fire.

A smoke test

The shortest useful check is that the app builds its tree and ticks:

lumenc run myapp --headless --ticks 5

It exits 0 on success and non-zero on a parse error, a missing asset, or a script that fails to compile. For a parse check alone, lumenc check myapp is faster and opens nothing.

Drive a running app

A running app can answer questions about its own UI and accept input, over a local TCP port. lumenc has a subcommand for reading the tree, sending input, taking a screenshot, and listing lint findings. The port carries more than that, signal reads and writes among them; those go through the MCP bridge rather than the shell. See Tooling.

Reading the UI needs nothing but a running app. Injecting input is opt in:

[mcp]
simulate = true

Without that key, click, type, key, and scroll refuse to run and say so. It also matters for headless runs specifically: a headless app turns the server off unless simulate = true is set, because a plain --ticks run has nobody to talk to.

Reading the UI

lumenc snapshot prints the live tree, one entity per line, with id, role, label, position, size, and state:

$ lumenc snapshot
# 11 entities (11 shown; 11 total in snapshot)
4294967202  node                                          0,0     960x720  -
4294967201    scroll                                      0,0     960x504  -
4294967200      text     "Tile 1"                         4,8     960x80   T

lumenc find searches that tree by text, role, or id, and exits non-zero when nothing matches, which makes it usable as an assertion by itself. lumenc element-at x y answers what is under a point.

Sending input

lumenc click x y clicks at a logical-pixel point and reports how many frames the app took to settle. lumenc type, lumenc key, and lumenc scroll cover text, key presses, and the wheel. Each accepts --wait-for <ring> to block until the app records the matching event, for example lumenc click 40 20 --wait-for ClickEvent.

Pictures and findings

lumenc screenshot out.png writes a PNG. --highlight outlines specific entity ids and --lint outlines everything the linter flagged, which turns a screenshot into a readable bug report. lumenc lint prints those findings as text and exits non-zero on an error-severity one. lumenc diff reports what changed since a given tick.

Which port

Each command resolves its port in this order:

  1. --port <n>
  2. LUMEN_MCP_PORT
  3. [mcp] port in lumen.toml, when you pass --app <dir>
  4. 7878

Pass --app myapp when the app sets its own port, so both sides agree without you repeating the number. Give each app its own port if you run more than one at a time.

A test in CI

Start the app, wait for it to come up, drive it, assert, and stop it. On a GitHub Actions runner, install the toolchain first. Put the port and the simulate key in lumen.toml:

[mcp]
port = 7999
simulate = true

Then:

#!/bin/sh
set -e

lumenc run myapp --headless &
APP=$!
trap 'kill $APP' EXIT

# Wait for the app to answer.
until lumenc snapshot --app myapp >/dev/null 2>&1; do sleep 0.2; done

# The button exists. `find` exits non-zero with no matches.
lumenc find --text "Add" --app myapp

# Click it, then assert the label changed.
lumenc click 100 550 --app myapp --wait-for ClickEvent
lumenc find --text "1 item" --app myapp

# Keep a picture of the failure if there is one.
lumenc screenshot result.png --app myapp

Every command exits 0 on success and non-zero on failure, so set -e is the whole assertion mechanism. --json on snapshot, find, element-at, lint, and diff gives you machine-readable output when you want to check something a shell cannot.

Two things to watch for:

  • Do not combine --ticks with the automation commands. A bounded run exits as soon as it hits the tick count, and your driver will find nothing to talk to. Leave the run unbounded and stop it yourself.
  • Poll for readiness rather than sleeping a fixed time. Startup includes the renderer's bring-up and a font scan, and both vary by machine.

Static checks

Three checks need no running app and belong in the same CI job:

lumenc check myapp
lumenc fmt myapp/src/main.lmn --check
lumenc lint --signals myapp --strict

check parses the app. fmt --check fails when the markup is not formatted and rewrites nothing. lint --signals reads every page and script of the app and the optional [signals] schema, and reports untyped writes, schema mismatches, ambiguous {name} interpolation, signals bound in markup that no schema entry or script covers, and signals a script writes that nothing reads. --strict turns its warnings into failures.

lumenc lint --css-cascade myapp is worth running once when you inherit an older stylesheet: it reports rules whose resolved value depends on cascade ordering.

Full flag lists for all of these are in the CLI reference.