A headless UI harness for Slint apps. It drives your real app, with all its
Rust wiring, on Slint's testing backend inside a throw-away profile, and collects every problem
into one report instead of stopping at the first: an invariant sweep of the window geometry, a
click-everything smoke pass, an optional coverage gate over your .slint sources, and your own
scenario expectations. Reports are written as JSON plus, by default, Markdown; HTML and plain
text are a builder call or an environment variable away (see Report formats). Exit code 0 means
no findings, 1 findings, 2 an aborted run.
Read this first. Harness::new() must be the first statement of your test's main. The run
redirects the environment (std::env::set_var) into a temporary profile, which is sound only
while the process is single-threaded, so nothing may start a thread before it. On Linux the
harness warns when it finds more than one thread. At the end of the run the environment is
restored the same way, so stop the app's threads in Harness::teardown; on Unix a thread still
reading the environment at that point is a data race. Several runs in one process are fine under
that rule.
Contents: Quick start, Examples, Hooks, Isolation, Aborts, Checks, Report formats, Console output, Parallel runs, Screenshots, Recording Videos (zoom camera and keystroke badges, filmed input, text guard and pacing, element targeting), Coverage gate, Environment variables, Builder reference, Troubleshooting, Limits, Versions and the Slint pin, License.
Five steps; each says what goes wrong when it is skipped. examples/counter is this recipe in
the smallest app; examples/notes is the full one (see Examples).
-
Cargo.toml. The harness is a dev-dependency. The small
uiprobe-hookscrate (std only, no Slint) is an optional normal dependency behind aui-harnessfeature, because code undersrc/can never name a dev-dependency. The test target needsharness = false(the harness ownsmain: panic recorder, exit code, argument filter) andrequired-features, so it is compiled only with the feature on.[features] ui-harness = ["dep:uiprobe-hooks"] [dependencies] slint = "=1.18.1" uiprobe-hooks = { version = "0.1", optional = true } [build-dependencies] slint-build = "=1.18.1" [dev-dependencies] uiprobe = "=0.18.8" [[test]] name = "ui" harness = false required-features = ["ui-harness"]
Skipped
harness = false:cargo test --test uiprintsrunning 0 testsand yourmainnever runs. Skippedrequired-features: with the feature off the UI has no debug info and the run aborts with exit code 2 (the abort names the fix). -
build.rs. Switch Slint debug info on when the feature is on; the harness's element queries need it. Leave the default alone otherwise: it honours
SLINT_EMIT_DEBUG_INFO, so awith_debug_info(false)would switch that path off.fn main() { let mut config = slint_build::CompilerConfiguration::new(); if std::env::var_os("CARGO_FEATURE_UI_HARNESS").is_some() { config = config.with_debug_info(true); } slint_build::compile_with_config("ui/app.slint", config).expect("the UI compiles"); }
Skipped: the first launch aborts with
the window exposes no elementsand the three ways to fix it. With theslint!macro instead of build.rs, setSLINT_EMIT_DEBUG_INFO=1, for example through[env]in.cargo/config.toml. -
tests/ui.rs.
Harness::new()is the first statement ofmain; one.scenario(..)per journey;.run()returns the exit code.use my_app::MainWindow; use uiprobe::prelude::*; fn main() -> ExitCode { Harness::new() .sizes([(1024, 700), (640, 480)]) .scenario("startup", |ctx| { let app = ctx.launch(MainWindow::new)?; ctx.sweep_at_sizes(&app, "startup"); ctx.view(&app).click("Open")?; ctx.shutdown(app); Ok(()) }) .scenario("click everything", |ctx| { ctx.smoke(Smoke::new("startup"), |ctx| Ok(vec![ctx.launch(MainWindow::new)?])); Ok(()) }) .run() }
Scenarios return
Result<(), Finding>: anErr(from?on a failed query, a failed launch ...) is recorded as a finding and the run goes on with the next scenario; a panic in a scenario becomes ascenario-panicfinding. A harness with no scenario is aconfigfinding, so an empty skeleton cannot pass by accident. -
Run it.
cargo test --features ui-harness --test ui. Cargo skips a target whoserequired-featuresare off without a word, so a plaincargo testruns no UI test; in CI usecargo test --all-features, and locally an alias in.cargo/config.tomlhelps:[alias] ui = "test --features ui-harness --test ui"
The first console line names the harness and Slint versions, the selected scenarios and the report directory (
<target>/uiprobe/<package>); a failing run ends with its findings. -
Hooks, as soon as the app has a file dialog, a clipboard, an opener or an OS-resolved data directory: see Hooks below. Without them a click that opens a native dialog opens a real one and blocks the run.
examples/counter: the recipe above in the smallest app (one window, two buttons): a sweep at two sizes, a click journey with an expectation, a click-everything pass.cargo test -p counter --features ui-harness --test ui.examples/notes: a small notes app that uses every seam: file dialogs answered from a queue, clipboard and opener captured, the settings file in the harness's data directory and guarded, an error notice read and cleared by the harness, a scenario deny word and an expected notice in the smoke pass,record_callback, the coverage gate with a TOML allow file, and all three report formats.cargo test -p notes --features ui-harness --test ui, then opentarget/uiprobe/notes/report.html.
Call these only under your feature. Without them everything still works, except that a click which opens a native file dialog opens a real one and blocks the run.
// A file picker: the feature-on arm answers from the queue, the feature-off arm is the real
// dialog. Keep the two arms in one function (or give the feature-off arm a `const fn` twin
// when the function must stay `const`), so every call site is identical in both builds.
fn pick_file(title: &str) -> Option<std::path::PathBuf> {
#[cfg(feature = "ui-harness")]
{
uiprobe_hooks::picker::next(title)
}
#[cfg(not(feature = "ui-harness"))]
{
rfd::FileDialog::new().set_title(title).pick_file()
}
}
// Clipboard and opener: return early when the harness captured the call.
fn copy_text(text: &str) {
#[cfg(feature = "ui-harness")]
if uiprobe_hooks::clipboard::capture(text) {
return;
}
// ... the real clipboard write
}
fn open_url(url: &str) {
#[cfg(feature = "ui-harness")]
if uiprobe_hooks::opener::capture(url) {
return;
}
// ... start the OS opener
}
// Apps that resolve their config directory through the OS (the `dirs` crate on Windows uses
// Known Folders and ignores APPDATA) ask the harness first:
fn data_dir() -> std::path::PathBuf {
#[cfg(feature = "ui-harness")]
if let Some(dir) = uiprobe_hooks::paths::data_dir() {
return dir;
}
// ... the normal lookup
std::path::PathBuf::new()
}In a scenario: ctx.hooks().queue_pick(path) / queue_cancel() answer the next dialog;
take_clipboard_writes(), take_opened() and take_picker_failures() read back what the app
did. A dialog opened with nothing queued answers "cancelled" and is an unexpected-file-dialog
finding.
The same pattern, compiled and driven, is examples/notes/src/lib.rs; the feature-off arms
there are stubs because the example ships no dialog crate.
Profile::isolated() creates <temp>/uiprobe-<pid>-<nanos>, points APPDATA,
LOCALAPPDATA, XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME,
XDG_RUNTIME_DIR, HOME, USERPROFILE, TEMP, TMP and TMPDIR into it, sets
uiprobe_hooks::paths::set_data_dir and moves the working directory into it. Windows Known
Folders are not reachable through the environment: an app that uses them asks
uiprobe_hooks::paths::data_dir (above). Environment redirection alone does not prove your
app obeys it, so hand over the paths the app really resolves:
.profile(Profile::isolated().guard_paths(|| vec![my_app::settings_path(), my_app::database_path()]))Any path outside the profile aborts the run with exit code 2 before a window exists. The profile is deleted at the end, and the environment and working directory are restored.
Exit code 2 ends the run early: a guarded path outside the profile, a Slint platform the app
installed itself (the app must not select a backend when driven by the harness), or a UI without
debug info. In every case the profile is deleted and the environment restored first, and the
message says what to change. A scenario can request the same through
uiprobe::profile::request_abort. No report file is written for an aborted run: the cause
is on stderr, the findings made so far on stdout only.
| Check | Meaning |
|---|---|
outside-window |
an element sticks out of the window (scrolled-out content is accounted for) |
zero-size |
an interactive control has no size |
overlap |
two controls overlap (header and modal backdrops and floating layers are accounted for) |
missing-label |
an interactive control has no accessible label or placeholder |
invisible-but-enabled |
an enabled control is (nearly) transparent |
panic |
a panic on any thread, caught by the recorder |
error-toast |
the app's error or warning notice (ctx.on_error_notice) was showing |
main-closed |
the main window closed during the smoke pass |
window-invalid |
a window could not be queried |
launch |
the app did not start |
timeout |
ctx.wait_for did not see its condition |
scenario-panic |
a scenario panicked; it ended there |
unexpected-file-dialog |
a native dialog opened with no queued answer |
bad-chord |
ctx.press got a key chord it cannot parse |
not-found |
a WindowView::find* or spot_* query matched nothing (similar labels are listed for find*) |
ambiguous-label |
WindowView::find matched several elements, or a spot_* targeting query found several different visible controls |
not-visible |
a spot_* targeting query found the element but less than 60 % of it shows (from TargetError::finding) |
disabled |
a spot_* targeting query found the element but it is not enabled (accessible-enabled: false; from TargetError::finding) |
coverage |
an uncovered UI action, or a stale allow-list entry |
worker-crash |
a parallel-run worker process died during this scenario |
config |
a bad environment variable, an unmatched UIPROBE_FILTER, a harness without scenarios, or a report file that could not be written or removed |
Identical findings are merged with a repeat count. The report lists every scenario with its finding count and wall time.
Every run writes report.json (the harness and Slint versions, findings with repeat counts,
scenarios with timings, notes, sections, per-check totals). Beside it, one file per selected
Format:
| Format | File | For |
|---|---|---|
Format::Markdown (default) |
report.md |
reading and diffing; findings grouped by window, then by check |
Format::Html |
report.html |
one self-contained page: inline style, no script, no external asset; a main landmark and real headings for screen readers, windows collapsible, a light or dark color scheme following the system setting |
Format::Text (raw) |
report.txt |
terminals and CI logs; the Markdown without markup (section bodies, which the coverage gate writes as Markdown, are copied as they are) |
Harness::formats([Format::Markdown, Format::Html]) selects them in code;
UIPROBE_FORMATS=md,html,txt replaces that list (json is accepted and ignored because
it is always written; an empty value means JSON only). A run removes the report.md,
report.html and report.txt it did not select, so the directory always shows what the last
run wrote. The finding texts are the same in every format.
Every line starts with uiprobe. In order: the banner (uiprobe 0.18.8 (Slint 1.18.1): 7 of 7 scenario(s) selected, report dir ...), the profile directory and the guarded
paths, one scenario <name> line per scenario with its -> N finding(s), M ms and a timing:
line (see Timing), the smoke counters, the coverage counters, the report files written, then PASS, no findings or FAIL, N finding(s): with the per-check counts and up to 25 findings as [window] check: element -- detail (xN) [state] (the rest: ... N more in <dir>). Aborts go to stderr as
uiprobe: ABORT: <cause and what to change>. No color, no cursor movement: the output
reads the same in a CI log and in a screen reader.
A run that takes hours with almost no CPU is waiting, and the report says on what. Under each
scenario's -> N finding(s), M ms line the console prints the labels that took the most real
time:
uiprobe: -> 0 finding(s), 51234 ms
uiprobe: timing: relaunch 31.2 s (45x), wait 12.0 s (210x, 3 timed out 9.0 s), settle 4.1 s (80x)
Each label is <name> <total> (<samples>x[, <n> timed out <time>]), sorted by total, at most six.
The backend records three labels by itself: wait (every wait_until, ctx.wait_for; a call that
gave up is a timeout sample), settle (settle is not also counted as wait) and pump (the
real time a direct pump call spent draining events; the pumps inside a wait belong to the
wait). Your own code adds more:
use uiprobe::prelude::*;
timing::record("db-open", elapsed); // one sample
timing::record_timeout("relaunch", elapsed); // a sample that hit its timeout
let app = timing::time("relaunch", || relaunch()); // times a closure
wait_until_labeled("save-done", timeout, || saved()); // a wait with its own labelreport.json stores the table per scenario (scenarios[i].timing: label to count,
total_ms, timeouts, timeout_ms) and the run's totals plus, for a parallel run, the workers
(timing.totals, timing.parallel with workers_started, peak_workers, wall_ms,
scenario_ms, utilisation_percent). The report files get a "Timing" section: the 15 slowest
scenarios by wall time with their three biggest labels, the time per label over the run, the
timeouts per label and, for a parallel run, the workers used and the utilisation (the sum of the
scenario times over peak workers times wall time). The tables are per scenario: samples are
reset when a scenario begins and taken when it ends, so work done between scenarios (the
teardown) is not counted.
Opt-in; the default is the serial run described so far. A suite with many scenarios, long waits
(ctx.wait_for, threads that settle slowly) and a machine with idle cores finishes sooner when
scenarios run side by side. A suite whose scenarios are short and CPU-bound gains little, and
the extra processes cost start-up time.
Harness::new()
.jobs(Jobs::Auto) // or Jobs::Fixed(4); Jobs::Serial is the default
.max_jobs(4) // cap for Auto and Fixed; default: the number of cores
// ... scenarios ...UIPROBE_JOBS=serial|1|auto|<n> replaces jobs and UIPROBE_JOBS_MAX=<n> (at
least 1) replaces max_jobs, so UIPROBE_JOBS=auto cargo test --test ui needs no code
change; an invalid value is a config finding and the configured value is kept.
How it works. Threads are not an option (Slint's platform, the profile's environment redirect
and the hooks are per process), so the test binary becomes a coordinator that starts copies of
itself as worker processes. Each worker has its own throw-away profile, environment, hooks,
panic recorder and mock time, and runs one scenario at a time. Jobs::Fixed(n) starts n
workers at once; Jobs::Auto starts one and adds another every 5 seconds while at least 1.5
cores were idle over the last interval and scenarios are still waiting, up to the cap. The
number of workers never exceeds the number of selected scenarios; one scenario, or Fixed(1),
runs serial.
What stays the same. The console shows each scenario's output in scenario order, not in
completion order; output outside scenarios (worker setup and teardown) is printed as it arrives,
prefixed uiprobe: worker <k>: . The result is one report: findings, repeat counts,
per-check totals and the scenario list are identical to a serial run of the same suite, with one
extra note, parallel run: <n> worker(s) used, peak <p> at once. The coverage gate sees the
union of what all workers reached.
A scenario that needs that union itself (a pass that invokes the callbacks nothing else
covered) is registered with Harness::scenario_last. It runs after every ordinary scenario; in a
parallel run it is dispatched only when all ordinary scenarios are done (workers without work wait
meanwhile) and receives the union of the coverage recorders of all workers, so
coverage::recorded_callbacks() shows what the whole run covered, not what its own worker did.
Several last scenarios run side by side.
Rules for your main. A parallel run re-executes main in every worker, so main must build
the same harnesses in the same order and do nothing before them that must not run twice;
Harness::new() as the first statement is already the rule. A test with several harnesses in
one main works. A worker reports a scenario list that differs from the coordinator's (a
scenario name behind an index does not match) as a config finding for that scenario.
Crashes and aborts. A worker that dies during a scenario (process::abort, panic = "abort", a
stack overflow) gives a worker-crash finding for that scenario, with the exit status and the
worker's last output lines; the other scenarios still run on a replacement worker. The dead
worker's profile directory may be left behind in the temp dir, and the finding says so. A worker
that dies before it is ready three times in a row aborts the run (exit code 2). An abort
requested by a scenario (request_abort) or raised during worker setup stops the whole run: the
other workers finish their current scenario, run the teardown and delete their profiles, then
the run ends with exit code 2.
Keep a suite serial when its numbers are compared against a baseline taken from a serial run
(finding counts do not change, wall times and console interleaving do), or when the machine is
the limit: a GPU-heavy app is slowed by workers sharing one GPU, so cap them with
UIPROBE_JOBS_MAX.
With the screenshots feature (uiprobe = { version = "=0.18.8", features = ["screenshots"] }) a harness can draw its windows with Slint's software renderer and save them as
PNG, for example to keep the screenshots of a README or a website in step with the UI:
Harness::new()
.renderer(Renderer::Software)
.scenario("main window", |ctx| {
let app = ctx.launch(MainWindow::new)?;
screenshot::set_window_size(app.window(), (1920, 1152), 2.0); // HiDPI picture
pump(50);
let shot = screenshot::capture(app.window()).map_err(|e| Finding::simple("shot", e))?;
shot.save_png(std::path::Path::new("assets/main.png"))
.map_err(|e| Finding::simple("shot", e))?;
ctx.shutdown(app);
Ok(())
})
.run()screenshot::capture(window)draws the window and returns aShot(physical pixels, RGBA8); with the defaultRenderer::Mockit returns an error, because the mock renderer draws nothing.Shot::crop(x, y, w, h)(physical pixels),crop_logical(logical window pixels) andcrop_to(&element, margin)cut a part out;save_png(path)writes it through a temporary file, so a failed run never leaves half a picture.Shot::blank,Shot::fillandShot::pastecompose captures (a dialog over its main window: headless windows are separate surfaces).screenshot::set_window_size(window, (w, h), scale)sets the logical size and the scale factor; the picture isw * scalexh * scalepixels.- The software renderer uses the system fonts, so pictures differ between machines; geometry
checks keep using the mock renderer. It draws only when
captureasks, and there is no window frame or title bar in the picture. Pump (orwait_untilthe app's own threads) before a capture so layout, images and animations have settled.
With the recording feature (uiprobe = { version = "=0.18.8", features = ["recording"] }, which
implies screenshots) a harness can play the app and write an MP4 with a drawn mouse cursor, click
ripples, captions and YouTube chapters, for example to keep tutorial videos in step with the UI.
It needs Harness::renderer(Renderer::Software), an even physical window size and ffmpeg at run
time (ffmpeg on PATH, or the full path in UIPROBE_FFMPEG).
use uiprobe::recording::{Recorder, RecorderOptions};
screenshot::set_window_size(app.window(), (1920, 1080), 2.0); // 3840 x 2160 pixels
pump(50);
let mut rec = Recorder::start(app.window(), RecorderOptions {
output: "videos/demo.mp4".into(),
encode_size: Some((1920, 1080)), // scaled down while encoding
..RecorderOptions::default()
})?;
rec.chapter("Intro");
rec.caption("Press the button");
rec.hold(Duration::from_secs(1))?;
rec.move_to_element(&button, Duration::from_millis(700))?; // glide, then a hover event
rec.click()?; // ripple only, nothing is pressed
// invoke the button's callback yourself, then keep filming:
rec.hold(Duration::from_millis(400))?;
let summary = rec.finish()?; // demo.mp4, demo.srt, demo.chapters.txt- Video time and Slint's mock time run together in
hold,move_to,move_to_elementand the zoom calls: every frame pumps one frame of mock time (33, 33, 34 ms at 30 fps), captures the window, draws the cursor over a copy and pipes the frame to ffmpeg.hold(1 s)at 30 fps is exactly 30 frames. Frames are never stored. The exception is the real-time waits (below). move_to/move_to_elementglide with ease-in-out and end with one realPointerMovedevent (hover_events), so hover styles show.clickdraws the ripple only: the action is yours to invoke, so a video never fires twice.- Background work runs in real time.
wait_while_recording(timeout, speed, max_video, cond)shows it as a time-lapse of at mostmax_videoand then cuts;wait_cutshows nothing. caption(text)starts a cue at the current video time (empty text ends it),chapter(title)adds a chapter;finishwrites<stem>.srtand<stem>.chapters.txt(the first chapter is forced to0:00).- The video is written as
<output>.partand renamed byfinish;abandon(or dropping the recorder) kills ffmpeg and deletes the.part, so a failed run leaves the old video alone. An ffmpeg failure is returned with the tail of its stderr. SinkKind::PngSequence(dir)writes numbered PNGs instead (no ffmpeg);SinkKind::Memorykeeps the frames for self-tests (Recorder::frames). The timing labelsrecording-captureandrecording-encodeshow where a slow run spends its time.- Build
RecorderOptions(andCameraOptions,BadgeStyle) with..Default::default(): fields are added in patch releases (0.18.8 added four toRecorderOptions), so a literal that names every field stops compiling. - The recorder borrows the window (
Recorder<'w>): finish or abandon it before shutting the app down, and do not resize the window while recording (a changed size is an error).
Small controls are hard to read in a 1080p video of a 1920 x 1080 layout, so a virtual camera can zoom into the 4K capture (sharp: the crop is cut at the capture's resolution, then scaled to the frame size with a bicubic filter; the output size never changes):
rec.zoom_to((820.0, 300.0, 280.0, 60.0), Duration::from_millis(700))?; // x, y, w, h, logical
rec.set_follow_cursor(true); // a zoomed camera pans with move_to, the cursor stays in view
rec.move_to_element(&field, Duration::from_millis(700))?;
rec.press_keys(app.window(), "Ctrl+Up")?; // real key events, and a "Ctrl + ↑" badge
rec.zoom_out(Duration::from_millis(600))?;zoom_toshows the region padded byCameraOptions::margin(24 logical px), grown to the frame's aspect ratio, limited toCameraOptions::max_zoom(2.0) and kept inside the window.camera()returns the current rectangle. At zoom 1.0 nothing is resampled. The cursor and the ripple are drawn in frame space and grow with the zoom (scale_cursor: falsekeeps them small).show_keys("Ctrl + ↑")draws a keycap badge near the bottom of the frame, not zoomed, forBadgeStyle::visible_ms(1400 ms, fading over the last 300 ms). Keys joined by+get a keycap each. The text is rendered by Slint's software renderer with the system fonts.
click_at(window, at, origin),type_text,scroll_atandpress_keysare the filmed input layer: each glides the cursor, shows the ripple or the badge and dispatches real Slint events to the window. That dispatch is the one activation; do not also invoke the callback (the video would fire it twice).click()stays ripple-only.originis the position of an attached window in the frame ((0.0, 0.0)for the main window).RecorderOptions::forbidden_textswithRecorder::set_text_source(Box::new(|| visible_texts))stops the recording (anErrnaming the fragment) when a visible text contains a fragment, case-insensitively; the half-written video is deleted. The texts are checked at the start and the end of every call that emits frames and at most every 250 ms of video in between, so a text that appears mid-hold can be in the frames (about 7 at 30 fps) before the next check, and one that goes again within an interval can be missed. Fragments without a source fail closed.uiprobe::recording::pacinghasreading_time,glide_duration,plain,wrap,cues,word_countandwheel_stepsfor screencast pacing and captions.wait_while_recordingandwait_cutpump the real time that passed (5 to 100 ms per poll), so an app's timers and tick budgets run at their real speed.RecorderOptions::still_reuse_frames(default 1 = off): still frames of a hold may reuse one full render of the window; with 4 to 8 holds are several times faster (a 4K software render is about 170 ms). The first frame of every call, recorder input, timers, animations andrefreshalways render; a result an app thread posts during a still hold can show up late by at most that many frames, because Slint's testing backend has no redraw signal.captures()andreused_frames()count the renders; the timing labels arerecording-captureandrecording-reuse.
view.find("Save") returns an element; for a click at a position (a recorded journey, a filmed
tutorial) ask for a Spot:
let spot = view.spot_label("Save")?; // Err(TargetError::Ambiguous { count: 2, .. }) if two visible controls say "Save"
let (x, y) = spot.click_point().expect("checked by the query");
Inside a scenario ? turns a TargetError into a Finding of window -; to name the window
write .map_err(|e| e.finding("MainWindow"))?. TargetError is #[non_exhaustive], so a
match on it needs a wildcard arm.
A Spot knows which scroll viewports and which window edge cut its element off. click_point()
is the centre of the part that is visible, and the query is NotVisible when less than 60 % of
the element shows (a row scrolled half out of a table is not clicked at its centre, which lies
outside the viewport). Tell duplicates apart with
spot_role(AccessibleRole::TextInput, "Name"), spot_within("Dialog", "OK") or
spot_label_near_y("Edit", row_y, row_height).
To bring an element into view, plan the wheel turn and perform it where the viewer can see it:
if let Some((container, delta_y)) = uiprobe::targeting::scroll_into_view_plan(&spot) {
recorder.scroll_at(window, container.center(), delta_y)?;
}
// then query again: the container may stop short, an outer one may need its own turn
A Rectangle { clip: true } cannot be detected; add it with Spot::clipped_by.
Opt-in: Harness::coverage(Coverage::scan("ui").allow_file("tests/coverage_allow.toml"))
scans every .slint under ui/ (relative to the test crate's CARGO_MANIFEST_DIR) for
global callbacks and interactive element sites, and at the end of the run turns every one the
scenarios never reached into a coverage finding. A click through WindowView::click or the
smoke pass counts as activated; a find* counts as touched; a scenario that invokes a global
callback directly says so with uiprobe::coverage::record_callback("Model.name").
Keys: Model.name for a callback; file.slint|Component|id, file.slint|Component|"label"
or file.slint|Component|<Type@line> for an element (the report's Coverage section prints
them). An element with neither an id := nor a literal label is untrackable: listed, not a
finding. Allow entries come from code (.allow(&[("Model.name", "why")])) or the TOML file:
[[allow]]
key = "Model.unused"
reason = "driven by the OS tray"
[[allow]]
key = "dialog.slint|Confirm|*" # trailing * = prefix
reason = "covered elsewhere"examples/notes/tests/coverage_allow.toml is a real one.
An entry that matches nothing is a coverage finding too (stale). GateRule says when the
gate runs: NoFilter (default: skipped under a scenario filter), Always, or Armed (only
when a scenario called uiprobe::coverage::arm_gate()), set with .gate_when(..).
An element is credited by, in order: the single-site rule (its label is computed, its element
type has exactly one site in the whole inventory and the run activated or fetched an element of
that type; an app component is also credited by the base names of the activated element), its
Component::id, then a (type, label) pair whose label matches the site's literal. Literals are
compared as the running element shows them: escapes (\", \\, \n, \u{2026}) are resolved,
an interpolation ("Layer \{i}") splits the literal into fixed pieces (at least three fixed
characters are needed), and the label must start and end with them unless the component adds to
the label. An element id behind if cond: or for x in y: is the text after the last colon, and
a one-line element with several literals gives each property its own.
Coverage::label_props(&["reader_label"]): extra property names read as labels, for wrapper components that carry the announced label in a property of their own.Coverage::reader_props(&["reader_label"]): of those, the ones whose literal wins overtexton a wrapper site (the running accessible label is the reader label when the site sets one). Naming a property here also reads it as a label. Both default to empty.Coverage::callback_hint("add a row to the callback table"): the advice at the end of a callback-gap finding (callback never invoked (file:line); <hint>).uiprobe::coverage::record_wrappers(&view.root, &pressed, touched): a wrapper component carries the site id on its own root while the element a scenario presses is the widget inside it; this records the ids of every element at the pressed element's rectangle (0.6 px tolerance). The smoke pass calls it for every activation; call it yourself when a scenario presses a wrapper by hand.uiprobe::coverage::record_site_id("Component::site-id"): credits a site directly, for a press whose element reports another id.
A parallel run carries all of these recorder sets from the workers to the coordinator.
| Variable | Effect |
|---|---|
UIPROBE_FILTER |
comma-separated scenario name substrings to run (positional arguments that do not start with - filter too); positional arguments that match no scenario run nothing and write no report (that is cargo handing another target's filter to this one), an unmatched UIPROBE_FILTER is a config finding |
UIPROBE_SIZES |
1366x768,720x480; replaces Harness::sizes |
UIPROBE_FORMATS |
md,html,txt: the report files besides report.json, which is always written; replaces Harness::formats; json is accepted and ignored, empty means JSON only, an unknown name is a config finding |
UIPROBE_JOBS |
serial, 1, auto or a worker count: how many worker processes run the scenarios (Parallel runs); replaces Harness::jobs; default serial, an invalid value is a config finding |
UIPROBE_JOBS_MAX |
worker cap for auto and a fixed count, at least 1; replaces Harness::max_jobs; default the number of cores, an invalid value is a config finding |
UIPROBE_SEED |
u64 for scenarios that randomise (ctx.seed()), default 0 |
UIPROBE_ALLOW_FAIL |
any value: exit code 0 even with findings |
UIPROBE_FFMPEG |
the ffmpeg executable of the recording feature (default ffmpeg on PATH) |
SLINT_EMIT_DEBUG_INFO |
1 at compile time: Slint debug info |
Exit codes: 0 clean, 1 findings, 2 aborted (isolation could not be proven, no debug info).
report_dir: default<target>/uiprobe/<package>(<target>fromCARGO_TARGET_TMPDIRundercargo test; one folder per package, so two harness tests in one workspace keep their own files).sizes: the window sizesctx.sweep_at_sizesuses.formats: which report files are written besidesreport.json(default[Format::Markdown]); see Report formats.profile:Profile::isolated().name_prefix(..).guard_paths(..).coverage: the opt-in coverage gate (above).jobs/max_jobs: serial (default), a fixed number of worker processes orJobs::Auto, and the cap; see Parallel runs.renderer:Renderer::Mock(default, draws nothing) or, with thescreenshotsfeature,Renderer::Software; see Screenshots.sweep_options(SweepOptions { .. }): the thresholds; the defaults are the Indicatrix ones.floating_layers(default["PopupWindow"]) lists the type names, or base names, of layers drawn above the content on purpose (a toast, a floating panel): a control inside a layer never overlaps a control outside it or in another layer; overlaps inside one layer are still reported.Smokeoptions:deny_rules,skip_blocked,always_restore,allowed_notices,max_controls,before_restoreandscope_may_close(true). The last is for passes over views namedWindow/<scope>(the namesWindowView::subtreeis given by convention): a press that closes the scope is counted inSmokeStats::closedinstead of reported aswindow-invalid. A customSmokeHostmay overrideadmits(&Control)to restrict the pass to some controls (default: all).scenario: one named journey.scenario_last: a journey that runs after allscenarios (whatever the order of the calls), in a parallel run with the union of every worker's coverage; see Parallel runs.teardown: runs after the last scenario and the gate, before the profile is deleted: stop threads, flush files.run/run_to_report: the latter returns theReportfor a test that asserts counts.
ctx.on_error_notice and ctx.clear_notice_with are per scenario. WindowView::find(label)
matches the accessible label or, when no label matches, the accessible placeholder text (a
LineEdit carries only a placeholder); find_id("Component::id") does not depend on text;
find_role, find_nth, labels() and placeholders() complete the set, and a miss is a
not-found finding that lists similar labels and placeholders. uiprobe::target_dir!()
works in any target; integration tests get the compile-time path, others the run-time one.
The prelude (use uiprobe::prelude::*) brings Harness, Ctx, Attached, Hooks,
WindowView, Control, ElementHandle, ElementRoot, AccessibleRole, Finding, Format,
Report, Profile, ProfileDir, Smoke, SmokeStats, DenyRule, DenyField, DenyMatch,
DEFAULT_DENY, SweepOptions, Coverage, GateRule, record_callback, arm_gate,
Jobs, Renderer, SLINT_VERSION, pump, settle, wait_until, wait_until_labeled, the
timing module and std::process::ExitCode. API details are
on docs.rs; recipes (secondary windows, threads, sticky notices, deny lists from data, key
chords, report.json in CI, a custom SmokeHost) are in docs/cookbook.md.
Symptom, cause, fix. Aborts (exit code 2) print the same advice on stderr.
cargo test --test uiprintsrunning 0 tests. The[[test]]lacksharness = false, so libtest ownsmainand yours is dead code. Add it (Quick start, step 1).cargo testruns the unit tests but no UI test, and says nothing. The target hasrequired-features = ["ui-harness"]and the feature is off; cargo skips it silently. Run with--features ui-harness(or--all-featuresin CI), or use thecargo uialias.ABORT: the window exposes no elements. The UI was compiled without Slint debug info. Eitherwith_debug_info(true)in build.rs under the feature and run with the feature on, orSLINT_EMIT_DEBUG_INFO=1while compiling, or[env] SLINT_EMIT_DEBUG_INFO = "1"in.cargo/config.toml. If nothing changes,cargo clean -p <your crate>: theslint!macro is not always re-run when only the environment changed.ABORT: the Slint platform was already set. The code the harness runs selects a backend (BackendSelector::select,slint::platform::set_platform). Keep the backend choice in the binary'smain; the window constructor the scenarios launch must not choose one.ABORT: the guarded path ... is outside the profile. The app resolves that path through something the profile cannot redirect (Windows Known Folders via thedirscrate, a hard-coded path). Under the feature askuiprobe_hooks::paths::data_dir()first (Hooks); a read-only path that may live outside does not belong inguard_paths.no scenario matches the argument filter ...; nothing run, report not written. You rancargo test <name>and cargo handed<name>to the harness binary as a scenario filter. Runcargo test --test ui(no filter), or filter on purpose withUIPROBE_FILTER.config: UIPROBE_FILTER=... matches no scenario. A typo in the filter; the finding lists the scenario names.config: no scenarios. The harness has no.scenario(..).not-found: no element with label "Save"; similar labels: [...]. The label changed, the dialog is not open, or the element is in an invisible subtree (invisible elements are never visited). A text field is found by its placeholder when it has no label;view.labels()andview.placeholders()list what is there;find_id("Component::id")does not depend on text.error-toaston every control after the first one in the smoke pass. The app's notice does not auto-hide and nothing clears it: registerctx.clear_notice_with(..)next toctx.on_error_notice(..)(both per scenario), and list the notices a state is expected to raise inSmoke::allowed_notices.- The run hangs at a click. That control opened a native dialog and the app does not go
through the hooks: add
uiprobe_hooks::picker::nextat the dialog site (Hooks). Until then, deny the control:Smoke::new(..).deny(["import"]). - The run ends at the first panic with no report.
panic = "abort"in the test profile disables catching. Use the defaultunwindfor the profile that runs the UI test. could not delete profile .... A file is still open or an app thread still runs at the end: stop threads and close files inHarness::teardown. On Linux the harness also warns when threads remain.- Linux:
warning: the process has N threads. Something started a thread beforeHarness::runredirected the environment (a logger, a runtime, a static initialiser).Harness::new()must be the first statement ofmainand nothing may spawn beforerun(). - Geometry findings differ between your machine and CI. The testing backend measures text
with the system fonts. Give fixtures
default-font-family: "FixedTestFont"(fixed metrics everywhere); for a real app compare counts on one machine and give a CI box its own baseline or a pinned font package. cargofails withfailed to select a version for slintafter adding the harness. The exact Slint pin (Versions and the Slint pin): pinslint = "=1.18.1"in your app, or use the harness release for your Slint line.- The report landed somewhere unexpected. Run through
cargo test(it setsCARGO_TARGET_TMPDIRandCARGO_PKG_NAME, from which<target>/uiprobe/<package>is derived) or setHarness::report_direxplicitly; the first console line always prints the directory.
- Fonts and CI baselines. The testing backend measures text with the system fonts, so
geometry findings depend on the machine. Only the font family
FixedTestFonthas fixed metrics; give your fixturesdefault-font-family: "FixedTestFont"for exact geometry. For an app under test, compare counts on the same machine, and give a CI box its own baseline (or install a pinned font package). - Scale factor is 1 unless a screenshot or recording run sets it with
screenshot::set_window_size(a HiDPI picture). At 1 logical and physical pixels are the same, so a run that does not call it cannot see DPI problems; the checks measure logical pixels and are not a DPI test either way. - One process. The harness relaunches windows, not the process.
thread_local!state in your app survives a relaunch; guard it with aWeakowner check, or a service stays bound to the first window. panic = "abort"in your test profile disables catching: the run ends at the first panic. The recorder chains to the previous panic hook, so backtraces still print.- The text scanner behind the coverage gate has known limits: component names must start
with a capital,
import { X as Y }renames are not followed,@childrenslots andstatesblocks are opaque, and labels built from expressions are untrackable (allow-list them). The gate is opt-in. - Native pickers without hooks block the run (the dialog is modal). Add the hooks from day one for any app with file dialogs.
- Visibility. Invisible subtrees are never visited;
visibleandclipare not readable through the element API, and there is no way to read a window'smin-width, so sizes are configuration.
uiprobe 0.18.x serves Slint 1.18.x exactly: the harness pins slint and
i-slint-backend-testing with =1.18.1, because the testing backend is an internal Slint
crate without semver that pins i-slint-core exactly. uiprobe::SLINT_VERSION holds the
version, the first console line prints it, and report.json carries it (harness, slint).
What that means for your app: its slint must resolve to the same 1.18.1. Cargo allows one
version of a 1.x crate per build, so a requirement that cannot be 1.18.1 does not build two
copies, it fails to resolve (failed to select a version for slint, or for uiprobe).
Two ways out: pin slint = "=1.18.1" in your app, or move to the harness release that serves
your Slint line (one harness minor per Slint patch line). cargo tree -i i-slint-core -e normal,dev shows who pulls which version. A git or path Slint can still put two
i-slint-cores into one build; the testing platform then lives in the wrong one and the first
launch fails or aborts; cargo tree -d shows the duplicate.
uiprobe-hooks has its own 0.1.x line and no Slint dependency. Minimum Rust: 1.92
(Slint's).
MIT.