Testing
Documentation content

Testing

Native SDK apps test headlessly by default: the view is a function of the model, dispatch is typed, and the layout engine runs without a window server. Unit tests drive the real loop, TestHarness covers runtime integration, and the automation harness drives the live app. For running these tiers on GitHub Actions — including the workflow native init --full scaffolds — see Testing in CI.

TypeScript tests against compiled apps

For an app with src/core.ts, add tests/*.test.ts and run native test. The build compiles the app with scriptc and links a headless host around the existing TestHarness. Node.js 24 runs the test code; the app's model, updates, markup, layout, effects, and automation execute in the native process.

// tests/counter.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { NativeApp, findWidget } from "@native-sdk/core/testing";

test("increment and replay", async () => {
  const app = await NativeApp.start({ wallMs: 77000 });
  try {
    let snapshot = await app.snapshot();
    snapshot = await app.click(findWidget(snapshot, {
      role: "button", name: "+",
    }));
    assert.equal(snapshot.model.count, 1);
    assert.ok(snapshot.widgets.some(widget => widget.name.includes("total: 1")));

    const replay = await app.verifyReplay();
    assert.deepEqual(replay.snapshot.model, snapshot.model);
  } finally {
    await app.close();
  }
});

findWidget requires exactly one matching accessibility role and name. Add view to distinguish identical controls in different views. Use a fresh snapshot after an interaction. Widget identities and effect keys are decimal strings so JavaScript preserves all 64 bits. Scroll regions also expose widget.scroll, the applied two-axis ScrollState with offsets, velocities, and viewport/content extents widened exactly from native f32. Other widgets have scroll: null.

Each NativeApp.start() creates an independent native process. click, action, menu, and frame return the resulting snapshot. Actions support focus, press, toggle, increment, decrement, and dismiss. Requests are serialized, time out after 10 seconds by default (timeoutMs overrides this), and reject on native errors or process exits. Always close the app in finally, or use await using.

For text-entry widgets, setText(widget, text) replaces the content through native select-all and text input, preserving leading and trailing whitespace. selectText(widget, anchor, focus) uses UTF-8 byte offsets. composeText, commitComposition, and cancelComposition drive native IME events. Each method returns a snapshot and records the interaction for replay. key(view, "cmd+a"), key(view, "backspace"), and key(view, "enter") use the native keyboard path.

For drag and scroll tests, drag(widget, { x, y }) sends a physical pointer gesture through native hit testing. Coordinates are in the receiving canvas. beginDrag leaves the gesture active so you can inspect its preview; finish it with pointer(widget, "up", point) or cancel with key(widget.view, "escape"). pointer also accepts "down", "drag", and "cancel", with an optional { x, y } movement delta. wheel(widget, deltaY, deltaX) scrolls at the widget's center. dropFiles(view, paths, window) sends a platform file-drop event; an empty view addresses the window, and the default window is 1. These inputs use the normal runtime and replay journal.

For secondary windows, snapshot.windows lists live window identities, labels, titles, bounds, focus, and hidden state. frame() presents all live canvases. Select controls with findWidget(snapshot, { role, name, view }); pointer, text, and keyboard operations route to that canvas. closeWindow(window) sends a native user-close notification, including the window's close command. menu(command, windowId) addresses a specific window and defaults to the primary window.

contextPress(widget) presents the declared native menu. snapshot.contextMenu contains the exact target, correlation token, pointer position, and all item records; labels remain lossless byte arrays. Pass that record to contextMenuAction(menu, itemId) to select an item, or use item 0 to dismiss. A superseded record exercises the native stale-token path and cannot select a successor menu. These events replay through the normal runtime.

snapshot.webViews lists every open WebView's URL, bounds, layer, transparency, bridge setting, and zoom. The test host enforces the app manifest's permissions and navigation origins during startup and replay, so undeclared navigation fails as it does in a shipping app.

Models, effects, and replay

snapshot.model is the committed native model's JSON projection: byte values become number arrays, enums become strings, and tagged unions use the generated mirror's {arm: payload} shape. Its TypeScript type is a JSON record. snapshot.widgets contains accessibility state, bounds, and available actions. Each widget's value reports its applied numeric accessibility value, or null when it has none; use it to assert slider and split-divider positions, including retained values outside the model. The fingerprint covers the native accessibility tree.

The host drives the primary canvas and every live secondary canvas on the current desktop build target. It uses fake effects and a fixed clock (wallMs, default zero). It does not start production services, load persistence, read launch environment values, or open app-data directories.

Inspect snapshot.effects.requests, then answer a request with app.respond(key, bytes, ok). For typed services, successful responses use the service contract's canonical binary encoding; the generated result decoder routes them to the typed Msg arm. Failed responses carry error bytes. See examples/service-feed-reader/tests/view.test.ts for a record response fixture. Inspect snapshot.effects.timers, then call app.fireTimer(key) to send a journaled platform timer event. snapshot.effects.recorded counts journaled effect results, including clock reads.

Rendering uses NullPlatform; OS windows, GPU presentation, and platform integrations still need live automation tests.

verifyReplay() finishes the in-memory recording, creates a fresh native runtime, and verifies recorded checkpoints, the final accessibility fingerprint, and the final model. Its report includes event, effect, and checkpoint counts plus the replayed snapshot. It is terminal: afterward, use only snapshot() and close(). The journal and each response are capped at 8 MiB; each request is capped at 64 KiB. Exceeding a bound fails the test.

Tests are discovered directly under tests/ (no recursive discovery), run in a shared Node process, and resolve the SDK selected by the build. The core remains compiled even when an app has no local node_modules. For editor checking of Node test files, install @types/node and use a separate test tsconfig with types: ["node"] and include: ["tests/**/*.ts"]; keep the deterministic core's tsconfig separate.

Zig full-loop UI tests

For apps created with --template zig-core, the generated src/tests.zig shows the core pattern: build the real view from the markup, find a widget, dispatch its press exactly like the runtime would, and assert on the model and the rebuilt view:

var view = try canvas.MarkupView(Model, Msg).init(arena, main.app_markup);
var ui = canvas.Ui(Msg).init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));

var fx = main.Effects.init(testing.allocator);           // fake-executor effects channel
defer fx.deinit();
fx.executor = .fake;

const plus = findByText(tree.root, .button, "+").?;           // walk tree.root
main.update(&model, tree.msgForPointer(plus.id, .up).?, &fx); // dispatch like the runtime
try testing.expectEqual(@as(i64, 1), model.count);

Two msgForPointer traps: a disabled control yields null (assert == null rather than unwrapping when testing disabled states), and the tree is a snapshot — after each dispatch, rebuild the view before pressing anything again. Widget ids are stable across rebuilds, so asserting an id stayed constant while its text changed is the standard way to prove keyed identity. Sliders have their own split: msgForValue(id, value) fires only a Zig builder's on_value constructor and returns null for a markup slider — a markup slider binds a plain on-change, so assert its dispatch with msgFor(id, .change).

The same tests can push the tree through the layout engine (canvas.layoutWidgetTree) to assert real frames — see the generated src/tests.zig for both patterns. Effects-using apps swap in the fake executor (app_state.effects.executor = .fake) to assert on spawn/fetch/file requests and feed results back deterministically — see Native UI: Effects.

TestHarness

TestHarness provides a headless test driver using NullPlatform and a BufferSink for capturing trace records:

const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{});
defer harness.destroy(std.testing.allocator);

The harness embeds the multi-megabyte Runtime, so create it on the heap (create/destroy) — stack instances overflow test threads, and the same rule applies to UiApp instances in tests (App.create/destroy). The harness provides a pre-configured runtime with NullPlatform and a trace sink that captures records in memory; handler and update errors propagate (fail the test) instead of degrading. Use it to test runtime integration: lifecycle events, command dispatch, and full UiApp frames.

TestHarness is the same mechanism used by the framework's own test suite to verify bridge policy enforcement, window management, and lifecycle correctness.

Headless tests

The default test suite does not require a window server:

zig build test
zig build test-desktop
zig build test-automation-protocol
zig build test-platform-info
zig build test-examples
zig build test-examples-native
zig build test-examples-mobile

Bridge and IPC coverage lives in the headless desktop tests: they inject platform bridge events, exercise command policy and handlers, and assert the platform response without launching a WebView. Automation command parsing is covered by test-automation-protocol.

test-examples runs all repository example checks. Use the narrower example groups when you want a faster pass over one slice.

test-examples-native runs every native-first example's headless suite with -Dplatform=null from the repository root. Zero-config examples (app.zon + src, no build files) are driven through the native CLI's generated build graph — the same path native test takes in any app directory — while examples that own a build.zig run their in-directory zig build test.

test-examples-mobile verifies the iOS, Android, and shared mobile-shell example layouts so the embedded host scaffolds stay present. It also checks the shared mobile-shell platform, capability, and command metadata.

WebView smoke tests

WebView smoke coverage is a separate macOS integration step using Automation:

zig build test-webview-smoke -Dplatform=macos
zig build test-native-shell-smoke -Dplatform=macos
zig build test-webview-cef-smoke -Dplatform=macos -Dweb-engine=chromium

This step:

  1. Starts the system WebView example with automation and the JS bridge enabled
  2. Waits for a published automation snapshot (native automate wait)
  3. Verifies main window, source, and native/WebView metadata (native automate snapshot)
  4. Sends a native.ping request through native automate bridge
  5. Verifies child WebView create, resize, navigate, and close commands
  6. Verifies the responses

The CEF smoke step additionally requires a local CEF layout or -Dcef-auto-install=true; it exercises native.ping and child WebView create/resize/navigate/close through the automation bridge. These steps are intentionally opt-in because they need a GUI-capable macOS session.

The native-shell smoke step launches examples/native-shell, verifies toolbar/sidebar/statusbar/WebView rows in the automation snapshot, drives native focus traversal, drives a main-window resize and checks relayout bounds, dispatches app.refresh through bridge, menu, toolbar, and shortcut command paths, creates and closes a child preview WebView, and checks that the native status label reflects each command source.

NullPlatform

NullPlatform is a headless platform stub that records loaded sources and dispatched events without creating real windows. Use it in tests and with EmbeddedApp:

var null_platform = native_sdk.NullPlatform.init(.{});
var runtime = native_sdk.Runtime.init(.{
    .platform = null_platform.platform(),
});