NativeDesktop

Testing

The three test tiers, goldens, real input, the nd-test locators, the MCP bridge, and how nd test runs them.

Example: Hello. The smallest app: a toolbar button, a count and window controls placed by coordinates. Source in examples/hello. It also runs live in the docs app.
Hello on macOS

Captured on macOS 27.0, 2026-10-22.

Hello on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Hello on Windows

Captured on Windows 11 26H2, 2026-10-22.

Every backend builds one semantic tree: role, name, value, test id, bounds, enabled, focused, checked, selected, and expanded. Tests read and act on that tree, never on pixels or the OS accessibility API, so a locator that you write in tier 1 reads the same against a real window in tier 3.

TierWhat runsNeeds
1Logic on TestBackend, no OSNothing; also runs in headless Chromium
2In-process real backends: audits and goldensA GPU or the window server for AppKit
3The app as its own process, real inputA window server (a headless Hyprland on Linux)

For the rules that an agent follows when it writes tests, see for agents.

Tier 1: logic

#[nd::test] runs a test on TestBackend, which records backend ops and has a deterministic clock. The same test compiles for wasm32-unknown-unknown and runs in headless Chromium (just web-test).

#[nd::test]
fn starts_at_zero(cx: &mut TestApp) {
    let window = hello::open(cx).id();
    assert!(text(cx, window).contains("0 clicks"), "{}", text(cx, window));
}

#[nd::test]
fn add_one_counts_each_click(cx: &mut TestApp) {
    let window = hello::open(cx).id();
    click(cx, window, "add");
    click(cx, window, "add");
    assert!(text(cx, window).contains("2 clicks"), "{}", text(cx, window));
}

From examples/hello/tests/tier1.rs, line 7.

Locators work in tier 1 through nd_test::TestHarness, which wraps a TestApp and runs requests through the same handler the automation socket uses (see crates/nd-test).

Tier 2: backends and goldens

Tier 2 renders with a real backend inside the test process. Three kinds of check live here.

The appearance audit is covered below. Goldens compare a screenshot with a PNG in the example's tests/goldens/. A mismatch past the tolerance writes the difference beside the golden (web-light.diff.png, or <golden>.actual.png) and fails. To re-record, set one of these variables:

let bless = std::env::var_os("ND_BLESS").is_some_and(|v| v == "1");

From examples/hello/tests/web_golden.rs, line 93.

The third kind is the AppKit conformance tests. These are harness = false binaries, one process per test, that open offscreen windows and check each component's native mapping (an NSMenu, an NSTableView). Glass appears only in tier 3 captures.

You can record Fluent goldens only on Windows. None are recorded yet.

Appearance audit

nd::audit::Audit renders every window that you name in light, dark, and two matugen themes on each backend that the target links. It reads back every text run and fill that was painted. It fails when a color isn't a theme token (or a state derived from one), or when text has less than WCAG contrast against what it sits on: 4.5:1, or 3:1 for large text. Each backend is its own test, such as appearance_audit::appkit. Matugen themes run on GPUI only.

fn main() {
    nd::audit::Audit::new("hello", hello::app).window("main", |cx| hello::open(cx).id()).run()
}

From examples/hello/tests/appearance.rs, line 2.

Declare the test target with harness = false in Cargo.toml. Audit::allow lists brand colors that the theme names on purpose. The audit still checks their contrast. nd check runs the audit for every package that has tests/appearance.rs.

Tier 3: real input

Tier 3 starts the app as its own process with NATIVE_AUTOMATION=1 and drives it over the automation socket. The child prints ND_AUTOMATION_LISTENING path=<socket> on stderr, and launch_app waits for that line. Each launch gets its own socket and ND_APP_DATA_DIR, so parallel launches share no path.

use nd_test::{LaunchOptions, Role, expect, launch_app};

#[test]
fn real_add_one_updates_the_count() {
    let app = launch_app(LaunchOptions::new(env!("CARGO_BIN_EXE_hello"))).expect("launch hello");
    expect(&app.get_by_role(Role::Text).name("0").exact()).to_be_visible().unwrap();
    app.get_by_test_id("add").click().unwrap();
    expect(&app.get_by_role(Role::Text).name("1").exact()).to_be_visible().unwrap();
}

From examples/hello/tests/locators.rs, line 7.

Test names that start with real_, or live in a real module, are tier 3. nextest runs them one at a time (except on Linux), and nd test --tier real selects only them. connect_app attaches to a host that something else started.

Locators and expect

app.get_by_role, get_by_test_id, get_by_text, get_by_label, get_by_placeholder, and locator("role=button[name=\"Save\"] >> nth=1") return lazy locators. An action re-resolves the locator against the current tree. It waits until the node is visible and enabled, has real geometry, and has stopped moving. expect(&locator) has to_be_visible, to_have_text, to_contain_text, and similar assertions. It polls until the session timeout, which is 5 s; change it with Session::with_timeout. The API blocks and has no futures.

Selector parts chain with >>: testid=, role=, text=, label=, type=, has-text=, has=(...), nth=, first, and last. The grammar is in selector.rs.

Tier 3 adds app.cursor, app.capture, and app.record for the real cursor, screenshots, and recordings.

ndshell

ndshell is the test-only helper that tier 3 is built on. Apps never link it.

The incident tests of hello launch the real binary with ND_DEBUG_PANIC, ND_DEBUG_HANG_MS, and ND_DEBUG_CRASH, and assert on the bundle (incidents.rs, diagnostics).

MCP bridge

nd-mcp lets an agent drive a running app. It has three tools:

nd-mcp --example hello
nd-mcp --socket "$ND_AUTOMATION_SOCKET"

--bin <path> (env ND_MCP_BIN) launches any binary with automation on. Arguments after -- go to that binary.

nd test

nd test maps --tier to a nextest profile from .config/nextest.toml (which nd new writes for your app too) and runs cargo nextest run.

ProfileRuns
fastTiers 1 and 2, fail-fast. --tier fast and CI's first step use it.
realTier 3 only, one at a time off Linux.
defaultEverything except tier 3.
ciThe default filter, no retries, JUnit output.
gateEverything, including tier 3, for the milestone gates (just gate m1).

Every test is its own process. Tests that hold the machine lock are in one nextest test group, so they also wait for each other inside a run. A test that takes the lock but is missing from the group wastes a parallel slot, so keep the group and the lock users in step.

nd test -p gallery forms
nd test --tier all

From the repository, you can also run the real-input tests with cargo nextest run --profile real -p hello.