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¶
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:
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:
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:
--port <n>LUMEN_MCP_PORT[mcp] portinlumen.toml, when you pass--app <dir>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:
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
--tickswith 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:
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.