Contributing

This document is for people changing the platform, hosts, tests, or example apps. For a user-oriented introduction, start with the Guide.

Prerequisites

Install:

  • Zig 0.16.0,
  • Python 3,
  • GitHub CLI (gh), authenticated for dependency attestation verification,
  • Node.js,
  • Zola,
  • the Tailwind CSS 3.4.17 standalone CLI (the site uses the v3 configuration),
  • Roc.

Local scripts use roc from PATH by default. Override it with ROC_BIN, ROC, or python3 scripts/test.py --roc-bin /path/to/roc.

CI uses the official roc-lang/setup-roc GitHub Action. The repository does not build Roc itself. The site build uses standalone command-line tools only; there is no npm dependency or package manifest.

Pull requests, pushes to main, and nightly validation run the source suite and native coverage, public examples against their committed release URLs, and the exact candidate archives. The required checks are Platform source, Published examples, and Release archive. The release workflow additionally validates archives on Linux x64/arm64 and Intel/Apple Silicon macOS. Pages deploys the supported release, not development builds from ordinary pushes.

Compiler pins live in both platform headers and every web and GUI example header, including internal web fixtures. .github/roc-nightly.json selects all of them for coordinated compiler updates; adding an example requires registering its header there. Public web dependency URLs remain unchanged during these updates. Run python3 scripts/toolchain.py --check --roc-bin /path/to/roc to validate the selected roots and installed compiler. The nightly bot advances those pins while preserving release URLs and automatically merges only a passing pin-only PR.

Test Driver

Run the full suite from the repository root:

python3 scripts/test.py

The default suite builds the platform hosts, runs Zig checks and unit tests, runs browser JavaScript contract tests, runs roc check, builds wasm apps, runs native semantic specs on macOS and Linux, validates the bundled platform on macOS and Linux, and runs the benchmark suite on macOS and Linux.

Useful targeted suites:

python3 scripts/test.py zig
python3 scripts/test.py browser
python3 scripts/test.py roc-check
python3 scripts/test.py roc-test
python3 scripts/test.py wasm
python3 scripts/test.py wasm-bench --roc-bin /path/to/roc
python3 scripts/test.py native --native always
python3 scripts/test.py gui
python3 scripts/test.py fault --native always
python3 scripts/test.py bundle --bundle always
python3 scripts/test.py bench --native always
python3 scripts/test.py size --roc-bin /path/to/roc
python3 scripts/test.py published

size builds the ReleaseSmall browser host and the fixed fixture set in test/size/fixtures.toml as production Wasm, then fails if any fixture’s raw or gzip size exceeds test/size/budgets.toml. It requires the selected compiler pin, because a different compiler produces different sizes. The budgets are explicit reviewed numbers; do not regenerate them to absorb a regression. scripts/wasm_size.py also compares two reports (--compare before.json after.json) and records attribution companions (--symbols); see docs/profiling.md for the measurement practice.

wasm-bench is the manual Node/V8 performance workflow for the complete production-controlled path from a DOM-double event through Wasm and command execution. It builds paired ordinary and instrumented ReleaseFast hosts, but uses only the ordinary production artifact for timings; the instrumented companion supplies exact allocation and shared-engine diagnostics after parity checks. Wall time is deliberately non-gating. See docs/profiling.md for case selection, comparison, and profiler workflows.

fault is the slower deterministic host-allocation campaign. It first runs the focused native SCM fixtures normally to record their runtime host allocation counts, then runs each allocation coordinate as an isolated process through the same --jobs worker pool used by native specs. Ordinary specs default to half the logical CPUs. Cases marked serial_native_spec in a benchmark manifest run alone, with no overlapping workers, so large structural fixtures stay within a predictable memory envelope without making the rest of the suite serial. Those entries may also set native_spec_timeout_seconds when their intentionally large workload needs more than the ordinary per-spec timeout.

Ordinary SCM execution and the native suite do not enable fault sweeping: fast semantic feedback is the default for application authors. The sweep is an explicit fault suite, while this repository’s full all suite and CI include it for a deliberately small set of platform-owned fixtures. Application projects can opt selected cases into an explicit fault run when their graph or lifecycle shape warrants the additional coverage. Fault injection does not add syntax or behavior to the .scm case itself, and it is deliberately not part of zig build test.

To replay a reported coordinate directly, copy the command printed after replay:. The worker interface is:

app --run-spec-json --fail-on-allocation 7 path/to/case.scm

Roc allocator internals are excluded from host coordinates: their physical resize/remap attempts can vary with process memory layout and cannot report recoverable OOM. Host-origin fault placements inside a Roc callback are reported as skipped by the recoverable campaign because the erased callback ABI cannot return OOM or unwind ownership. Real allocation failure there is a fatal poison-and-trap boundary. Infallible command calls, including observer commands after an earlier step committed, likewise report skipped_fatal_command: the native recoverable sweep does not inject at these positions and does not claim they recovered. Other selected host allocations must refuse and retry successfully without partial publication.

The Wasm suite separately builds the coordinated-writes fixture with test-only allocator exports and sweeps every allocation in a write-plus-observer host call. It verifies poison, a bounded diagnostic, empty publication buffers, unchanged browser DOM, no unpublished task execution, detached event listeners, and allocation-free idempotent containment. Recovery uses a fresh instance. This linked-app fatal campaign complements native refusal/retry tests; it does not turn arbitrary native crashes into accepted outcomes. Roc-allocator fatal boundaries also have focused linked-host tests.

Use --keep-output when debugging generated artifacts under .test-out/.

For small documentation edits that do not change behavior or coverage claims, run the lightweight tidy gate:

git diff --check
zig build run-check-tidy

Track active work in issues and pull requests. Temporary spike notes should not be merged; fold enduring conclusions into design.md, maintained documentation, tests, or local code comments, then remove the notes.

Zig Build Steps

Zig owns the platform host artifacts and Zig-only checks. It does not build Roc app executables.

zig build build-test-hosts
zig build test
zig build run-test-browser

For focused Zig host unit work, filter Zig tests at build time:

zig build run-test-zig -Dtest-filter="signals host"

zig build build-test-hosts builds our host and installs independently released musl inputs from dependencies.lock.json into Roc’s platform target layout. It verifies cached or downloaded dependency archives with gh attestation verify; it never rebuilds musl or accepts existing local libraries as a fallback:

  • platform-web/targets/x64mac/libhost.a
  • platform-web/targets/arm64mac/libhost.a
  • platform-web/targets/x64musl/libhost.a
  • platform-web/targets/x64musl/crt1.o
  • platform-web/targets/x64musl/libc.a
  • platform-web/targets/arm64musl/libhost.a
  • platform-web/targets/arm64musl/crt1.o
  • platform-web/targets/arm64musl/libc.a
  • platform-web/targets/wasm32/host.wasm

Roc app executables built during tests are written under .test-out/ by scripts/test.py.

Dependency artifact releases

The Dependency releases workflow builds musl from dependencies/musl.json, tests the exact archives on Linux x86-64 and AArch64, and checks that a second build produces the same bytes. Pull requests validate without publication. An explicit dispatch on main with a new deps-musl-<version> tag publishes those tested archives with signed GitHub build provenance and a consumer lock. The workflow does not compile the platform host or a Roc application.

To exercise the producer locally on Linux x86-64 with Zig 0.16.0, Git, and Make:

python3 scripts/build_musl.py --target x64musl --output /tmp/musl-candidate
python3 scripts/test_musl_artifact.py /tmp/musl-candidate/musl-x64musl.tar --target x64musl
python3 -m unittest scripts/test_dependency_artifacts.py

Use a fresh output directory for each build. Local candidate testing establishes link behavior; release consumption additionally requires CI-signed provenance. Review the published dependencies.lock.json before adopting it. Fetch and verify a selected locked artifact with the GitHub CLI installed:

python3 scripts/dependency_artifacts.py --lock dependencies.lock.json --artifact musl-x64musl --output /tmp/verified-musl

The default download cache is ~/.cache/roc-signals/dependencies; --cache selects another directory. Digest and provenance verification also run on cached bytes. The output retains the selected lock and a directory for each artifact, containing its manifest, target files, and license notices. Existing output directories are rejected. There is no unsigned fallback or automatic dependency upgrade.

The root dependencies.lock.json is the platform’s reviewed dependency selection. To update it, merge the selected entries from a successful dependency release’s lock, preserve entries for other dependency families, and run the native and exact-bundle tests. Ordinary host builds reuse the selected release. The web bundler stages dependencies from newly verified archives, includes the selected lock, per-target manifests and license notices, and ignores mutable development copies or unexpected libraries under platform-web/targets.

Publication refuses an existing tag or release. If a run stops during publication, inspect the existing tag, assets, and attestations, and recover the tested bytes; do not overwrite the release or rebuild under its existing identity. Keep GitHub release immutability enabled for this repository. The publication command attaches all assets before publishing; publication then locks their bytes and the tag. Historical releases created before immutability was enabled remain mutable and must not be described as having that protection.

GUI CI caches compiled Cargo dependencies using the lockfile, Rust environment, and runner image identity. Only successful pushes to main save the cache; pull requests restore it without publishing entries. Workspace host code remains outside that dependency cache and is rebuilt from the current checkout. This is a build acceleration mechanism, separate from verification of release inputs. Published-download checks continue to use fresh Roc caches.

Pull-request CI selects affected jobs from the complete merge-base diff using scripts/ci_changes.py. GUI-only changes run the native GUI jobs; web changes run browser/native checks, published examples, and release archive checks. Known documentation paths run the site check. Shared engine changes, build infrastructure, compiler/dependency locks, and unknown paths select every area. Renames count at both their old and new paths. Main-branch pushes and manual validation still run every area, and release workflows retain their full gates. The required Platform source check verifies the selection job and requires success from every selected job; it accepts a skip only for an unselected area. New commits cancel superseded PR CI runs, but do not cancel main or release runs.

The Windows dependency releases workflow independently generates the ADVAPI32 import library from the definition and license hashes in dependencies/windows-imports.json. It executes a probe linked against that candidate on Windows and compares two builds before signing. Dispatch it on main with a new deps-windows-imports-<version> tag. The emitted lock identifies windows-dependencies.yml as its signing workflow. To exercise its producer:

python3 scripts/build_windows_imports.py --output /tmp/windows-import-candidate
python3 scripts/test_windows_import_artifact.py /tmp/windows-import-candidate/windows-imports-x64win.tar

The second command cross-links on Linux; native execution is required on Windows before publication. This artifact contains no Signals host or application manifest resource.

The FreeType dependency releases workflow builds the pinned upstream source from dependencies/freetype.json. On Linux x86-64, Python 3.12 and Docker are sufficient to run the same producer:

python3 scripts/build_freetype.py --output /tmp/freetype-candidate
python3 scripts/build_freetype.py --output /tmp/freetype-rebuild
cmp /tmp/freetype-candidate/freetype-x64glibc.tar /tmp/freetype-rebuild/freetype-x64glibc.tar
python3 -m unittest scripts/test_freetype_dependencies.py

Source downloads are cached by digest under ~/.cache/roc-signals/sources and verified again before each build. Docker constructs the reviewed builder from its base-image digest, bootstrap CA and Zig distribution digests, and authenticated Ubuntu package snapshot; the actual compilation has no network access and does not mount Cargo or platform target directories as build inputs. Each candidate is extracted and tested by rendering an exact glyph bitmap. No compiler pin or platform host build is involved. CI repeats the build in another clean container before publication.

Dispatch this workflow on main with a new deps-freetype-<version> tag to publish tested bytes and their lock through freetype-dependencies.yml. Review and merge the lock entry separately when adopting the dependency. The Linux GUI builder verifies that release before compilation; the bundler verifies it again in fresh staging and excludes the development copy of libfreetype.so. Combined GUI bundles retain the lock entries and notices for both FreeType and Windows imports. Other Linux libraries still need independent producers. The artifact preserves FreeType’s system SONAME; applications still use the operating system’s runtime font libraries. The C compiler is Zig 0.16.0, targeting baseline x86-64 and glibc 2.39; the separate Roc compiler pin is preserved. See dependencies/README.md for the input and runtime boundaries.

Coverage

Native host coverage is a diagnostic tool for finding major gaps in the Zig runtime and host tests. It runs the existing signals_shared and signals_host Zig test roots under kcov, then merges their line coverage into one report. This keeps direct src/signals/ unit coverage and host-driven coverage visible together.

This workflow supports macOS and Linux arm64. Linux x86_64 is currently disabled because kcov cannot reliably read Zig DWARF on that target; run the coverage check on a supported runner rather than interpreting its refusal as a completed coverage pass.

Run a fresh coverage pass from the repository root:

python3 scripts/coverage.py

Reuse the previous kcov output for faster inspection:

python3 scripts/coverage.py --use-last-run --top 20
python3 scripts/coverage.py --use-last-run --format lines --file engine --context 5
python3 scripts/coverage.py --use-last-run --format json --top 10

Coverage output is written under kcov-output/native-host/. The script prints a ranked summary by uncovered line count; use the lines format to inspect the actual uncovered source ranges before adding focused tests.

When coverage points at generated ABI ingestion, keep roc_platform_abi.zig as the raw layout contract and add a small borrowed typed view above it instead. Prefer seam-level tests for those typed views before adding broad host/spec tests; that gives Zig exhaustive switches and named payload fields while preserving the external ABI exactly.

When coverage points at large engine paths, first look for engine-adjacent logic that can live in a focused src/signals/ module, such as descriptor bookkeeping, effect lifecycle state, or borrowed ABI views. Unit-test those seams directly and keep host/spec tests for cross-module behavior. If a seam retains or releases callable identity, allocate real retained Roc callables in tests instead of using stack pointers or boxed U64 stand-ins.

Run coverage after substantial changes to src/signals/, src/native_host.zig, the native spec runner, the simulated DOM, allocation diagnostics, or host runtime behavior. The coverage job is intentionally separate from python3 scripts/test.py because kcov is slower and mainly useful when investigating test gaps.

Known-failure ratchet

The example spec suites run to completion - every wasm mount and every native spec, each in its own process - and the run is judged at the end against test/known-failures.txt, the list of specs currently expected to fail:

  • a failure that is not listed is a regression and fails the run;
  • a listed spec that now passes also fails the run, until its line is removed;
  • python3 scripts/test.py ... --update-known-failures removes the lines that passed. It never adds one.

So the list only shrinks by fixing things. Accepting a new failure means adding its line by hand with a comment saying why, where a reviewer will see it. Keys are native <example>/<spec>.scm and wasm <example>; filters and shards only judge the specs that actually ran. --fail-fast still stops at the first failing example when you want a quick signal.

Fuzzing

Fuzz targets live in test/fuzzing/, one file per target, and are built through zig-afl-kit against a system AFL++.

Most of them are not byte fuzzers. The engine is a deterministic state machine whose interesting failures come from sequences of individually reasonable operations, so those targets decode the fuzzer’s bytes into a valid program - a signal graph, a run of source updates, a list of keyed-row edits - and then check the engine against a deliberately slow reference model that recomputes everything from scratch. Random bytes fed directly to the engine would be rejected at the boundary long before reaching the behavior worth testing.

TargetShapeWhat it checks
propagationgenerated DAG plus update sequencedependency order, glitch freedom, equality cutoffs, diamond deduplication, one evaluation per node per generation
keyed-scopesgenerated row edits and branch flipskey identity across insert/remove/reorder, scope retirement, reuse barriers, complete disposal
structuralgenerated initial root of sibling and nested each sites, mounted through the native host with allocation failure injected at a chosen or every preparation attemptpublished topology matches the model, nothing published after a refusal, retry on the same engine succeeds, commit and teardown never allocate
ownershipgenerated capability and value routingretained-value and callable ownership balance, rejection of mismatched routing
boundaryraw bytesschema and extraction-plan parsing: truncation, trailing bytes, invalid UTF-8, duplicate fields

The engine-driving targets reach the engine through native_host.fuzz_fixtures, the same fixture kit the native host tests use. The fuzz build compiles the native host with the fuzz_fixtures build option so that test-only machinery is available outside zig test.

python3 scripts/fuzz.py drives all of this. It owns the target list, the corpus layout, the AFL++ environment variables, and crash triage, so none of that has to be remembered or retyped:

python3 scripts/fuzz.py list
python3 scripts/fuzz.py run propagation --time 10m
python3 scripts/fuzz.py run all --time 5m -j 4
python3 scripts/fuzz.py status

run rebuilds first, adds the committed regression inputs to the local seed corpus (using one minimal seed if there are no inputs), fuzzes, and then prints throughput, edge count, stability, and any saved crash inputs. It exits non-zero when a crash was saved. Corpora persist under .fuzz-out/<target>/corpus, because inputs AFL++ found interesting last time are the cheapest way back into deep engine states. Local and CI campaigns use the same seeding path. --resume continues a previous session’s queue rather than importing new seeds, and clean discards both.

Watch stability, which should sit near 100%. A lower number means the target is not deterministic for a fixed input, which breaks the reference-model comparison and must be fixed before any crash it reports can be trusted.

Prerequisites

Fuzzing needs AFL++ on PATH:

sudo apt install afl++   # or: brew install afl++

Without it, the build still succeeds and produces the repro executables only, so a crash found on a fuzzing machine stays reproducible everywhere:

python3 scripts/fuzz.py build --no-afl

The underlying build steps are zig build build-fuzz for the repro executables and zig build build-fuzz -Dfuzz to also link the AFL++ persistent-mode executables.

Reproducing a crash

status lists saved crash inputs and the command to replay each one. The repro executables need no AFL++ and print the generated program and the operation sequence that led to the failure:

python3 scripts/fuzz.py repro propagation .fuzz-out/propagation/out/primary/crashes/<file> --verbose

Shrink a large input first:

python3 scripts/fuzz.py minimize propagation <crash-file>

Then turn the minimized case into a focused Zig test beside the seam it broke, or a native semantic spec if the failure is application-visible, and fix the engine. The crash file itself is not the regression test; a fuzzer finding is only finished once the invariant it violated is asserted somewhere permanent.

Bundles

Build both app-independent hosts and create both platform bundles:

scripts/bundle.sh
# Build and serve web/ and gui/ bundles plus URL-bound GUI example sources:
scripts/bundle.sh --serve --port 8000

The script uses ROC_BIN, ROC, or roc from PATH; use the pinned compiler. Archives and bundles.json default to .test-out/bundles, with separate web/ and gui/ directories. BUNDLE_OUT_DIR or --output-dir changes that root. --package web and --package gui select one platform and put its archive directly in the output directory. --no-build reuses prepared hosts; --debug-gui selects a faster development Rust build. Use the default optimized GUI build for distributable archives: development archives can exceed the pinned compiler’s default 100 MiB transitive package budget. Existing web test, site, and release commands explicitly select the web package.

For the separate browser JavaScript artifact, run python3 scripts/bundle_browser.py.

To test an existing bundle archive instead of rebuilding one:

python3 scripts/test.py bundle --bundle always --bundle-ref path/to/bundle.tar.zst

The test driver refuses non-local platform URLs by default so development tests exercise workspace changes. Use a local bundle path during development. When intentionally verifying a published release URL, pass --allow-release-platform-url.

The browser runtime checks the wasm exports before mounting. A runtime/bundle skew fails early with a Signals wire protocol version mismatch or Signals wire protocol feature mismatch error; deploy www/static/signals.mjs and app wasm built from the same compatible platform release.

To inspect command-wire byte traffic for a built wasm app, keep wasm outputs and mount an artifact with telemetry summarization:

python3 scripts/test.py wasm --keep-output
node scripts/browser/mount_wasm_example.mjs .test-out/wasm/package-explorer.wasm package-explorer --telemetry-summary

Repeat the mount command for each public wasm app when refreshing a public-app telemetry snapshot.

Static Site

Build and serve the static site with:

python3 scripts/serve.py

The helper builds ReleaseSmall host artifacts, generates www/static/signals.css with the standalone Tailwind CLI, runs Zola into dist/, creates a platform bundle under dist/platform/, builds public example apps with --target=wasm32 --opt=size by default, and copies downloadable source files under dist/examples/<slug>/source/.

Each generated Wasm artifact is validated with Node’s WebAssembly compiler before the build proceeds. A successful Roc compilation alone does not prove that the browser can load the generated code; invalid artifacts fail the site build with the affected file and validation diagnostic.

Example source files in dist/ have their local platform header replaced with SIGNALS_PLATFORM_URL when set. Otherwise they point at extra.release_platform_url from www/config.toml, falling back to the generated GitHub Pages platform bundle URL. The wasm builds themselves use a temporary local HTTP server for the freshly generated bundle, so development builds do not depend on a published release.

Useful variants:

python3 scripts/serve.py --example package-explorer --port 9001
python3 scripts/serve.py --app-opt dev
python3 scripts/serve.py --host-opt Debug
python3 scripts/serve.py --platform-url https://example.com/platform/release.tar.zst
python3 scripts/serve.py --no-server

For public site content, documentation, or site config changes, run the browser host and public apps production build without starting a server:

python3 scripts/serve.py --no-server --app-opt size

The pinned compiler’s dev backend currently emits invalid Wasm for unit-valued capability callbacks (see UPSTREAM_COMPILER_BUGS.md, case 10). The optional --app-opt dev build is a compiler diagnostic, not a passing release gate or a deployable alternative. Keep artifact validation enabled. After checking dev output, rebuild with --app-opt size; both modes write dist/.

Releases

Keep user-facing changes and migration instructions in releases/unreleased.md, reviewed alongside the implementation. Before publishing, rename it to releases/<exact-release-tag>.md (unprefixed SemVer, such as 0.2.0-rc1), replace the unreleased heading with that version, and name the explicit source → target version in the migration section. Create a fresh unreleased file when work on the next release begins. Published notes describe that upgrade; do not rewrite them to follow later APIs. The site guides document the supported API and link to releases rather than duplicating version-specific instructions.

Dispatch Release on main with release_tag and nightly_validation: false. The release guard explicitly permits exact-nightly bootstrap; it does not claim a stable Roc compiler exists. Preparation records the final source SHA and builds ReleaseSmall hosts, the platform archive, signals-browser.zip, and complete signals-starters.zip. Tests run those exact artifacts before publication. The release body combines the committed versioned notes with compiler/source identity, named URLs, and SHA-256 digests in signals-release.json.

To exercise preparation locally:

python3 scripts/release.py prepare --version 0.2.0-rc1
python3 scripts/release.py check
python3 scripts/release.py verify

Preparation requires a clean committed checkout so its source SHA identifies the actual inputs. Output defaults to ignored .release-out/ and must be empty; retain an existing candidate when investigating or recovering a release. Published checks use isolated caches and committed URLs without rebinding. They do not build hosts or borrow the checkout’s browser executor. Candidate checks rewrite only temporary starter copies to a loopback URL for the exact proposed archive.

After upload, the workflow verifies actual downloads, deploys signals-site.zip, and opens a verified signed follow-up PR updating public URLs and releases/current.json. It explicitly dispatches and reports required checks on that PR’s head; the release follow-up is manually merged. Compiler pins are preserved. A moved base or occupied follow-up branch is refused rather than overwritten. Nightly validation performs none of these writes.

The site archive retains earlier /versions/<version>/ pages and platform downloads, and serves the supported release at the existing landing URLs. The initial migration restores platform downloads from the actually deployed site’s Actions artifact and refuses to proceed if it cannot recover that evidence. Rendered documentation stays in artifacts, not Git.

Ordinary publication rejects any existing tag or release. After partial publication, inspect the tag SHA and every existing asset against the retained signals-release.json and original Actions artifacts. Preserve matching files; upload only missing original assets through a reviewed recovery operation, verify fresh downloads, then resume deployment/follow-up. Do not rerun preparation from a moving branch, overwrite assets, or move a tag. If the original artifacts cannot be recovered, prepare a new version rather than claiming the old identity.

Spec Language

Native app specs use semantic locators rather than positional DOM indices. They are app-facing semantic tests, not a browser emulator; keep browser-only event ordering and rendering details in JavaScript/browser contract tests.

(test "checkout succeeds"
  (steps
    (expect-visible (role heading :name "Team Checkout"))
    (fill (label "Email") "team@example.com")
    (expect-value (label "Email") "team@example.com")
    (check (label "Accept terms"))
    (expect-checked (label "Accept terms") true)
    (click (role button :name "Place order"))))

Put each independent case in its own *.scm file under the app’s specs/ directory. The driver discovers files recursively and gives each one a fresh app process. Keep pre-mount state in an optional (setup ...) form; setup accepts only initial-location, initial-visibility, initial-online, local-storage, and session-storage.

Writing specs that do not rot

Locate dynamic text by identity, not by content. A text: locator matches on rendered content, so it couples the locator to the value: change the value and the element stops resolving, and the failure reads “no element has text …” rather than showing a diff. Give the element a test_id instead:

Html.paragraph_s_attrs(status, [Html.test_id("sync-status")])
(expect-text (test-id "sync-status") "Synced 3 notes")

Note that (expect-text (text "X") "X") — the same string as both locator and expected value — asserts only that an element with that text exists. It is (expect-visible (text "X")) written the long way, and it cannot report a value mismatch. Prefer a test-id locator with the value as the expectation.

dirty_source_roots counts sources, not derived nodes. It is 1 for almost every interaction no matter how deep the graph. Use derived_calls_into_roc (one per map/map2/combine evaluation) as the fine-grained budget, and propagation_prunes to show an equality cutoff fired. See examples-web/_fixtures/metric-semantics/.

Assert structural metrics exactly; bound engine-internal ones. rows_created, rows_reused, rows_removed, scopes_created and scopes_disposed are semantic: they describe what the reconciler did, and an exact assertion is a real regression test. dirty_source_roots counts internal work whose exact value moves with unrelated engine changes — bound that with expect-metric-delta-at-most so an unrelated improvement does not fail an unrelated spec.

Do not assert patches_emitted in a spec. It aggregates every render command, so its value moves with any change to how work is emitted rather than to how much work is done, and the bounds drifted far enough from the real numbers to stop meaning anything: they were removed rather than re-fitted. Patch counts are still worth watching, but through the benchmark metrics, where a number that moves is compared against its own history instead of against a constant someone wrote down once.

To see what actually rendered, assert a deliberately wrong value on the enclosing region. expect-text falls back to the concatenated descendant text of a container that has no text of its own, so the failure prints the real content:

(expect-text (role region :name "Your Region") "PROBE")

Supported locators:

  • (role <role> :name "<accessible name>")
  • (label "<label>")
  • (text "<exact text>")
  • (test-id "<id>")

Supported action commands:

  • (click <locator>), (real-click <locator>)
  • (pointer-down <locator>), (pointer-up <locator>)
  • (pointer-enter <locator>), (pointer-leave <locator>)
  • (key-down <locator> "<key>" true|false)
  • (shortcut <locator> "<key>" <modifier-mask>) (native GUI)
  • (focus <locator>), (blur <locator>)
  • (composition-start <locator>), (composition-end <locator>)
  • (change <locator> "<value>"), (select-option <locator> "<value>")
  • (custom-event <locator> "<event-name>" "<detail>")
  • (submit <locator>), (fill <locator> "<text>")
  • (check <locator>) and (uncheck <locator>)

shortcut dispatches the exact Gui.on_shortcut binding declared on the located region through the shared engine. Keys use the public canonical vocabulary; modifier bits are Control 1, Shift 2, Alt 4, and Meta 8 (add them for a combination). For example, (shortcut (test-id "editor") "s" 3) invokes Control+Shift+S. Missing bindings, invalid keys, and masks outside 0..15 fail. This command checks application semantics and scope disposal. GPUI interaction tests cover focused routing, native editor precedence, and event propagation.

Supported assertions:

  • (expect-visible <locator>)
  • (expect-absent <locator>)
  • (expect-text <locator> "<text>") — compares the element’s own text; for a container with no text of its own, compares the concatenated descendant text instead, so a region can be asserted by its rendered content
  • (expect-value <locator> "<text>")
  • (expect-attr <locator> <attr-name> "<value>")
  • (expect-no-attr <locator> <attr-name>)
  • (expect-checked <locator> true|false)
  • (expect-disabled <locator> true|false)
  • (expect-updates <locator> <count>)

Readable native file fixtures

Use structured Files settlements for application workflows so specs do not need hand-counted UTF-8 frames or knowledge of the private files1 payload:

(click (role button :name "Open…"))
(expect-pending-task "notes-open" 1)
(resolve-file-choice "notes-open" (chosen "/tmp/meeting.txt"))
(resolve-file-read "notes-read" :path "/tmp/meeting.txt" :text "First line\nSecond line: λ")
(expect-value (label "Note text") "First line\nSecond line: λ")

The complete initial vocabulary is:

(resolve-file-choice "save-path" (canceled))
(resolve-file-choice "save-path" (chosen "/tmp/project.board.json"))
(resolve-file-read "read" :path "/tmp/note.txt" :text "Contents")
(resolve-file-write "write" :path "/tmp/note.txt" :bytes 8)
(reject-file "read" :kind permission-denied :detail "/tmp/note.txt")

Directory browsing, previews, native launch, and incremental logs use the same structured vocabulary:

(resolve-file-directory "folder" :path "/tmp" :entries
  ((file "/tmp/readme.txt" 123) (directory "/tmp/project" 0)
   (symbolic-link "/tmp/latest" 12) (other "/tmp/socket" 0)))
(resolve-file-preview "preview" :path "/tmp/readme.txt" :text "First page" :truncated true)
(resolve-file-open "launch" :path "/tmp/readme.txt")
(resolve-file-log "tail" :path "/tmp/app.log" :text "Ready\n"
  :device 7 :inode 13 :offset 6 :change initial :state at-end)

Log changes are initial, continued, rotated, or truncated; states are more, at-end, or partial-utf8. Cursor numbers and directory file sizes are canonical unsigned decimal U64 values, including values above signed I64’s maximum. Signs, leading zeros, quoted numbers, and overflow are refused. Preview and log text are bounded to 64 KiB. Direct directory fixtures accept up to 10,000 entries and four MiB of combined root/entry path bytes. Directory fixtures settle list_directory tasks, not recursive scans; preview, log, and launch fixtures each require their corresponding declared service.

Fields may appear in any order; each documented field is required exactly once. Choice tags are chosen and canceled. Error kinds are canceled, not-found, permission-denied, invalid-utf8, invalid-path, resource-limit, io, and unavailable; canceled errors require empty detail. Paths must be absolute, valid UTF-8, and at most 4096 bytes. Read text and write byte counts have the native one-MiB bound, and error detail is bounded to 4096 UTF-8 bytes. Unknown fields, duplicate fields, invalid types, and oversized values reject the spec.

A fixture checks the pending task’s declared service before calling its Roc result decoder. Read fixtures cannot settle write tasks; choice fixtures accept file, directory, and save choosers; error fixtures accept native Files tasks. A mismatch reports the source line, task label, expected service, and actual service or missing request. A task label locates a request for the harness; it does not determine service semantics. Settlements still use ordinary engine propagation and task ownership.

These commands simulate results and perform no filesystem IO. They establish application response, cancellation, and state behavior; real filesystem and native chooser behavior need host tests and a native walkthrough. Keep raw resolve-task, reject-task, and resolve-stale-task when deliberately testing malformed payloads or stale delivery. The Board and Notes journeys demonstrate save snapshots, failed loads, cancellation, retries, and retained drafts.

A supplied result does not assert the request payload the app emitted. For example, resolving a write with :bytes 14 does not prove that the app submitted those fourteen bytes. Test snapshot construction as pure application logic and check real submitted data through focused native IO workflows. The harness currently exposes pending/canceled counts, not request-body assertions.

Supported async and lifecycle commands:

  • (resolve-task "<task-name>" "<payload>")
  • (resolve-stale-task "<task-name>" "<payload>")
  • (reject-task "<task-name>" "<payload>")
  • (tick-interval <period-ms>), (tick-interval-if-active <period-ms>)
  • (request-window-close), (expect-window-closed true|false) (native GUI lifecycle)
  • (expect-pending-task "<task-name>" <count>)
  • (expect-canceled-task "<task-name>" <count>)
  • (expect-interval <period-ms> <count>)
  • (expect-cleanup "<cleanup-name>" <count>)

A window-close assertion records the committed close decision. The harness keeps the final semantic tree available for inspection; it does not simulate an OS window or automatically reject subsequent interactions with that tree. End the interaction journey at closure and use GPUI lifecycle tests to verify native removal and teardown.

Supported metric commands:

  • (mark-metrics)
  • (expect-metric-delta <metric-name> <delta>)
  • (expect-metric-delta-at-most <metric-name> <delta>)

Quoted values are unescaped (\n, \t, \\, \") for every command that takes one, including fill, change, select-option, key-down, and the expect-text / expect-value / expect-attr comparison values.

expect-pending-task asserts an absolute count, not a delta. To prove that an interaction did not start a request while another is in flight, assert that the count is unchanged and that expect-canceled-task is still 0.

resolve-stale-task requires a previously canceled request for that task name; without one the host reports fake stale task result had no matching canceled request. Force a supersede first.

real-click dispatches pointerdown -> pointerup -> click through the simulated propagation path, including capture/bubble, self, and stop policy. Use it for nested controls where parent event flow matters; use click for a direct unit click binding on one target. For buttons inside forms, omitted type behaves as submit, type="submit" submits, and type="button" stays click-only. Reset buttons dispatch app-managed prevent-default reset bindings. Checkbox controls use the checked-change default path even without a click handler. submit is for app-managed forms and requires a unit submit binding from Html.on_submit_prevent_default. custom-event sends its detail argument as event.detail, which reducers built with State.on_detail receive as text.

Common metric names include dirty_source_roots, rows_reused, rows_created, rows_removed, scopes_created, scopes_disposed, stream_nodes_scanned, stream_nodes_scanned_events, render_indexes_refreshed, active_intervals_synced, active_graph_records_rebuilt, signal_record_table_rebuilt, stale_task_results_ignored, retained_alloc_delta, host_retained_alloc_delta, and host_retained_bytes_delta. The authoritative list lives in src/spec/spec_runner.zig.

Benchmark Mode

The Python driver builds benchmark binaries under .test-out/bench-bin when the bench suite runs. The default all suite includes benchmarks on supported native hosts; use python3 scripts/test.py bench --native always to force the focused bench gate. A built app binary also accepts benchmark flags directly:

.test-out/bench-bin/signals-data-grid-bench --bench-app --bench-name signals-data-grid --bench-iterations 100 --bench-samples 3 examples-web/data-grid/specs/initial-mount.scm

The host initializes a fresh app per iteration, applies the initial command batch, then replays commands classified as benchmark actions in src/bench/benchmark.zig (user actions, task results, and interval ticks). Expectation and metric assertion commands remain the semantic correctness suite used by python3 scripts/test.py native.

The keyed fixture also has a production browser adapter under benchmarks/js-framework-benchmark/roc-signals-keyed/. Build and verify it with:

cd benchmarks/js-framework-benchmark/roc-signals-keyed
npm ci
ROC_BIN=/path/to/roc npm run build-prod
npm run verify

This produces a ReleaseFast Wasm host, a Roc --opt=speed app, and the matching shared runtime modules. The local verifier checks DOM structure and keyed identity semantics; comparative browser timings and memory numbers must come from the official js-framework-benchmark runner. See docs/profiling.md and the adapter README for the copied-directory workflow and known submission-policy gaps.

Roc API Shape

Apps import:

import pf.Elem exposing [Elem]
import pf.Html
import pf.Signal
import pf.Ui

Signal, Html, and Ui build pure descriptor trees:

  • Signal.Signal(a) is an opaque typed descriptor.
  • Ui.state introduces local state through a closure binder.
  • Ui.when, Ui.switch, and Ui.each introduce explicit dynamic scopes.
  • Html creates static markup, signal-backed text/attrs, and event bindings.

Apps no longer define erased value encode/decode boilerplate for row fixtures. Typed values cross the host boundary as capability-owned HostValue cells.

Host Boundary

The host calls roc_ui_init once, stores the returned boxed Elem, walks the descriptor tree, evaluates signal expressions against host-owned state, applies patches to the simulated DOM, and dispatches events through retained Roc reducers. Branch and keyed-row scopes are disposed by the host when they leave the active tree. Non-structural state changes patch only the dirty signal-backed leaf sinks recorded in the retained descriptor stream. Structural when and Ui.each changes are applied through local active-stream splices, row moves, and affected event-binding refreshes rather than a full root rebuild.

Ui.each retains each immutable Rows(item) generation behind its app-compiled capability. Rows owns key projection, cached exact keys, stable item slots, and the transition from its immediate parent. The engine calls describe, then copy_delta when that parent matches the site’s committed generation, or copy_snapshot for a fresh site, an explicit snapshot, or a nonmatching parent. The callbacks write into bounded host sinks reserved before the copy; they do not return temporary Roc lists of keys or items.

compare_slots and clone_item access typed items through the collection’s capability. The host does not inspect their layout or retain a typed item cell for every row merely to reconcile keys. Candidate generations and row changes stay provisional until an allocation-free commit publishes the structural change. Use the Rows delta/snapshot work counters to distinguish sparse edits from full snapshot reconciliation when testing update costs.

Glue

Regenerate glue after changing exposed platform types or provided entrypoints:

roc glue <path-to-roc>/src/glue/src/ZigGlue.roc src/signals platform-web/main.roc
zig fmt src/signals/roc_platform_abi.zig

Use the ZigGlue.roc from the same Roc commit named by the roc header in platform-web/main.roc. The host uses the generated types’ public incref and decref methods; generated helper functions are implementation details and must not be made public by hand.

Native GUI platform spike

platform-shared/ owns common signal, scope, descriptor, ownership, and render construction modules. scripts/prepare_platforms.py copies its Roc files into platform-web/ and platform-gui/. These flat generated copies are individually gitignored. The fixed repository layout needs no per-platform source configuration.

Run python3 scripts/prepare_platforms.py --check or zig build run-check-platform-sources to compare SHA-256 hashes without changing any files. Missing, modified, and stale shared-module entries fail the check. The check runs with zig build test, and CI exercises deliberate drift detection. Refresh copies after editing canonical sources with python3 scripts/prepare_platforms.py. Bundle staging always reads the canonical shared sources, even when local generated copies are stale.

The flat layout is intentional: nested shared/ imports and hosted declarations currently fail with the pinned compiler, including when compiled from bundles. See UPSTREAM_COMPILER_BUGS.md for the observed limitations.

The GUI targets are Apple Silicon macOS, Linux x86_64 with glibc and a Wayland/GPU session, and Windows x86_64. Host development needs Rust (tested with 1.94 on macOS, and 1.95.0 in Linux and Windows CI) and Zig 0.16. Linux also needs a C toolchain/CRT, FreeType and xkbcommon development packages, and the xkbcommon-X11 runtime. macOS needs Xcode with its Metal compiler component (xcodebuild -downloadComponent MetalToolchain). If Xcode reports mismatched support frameworks, complete xcodebuild -runFirstLaunch first. Windows needs the x86_64-pc-windows-msvc Rust toolchain; the optimized host build also compiles GPUI’s shaders with the Windows SDK’s fxc.exe (set GPUI_FXC_PATH if it is not discovered), and a development build compiles them at runtime instead. Zig supplies the archiver and resource compiler, Roc’s own x64win link supplies the C runtime, and the one additional import library is fetched from the independently signed Windows dependency release pinned in dependencies.lock.json. Its producer uses Zig’s MinGW-w64 definitions. Source host builds require authenticated GitHub CLI access and verify that dependency before compiling the host; there is no local import generation fallback. Windows bundle staging verifies it again and includes the selected lock, manifest, and license. The host build still compiles its own application manifest resource. No MSVC link step or Windows SDK libraries are involved. Use python rather than python3 in the commands below on Windows, where python3 is often a Store shortcut; build.zig prefers python there. The workspace pins GPUI 0.2.2. Other native targets, including Intel macOS and Windows on Arm, are not implemented. The GUI builder selects TOOLCHAINS=Metal on macOS unless explicitly overridden; use the same setting for direct cargo test commands if Xcode’s default lookup still reports the installed Metal component as missing.

The GUI platform header lists the Rust host and Zig engine as separate link inputs: libsignals_gpui_host.a and libengine.a on Linux and macOS, or signals_gpui_host.lib and engine.lib on Windows. Roc links these with the application object and the other declared inputs to produce the executable. The builder does not merge them into a combined host archive. Rebuild older prebuilt target directories before using them with this header.

Cargo tracks the checkout identity supplied by .cargo/config.toml through the host’s build.rs. This prevents stale host reuse when multiple checkouts share CARGO_TARGET_DIR, without cleaning the host on every build. Run direct Cargo commands from the workspace or crate directory so Cargo discovers that config.

python3 scripts/build_gui.py --debug
roc build examples-gui/counter/main.roc --output=.test-out/Counter
.test-out/Counter
# Same executable, display-free semantic check:
.test-out/Counter --run-spec-json examples-gui/counter/specs/counting.scm
# Brief rendering/adapter integration check:
.test-out/Counter --smoke --smoke-click Increment --smoke-expect 'Count: 1'

python3 scripts/test.py gui --roc-bin /path/to/pinned/roc --keep-output checks the compiler identity, prepares shared sources, builds the development GUI host, runs focused GPUI adapter/editor tests, then roc check and roc test for each registered GUI app and internal fixture under test/gui/, builds fresh executables, and runs their native semantic specs without a display. The manifest at examples-gui/examples.toml must list every app directory, and each app must have specs. Every GUI check must pass; this suite has no known-failure allowlist. --spec-filter, --shard, --jobs, and --fail-fast also apply. The default all suite includes GUI checks on Linux x86_64; run gui explicitly on macOS, where it requires full Xcode and the Metal toolchain. CI runs them in a dedicated Linux, Windows, and macOS jobs. GUI executables remain under .test-out/gui when output is kept. Linux CI then runs xvfb-run -a python3 scripts/gui_smoke.py --wayland with Weston and Mesa’s software Vulkan driver. Weston runs on Xvfb so GPUI receives a Wayland input seat as well as a virtual display; Weston’s headless backend provides no seat and GPUI 0.2.2 requires one. It opens every maintained example, requires the host’s explicit rendering result, checks the counter’s increment action, and fails on a crash or a 30-second timeout. This checks the GPUI window/rendering path separately from the display-free specs. Run the same script on a desktop after the GUI suite to exercise the local graphics driver.

Normal GUI launches do not print engine metrics. Pass --host-trace-engine to an app executable to log event-turn metrics to stderr; --smoke prints its explicit validation result. Host errors remain visible without tracing.

Host builds default to two Cargo workers. Use scripts/build_gui.py --jobs N or scripts/test.py gui --gui-build-jobs N to adjust memory pressure. Parallel app work should serialize substantial host builds. The builder cleans the local Rust host crate before compiling so a shared Cargo target cannot reuse another worktree’s host implementation; dependency artifacts remain cached. Release builds retain Rust’s optimized per-crate compilation and local ThinLTO, with cross-crate LTO disabled so rebuilding the host does not optimize the full dependency graph again. This policy makes no claim about runtime performance relative to a whole-program LTO build. GUI specs validate shared semantics; the separate window smoke above checks rendering and adapter dispatch, and does not establish OS keyboard, pointer, or IME behavior. GPUI adapter tests exercise simulated input and layout; they also do not replace a native desktop walkthrough.

After scripts/bundle.sh --package gui --serve, download http://127.0.0.1:8000/Counter.roc and run roc build Counter.roc. Alternatively, roc run Counter.roc --opt=speed compiles and opens the window directly. Plain roc run currently encounters the required-main shim collision documented in UPSTREAM_COMPILER_BUGS.md, case 12. The app author needs the pinned Roc compiler and the target operating system (plus runtime GUI libraries on Linux). Rust, Zig, and the native SDK/toolchain are used only when preparing the platform bundle. On macOS, the package embeds compiled Metal shaders and copies the required SDK framework/library link stubs into targets/macos-sysroot; building a bundled app does not need Xcode. The macOS build is validated on macOS 26.3; older versions are not yet validated. This produces a native executable, not a desktop installer. The Linux prebuilt host depends on the build machine’s glibc/library ABI; portable release packaging needs a deliberate sysroot and license inventory.

Prebuilt link inputs come from CI on their own release cycle instead of a local host build, and no binary is committed. The GUI host link inputs workflow (gui-hosts.yml, dispatched with a gui-hosts-<date> tag) builds the optimized platform-gui/targets/<target> trees on each supported runner, records signed build provenance, and publishes them as a GitHub release of gui-link-inputs-<target>.tar archives. Rerun it only when the host changes. To bundle from a published set, verify and extract each archive, then pass the extracted targets/ tree:

gh release download gui-hosts-2026-09-09 --pattern 'gui-link-inputs-*.tar' --dir /tmp/hosts
gh attestation verify /tmp/hosts/gui-link-inputs-x64win.tar --repo lukewilliamboswell/roc-signals
mkdir -p /tmp/hosts/targets && tar -xf /tmp/hosts/gui-link-inputs-x64win.tar -C /tmp/hosts/targets
scripts/bundle.sh --package gui --no-build --prebuilt-targets /tmp/hosts/targets

--prebuilt-targets copies the tree into the staged platform alongside any inputs already present locally, so one bundle can carry every operating system’s inputs.

The bundle output also contains every registered GUI app under examples-gui/, including its supporting Roc modules and semantic specs. Those generated app headers refer to the served bundle URL. With the server still running, build an app directly from the output directory:

roc build .test-out/bundles/examples-gui/notes-editor/main.roc --output=.test-out/Notes
.test-out/Notes

Gui offers typed rows, columns, panels, native styles, headings/text, enabled buttons, labeled inputs, and checkboxes. See the native presentation protocol in docs/native-gui-protocol.md for field compatibility and limits. Signals, keyed rows, scopes, and ownership remain in the shared engine. Wide collections use Gui.virtual_list to bound child lookup and layout to the visible range; ordinary containers enumerate direct children when rendered. See Native GUI for controls and keyboard regions, and crates/gpui-host/README.md for the boundary limits.