Headless UI driver for LUI applications — mount a real LUI app (view + reducer), replay the emitted patch ops into a queryable node tree, inject host events, and assert on the UI state. No platform host required; tests run in microseconds.
Three ways to drive:
- In-process (
Drive.Session): mount an app against a recording backend inside your test suite. Events go throughLui_app.dispatch_event— the same path a native host uses. - FFI (
Drive.Ffi/ thedriveCLI): dlopen a shared library exporting thelui_ocaml_*bridge ABI (the same ABI the SwiftUI host loads, e.g.libmeng_editor.sobuilt viaocamlopt -output-complete-obj). Patches stream back as wire JSON and are replayed into the sameModel. - Live (
Drive.Live): attach over Unix socket, loopback TCP or WebSocket. Use--input semantic(default) for injected LUI events, or--input nativefor capability-checked, acknowledged host input. Native input never falls back to semantic injection.
press kind:button&text:Increment # selectors: id:, kind:, ext:, text:, prop:n=v, &-conjuncts
type kind:text-field "hello" # TextChanged
key "cmd+p" # ExtensionEvent on ext:key-surface
expect text:count=2 # assert node exists
expect-absent kind:dialog # assert node absent
wait ext:code-editor 10 # poll until match (seconds)
dismiss kind:dialog
toggle kind:toggle true
change kind:radio # Change (radio/select on_change)
value kind:slider 0.5
ext ext:web-view web-view navigated '{"url":"https://x"}'
tap 142 36 # coordinate press (live attach only)
sleep 0.2
dump # print the node tree
dump-frames # print host-reported node frames
poll # drain async actions once
drive --lib ./libmeng_editor.so --os macos --host swiftui \
--env MENG_ROOT=/path/to/workspace scenario.drive
--os/--host select the backend profile the target is told to use
(any LUI profile — generic, macOS/SwiftUI, iOS); they do not launch a
simulator or implement native input. Failures print line
numbers; exit code is non-zero on any failure.
let s =
Drive.Session.mount ~profile:Drive.profile_generic
~initial:Model.initial ~reducer:Update.reducer ~view:View.view ()
let d = Drive.Session.driver s
let btn = Drive.Session.resolve d (All [Kind "button"; Text "Save"])
let () = Drive.Session.press s btn.id
let () = assert (Drive.Model.exists s.tree (Text "saved"))For apps with async effects (plugin hosts, LSP sessions), pass
~drain to mount — a function returning pending actions that drive
sends into the reducer whenever the session is polled (after every
event and during wait).
Events are in-memory dispatches, assertions are tree lookups. The whole
suite in test/ (mount + press + type + dialog + a scripted scenario)
runs in ~1ms.
Set MENG_DRIVE_SOCKET=/path.sock on the app before start; the app
opens a Unix socket that replays every patch batch emitted since boot
(one JSON object per line — the same stream the native renderer gets)
and accepts injected events as newline JSON. Then:
drive --socket /path.sock scenario.drive
Event lines look like {"event":"press","id":37},
{"event":"text","id":22,"value":"hi"},
{"event":"ext","id":24,"ident":"web-view","name":"navigate","fields":{"url":"..."}}.
Because the scenario drives the live process, the real UI visibly
responds, but semantic events do not exercise native hit testing,
gesture recognition, or focus handling.
Native test hosts can expose the same newline-delimited JSON protocol on a loopback TCP listener. This also works across an explicit device port forward:
adb -s "$DEVICE" forward tcp:49377 tcp:49377
drive --tcp 127.0.0.1:49377 --input native scenario.drive
adb -s "$DEVICE" forward --remove tcp:49377Start a compatible test host on the device first; port forwarding alone does
not provide native input. --tcp accepts only 127.0.0.1, localhost
(mapped directly to IPv4 loopback), or [::1], with a port from 1 to 65535.
No DNS lookup or external interface is allowed. It is mutually exclusive with
--lib, --socket and --ws-listen. There is no reconnect/retry: a timed-out
action may already have run. Close the host and start a new test session.
Embedding callers use Drive.Live.connect_tcp ~host:"127.0.0.1" ~port:49377,
then Drive.Live.driver ~timeout:5.0 and Drive.Live.close, just as for Unix
socket attach. No native capability is implied by the transport: the host's
v1 handshake remains mandatory.
In semantic mode, selector events target a node id directly; tap x y
instead resolves the node at a point in drive's model, not in the OS.
Hosts that support it interleave a frames snapshot on the same
socket:
{"frames":[[id,x,y,w,h], ...]} # window coords, top-left origin,
# full snapshot (replaces the table)
A tap resolves the deepest node containing the point, then walks
ancestors to the first press-enabled node or button (an
enabled=false hit swallows the tap, like a real disabled control), and
emits a normal press for it. tap <sel> is shorthand for tapping the
center of the node's reported frame — so dump-frames + tap needs no
hand-picked coordinates. Works on any host that reports frames — SwiftUI
and Flutter adapters can implement this contract. In-process and FFI
drivers reject both tap forms. tap <sel> without a frame now fails;
use press <sel> when semantic injection is intended.
In native mode, both forms are sent to the host's input system. Selector clicks use fresh host geometry, not cached frame-based semantic dispatch.
Browser pages can only dial out, so for web hosts the direction inverts: drive listens, the page connects.
drive --ws-listen 9222 scenario.drive
then open the host page with ?drive=ws://127.0.0.1:9222 (or call
LuiDrive.attach(url) from web/lui-drive.js). The page speaks the
same line protocol over a WebSocket — one text message per JSON line.
web/lui-drive.js is the reference host adapter: elements tagged
data-lui-node-id/data-lui-kind are reported as the node tree,
getBoundingClientRect() feeds the frames snapshots, and incoming
events are injected synthetically (press → el.click()). This adapter
advertises no native capabilities; it is not trusted browser input. Everything is
gated on a live connection — without ?drive= the file registers no
observers, listeners, or timers, so it costs nothing on a page that
isn't being driven. See web/demo.html for a runnable target.
Requires Node.js 22+ and a Chromium instance exposing a loopback-only
CDP HTTP endpoint. Launch an isolated browser profile, serve web/ locally,
and open demo.html without ?drive=. Then, in separate terminals:
drive --ws-listen 9223 --input native scenario.drive
node web/cdp-host.mjs --cdp http://127.0.0.1:9222 \
--url http://127.0.0.1:8000/demo.html --drive ws://127.0.0.1:9223--url must identify exactly one open tab. Enable data-lui-node-id tagging
in the application before attaching. The CDP adapter injects the shared
snapshot reader; the page need not load lui-drive.js itself.
The same press, tap, type, and key scenario commands now use
Input.dispatchMouseEvent, Input.dispatchKeyEvent, and Input.insertText.
type replaces the editable value through focus, Select All, and input,
not assignment to input.value. Additional native commands:
press testid:capture
type testid:editor "hello"
key "mod+a"
scroll testid:list 0 300
drag testid:source testid:target
settle
screenshot result.png
Actions resolve exactly one node; testid: maps to LUI's
accessibility-identifier, and label: matches its exact label. For Web,
test IDs come from data-testid, data-lui-accessibility-identifier, or
the DOM id; labels come from data-lui-label or aria-label.
Existence assertions still permit multiple matches.
Chromium selector actions scroll into view, check enabled state and
occlusion, and use actual browser input. The host waits for 100ms of
DOM/geometry quiet and finite Web Animations (at most 2s), publishes the
latest tree/frames, then acknowledges the action. This is not network
idle or application-task completion: use wait for async business state.
--action-timeout SECONDS controls the driver deadline (default 5s).
Native scenarios stop at the first failure.
| Host | Semantic input | Native input |
|---|---|---|
| In-process / FFI | Yes | No |
web/lui-drive.js |
Synthetic DOM events | No |
web/cdp-host.mjs |
No | Chromium mouse, text, keys, wheel, pointer drag, PNG |
| Android | Existing host-specific semantic attach | Optional LUI debug instrumentation host, API 30+; device input unverified |
| iOS | Existing host-specific semantic attach | Separate XCTest runner + DEBUG LUI read-only probe; runner compiles, device input unverified |
| MyGo | Existing host-specific semantic attach | Unsupported: the pinned SDK has no public live-window input dispatcher |
Android's optional host lives in LUI, under
platform/android/drive; its sample launcher explicitly preserves/restores a
test-device IME and TCP forwarding. Text requires deliberately selecting the
test IME; the SDK never changes it automatically. Compilation and protocol
tests are not evidence of actual device input. Unsupported geometry/input fails
instead of falling back to semantic events.
The iOS runner is a separate UI-testing target, not app code. It uses public XCTest input and reads the app through an explicitly enabled DEBUG probe; stable native accessibility identifiers are required. The standalone generated project compiles the runner only, not the app under test. Follow the Apple guide for build/install steps and public XCTest limits. Neither mobile adapter implies MyGo support or universal animation/network idle.
CDP currently targets tagged light DOM, not iframes or shadow roots.
Drag is a pointer gesture, not a promised HTML5 drag-and-drop operation;
both endpoints must fit in one viewport. Long press holds the primary
mouse button for 600ms. Supported keys are letters, digits, Enter, Tab,
Escape, Backspace, Delete, Space, arrows, Home/End and PageUp/PageDown;
combine with mod, cmd/meta, ctrl, alt and shift. Use type for
arbitrary text. Semantic-only commands (ext, toggle, value, submit,
dismiss, appear, change, goto) explicitly fail in native mode.
See protocol and API migrations before updating a custom driver or implementing a native host.