Skip to content

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();
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.

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).

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.

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.

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 });

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.

  • pngSize(path) — PNG dimensions from the IHDR chunk (used by screenshot(), useful standalone).
  • poll(fn, pred, {timeoutMs?, intervalMs?}) — generic poll-until-predicate, for the rare condition waitFor’s vocabulary doesn’t cover (e.g. window count settling, a SourceList’s rows array reordering after a click).
  • resolveTarget(t), findNode/findAllNodes/findMatchingNode — the target-normalization and tree-walk primitives AppHandle is built on, for scoped subtree searches (findNode(paneNode, "some-child-testid")) rather than a whole-tree find.
  • JsonNode, GetTreeResult — re-exported from packages/react/src/generated/rpc.ts so a caller never has to reach into the generated tree outside this package.