Test Harness
@nativedesktop/test wraps the automation socket into a
launchApp/AppHandle API: spawn a host binary, wait for it to be ready, connect, and drive it
with the same RPC surface that page documents — without hand-rolling process spawn, marker
parsing, socket connect, or a find/mustFind tree walker in every script. It reuses
its own src/socket.ts AutomationClient for the wire framing (length-prefixed JSON-RPC
2.0); packages/mcp imports it from here rather than keeping a second implementation.
import { launchApp } from "@nativedesktop/test";
const app = await launchApp({ entry: "examples/notes/main.tsx", backend: "gtk" });
await app.click("new-note-button");await app.waitForText("Untitled note");await app.setValue("title-input", "Grocery run");
const shot = await app.screenshot("/tmp/notes.png");console.log(`${shot.width}x${shot.height}`);
await app.close();launchApp(options)
Section titled “launchApp(options)”const app = await launchApp({ entry: "src/main.tsx", // -> ND_SCRIPT cwd?: string, // default process.cwd() backend?: "gtk" | "appkit", // default @nativedesktop/host's resolveBackend() hostBinary?: string, // pre-resolved binary path, bypasses resolveHostBinary() env?: Record<string, string | undefined>, dev?: boolean, // ND_DEV=1, default false acl?: Record<string, string[]>, // -> ND_ACL_GRANTS JSON dialogScript?: DialogScript, // -> ND_AUTOMATION_DIALOG_SCRIPT (see below) readyMarkers?: string[], // default ["ND_AUTOMATION_LISTENING", "ND_COMMIT_APPLIED"] readyTimeoutMs?: number, // default 20_000 rpcTimeoutMs?: number, // default 8_000 retries?: number, // relaunch attempts on ready failure/early exit, default 2 logPath?: string, onStderr?: (line: string) => void,});hostBinary is for callers @nativedesktop/host’s own resolution can’t place: a consumer outside a
NativeDesktop checkout (installed as a file:/link: dep, so the source-checkout fallback can’t find
it either), or the gtk-on-macOS dev path (no prebuilt ships there by design — resolve nd-hello from
a sibling checkout yourself and pass its path).
entry is resolved relative to cwd. The GTK host (including GTK-via-Quartz on macOS) fails to
start when XDG_RUNTIME_DIR is unset or points at a directory that doesn’t exist —
launchApp creates a fresh one automatically unless the environment already provides a valid one,
so callers never have to remember export XDG_RUNTIME_DIR="$(mktemp -d)" themselves.
If the ready markers never appear (or the process exits first), launchApp kills it and retries up
to retries more times before rejecting with the last retries + 1 attempts’ failure and the last
40 lines of stderr.
AppHandle
Section titled “AppHandle”| Member | Notes |
|---|---|
pid, logPath, socketPath, backend |
identity of the current live process |
rpc |
the wrapped AutomationClient — every call races a rpcTimeoutMs timeout and, on timeout, rejects with `${method} timed out after Nms` plus the last 40 stderr lines |
stderr() / stderrTail(n = 40) |
the full captured stderr, or its last n lines |
tree(window?) |
raw getTree result |
find(testId, {window?}) / findAll(testId, {window?}) / mustFind(testId, {window?}) / findMatching(pred, {window?}) |
tree-walk helpers over the current snapshot; mustFind throws when nothing matches |
click(t), setValue(t, v), type(t, s), scroll(t, {dx?, dy?}), hover(t), doubleClick(t), rightClick(t) |
single-RPC, actionability-checked, host-side testId resolution — no client-side retry loop |
keys(spec, {window?}), drag(opts) |
input synthesis; macOS only, -32003 on GTK |
waitFor(condition, {timeoutMs?, window?}) + sugar (below) |
thin pass-throughs to the waitFor RPC — the host does the polling, not this client |
waitForMarker(marker, timeoutMs?) |
polls captured stderr for a substring (dialog-script exhaustion, crash markers, anything else the host logs) |
windows() / waitForWindows(count, timeoutMs?) |
the windows RPC / poll it until the count matches |
screenshot(path, opts?) |
see below |
restart() |
tears down the current process and relaunches with the same options; app state resets to a fresh launch |
close() / kill() |
graceful (SIGTERM, falls back to SIGKILL after 3s) / immediate |
[Symbol.asyncDispose] |
await using app = await launchApp(...) closes it automatically |
Every action method takes a target: t: string | number | {testId?, ref?, window?, action?}.
A bare string is a testId, a bare number is a ref; an object descriptor passes through.
action applies to click only: app.click({ testId: "row-testid", action: "action-id" })
invokes a SourceTree row’s trailing action semantically (see
SourceTree row actions).
Exactly one of
ref/testId must resolve — the host validates it (invalidParams otherwise).
waitFor sugar
Section titled “waitFor sugar”Every one of these is a single waitFor RPC call — no client-side polling:
| Method | Condition |
|---|---|
waitForText(text, opts?) |
{textContains: text} |
waitForPresent(testId, opts?) |
{testId, state: "present"} |
waitForGone(testId, opts?) |
{testId, state: "gone"} |
waitForEnabled(testId, opts?) |
{testId, state: "enabled"} |
waitForDisabled(testId, opts?) |
{testId, state: "disabled"} |
waitForFocused(testId, opts?) |
{testId, state: "focused"} |
waitForCount(testId, count, opts?) |
{testId, countAtLeast: count} |
waitForValue(testId, value, opts?) |
{testId, valueEquals: render(value)}, or valueContains when opts.contains is true |
opts is {timeoutMs?, window?} (waitForValue also takes contains?: boolean). See
waitFor conditions for exactly what
each state checks and how values are rendered to a string.
screenshot(path, opts?)
Section titled “screenshot(path, opts?)”interface ScreenshotOptions { window?: number; retries?: number; // default 5 minHeight?: number; // throw if the PNG is shorter than this minBytes?: number; // throw if the PNG file is smaller than this via?: "rpc" | "ndshot"; // "ndshot" shells out to tools/ndshot, macOS/AppKit only}Retries with a 150ms backoff (a screenshot right after a mutation or animation can race frame
invalidation and answer a transient error or a stale frame). Every call is floor-checked for
non-zero dimensions regardless of minHeight/minBytes. pngSize(path) (also exported) parses a
PNG’s width/height from its IHDR chunk, independent of the harness.
via: "ndshot" runs tools/ndshot capture --out <path> --pid <app.pid> instead of the in-process
screenshot RPC — the workaround for macOS 26’s offscreen-render blanking of TextInput/
TextArea documented in ndshot.
Requires tools/ndshot/build.sh to have run at least once.
Dialog scripts
Section titled “Dialog scripts”dialogScript takes the same shape ND_AUTOMATION_DIALOG_SCRIPT parses — see
Scripted native dialogs for the
full per-method entry shapes and the exhaustion contract:
import type { DialogScript } from "@nativedesktop/test";
const dialogScript: DialogScript = { "dialog.openFile": [["/tmp/a.txt"]], "window.showAlert": [{ buttonId: "delete" }],};const app = await launchApp({ entry: "examples/dialogs/main.tsx", dialogScript });Lifecycle and cleanup
Section titled “Lifecycle and cleanup”killAll() (also exported at module scope) kills every host process any launchApp call in the
current process has spawned, and is wired to process.on("exit"/"SIGINT"/"SIGTERM") — a thrown
assertion partway through a script never leaves an orphaned window behind. Prefer await app.close() when a test finishes normally; reach for killAll() in a top-level finally or a
test runner’s global teardown instead of tracking handles yourself.
Also exported
Section titled “Also exported”pngSize(path)— PNG dimensions from the IHDR chunk (used byscreenshot(), useful standalone).poll(fn, pred, {timeoutMs?, intervalMs?})— generic poll-until-predicate, for the rare conditionwaitFor’s vocabulary doesn’t cover (e.g. window count settling, aSourceList’srowsarray reordering after a click).resolveTarget(t),findNode/findAllNodes/findMatchingNode— the target-normalization and tree-walk primitivesAppHandleis built on, for scoped subtree searches (findNode(paneNode, "some-child-testid")) rather than a whole-treefind.JsonNode,GetTreeResult— re-exported frompackages/react/src/generated/rpc.tsso a caller never has to reach into the generated tree outside this package.