Headless and Automation API

The headless API is exposed as shady.automation from the Lua runtime. It is intended for compositor regression tests and deterministic interaction scripts.

A typical headless test runs Shady with a headless wlroots backend:

export WLR_BACKENDS=headless
export WLR_HEADLESS_OUTPUTS=1
export WLR_RENDERER=gles2
export LIBGL_ALWAYS_SOFTWARE=1
export SHADY_LUA_INIT="$PWD/tests/my-test.lua"

./build/shady -c tests/my-test-config.lua

Timers

shady.automation.after(ms, callback)

Run callback once after ms milliseconds.

shady.automation.after(200, function()
    shady.log("timer fired")
end)

ms must be non-negative.

Keyboard

shady.automation.key(shortcut) -> boolean

Press and release a key combination.

assert(shady.automation.key("Super+Tab"))

shady.automation.key_down(shortcut) -> boolean

Send only the press state.

shady.automation.key_up(shortcut) -> boolean

Send only the release state.

The shortcut syntax is the same syntax used by shady.bind.

Text input

shady.automation.type_text(text) -> pid | nil, error

Spawns wtype to type text into the focused Wayland client.

local pid, err = shady.automation.type_text("hello")
assert(pid, err)

This requires wtype in PATH.

Pointer

shady.automation.move_pointer(x, y)

Move the compositor pointer to logical layout coordinates.

shady.automation.click(button)

Press and release a mouse button. button can be "left", "right", "middle", or a numeric Linux input button code.

shady.automation.drag(x1, y1, x2, y2 [, button [, steps]])

Move to the start point, press the button, interpolate to the end point, then release it.

shady.automation.drag(100, 100, 260, 180, "left", 12)

Default button: left. Default steps: 8. Valid step count: 1..256.

shady.automation.scroll([vertical [, horizontal]])

Send pointer-axis scroll events.

shady.automation.scroll(-15, 0)

Screenshots

shady.automation.screenshot(path [, callback]) -> pid | nil, error

Captures the first available output using grim.

Without a callback:

local pid, err = shady.automation.screenshot("/tmp/shady.png")
assert(pid, err)

With a callback:

shady.automation.screenshot("/tmp/shady.png", function(ok, path)
    assert(ok, "screenshot failed")
    shady.log("saved " .. path)
end)

This requires grim in PATH.

Prefer waiting for compositor events instead of fixed startup sleeps.

local started = false

shady.on("window.mapped", function(window)
    if started or window.app_id ~= "my-test-app" then
        return
    end
    started = true

    shady.automation.after(100, function()
        local x0, y0 = window.x, window.y
        shady.automation.drag(x0 + 40, y0 - 14,
                              x0 + 140, y0 + 36,
                              "left", 8)

        shady.automation.after(80, function()
            assert(window.x ~= x0 or window.y ~= y0)
            shady.quit()
        end)
    end)
end)

Probe client

The repository builds headless-automation-probe for test scenarios that need a small xdg-toplevel with controlled app ID/title.

Existing tests under tests/headless-*.lua are useful reference implementations.

Notes

Automation coordinates are compositor logical coordinates, not raw buffer pixels.

The automation API injects events directly into Shady where possible. type_text and screenshots intentionally use external Wayland tools.

For spatial tests, visually transformed 3D geometry may not line up with simple 2D coordinates. Prefer tests that use known screen-space states or coordinates derived from the rendered geometry.