Tests in the app
day test runs tests inside a built Day app on a chosen toolkit. A test is a plain function
marked #[day::test] that returns a [Case]: either a page plus a drive against it (a GUI
test), or a body with no page (a headless test, for logic that needs the app’s own
environment). The same function runs on the mock toolkit under cargo test, so a broken
binding or id fails in milliseconds before a toolkit is ever involved.
A test is named after its function, with hyphens for underscores: button_press runs as
button-press. The name has one source, so the CLI, the report, conformance.json and an editor
reading the source all agree on it.
The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the
example messages and catalog setup.
Declaring a test
use day::prelude::*;
use day::{Case, Drive};
#[day::test]
fn button_press() -> Case {
let presses = Signal::new(0i64);
Case::new()
.page(move || {
column((
button(res::str::press())
.action(move || presses.update(|n| *n += 1))
.id(id!(btn_press)),
label(res::str::counter_value(presses)).id(id!(btn_count)),
))
})
.shot("default")
.drive(|d: Drive| async move {
d.tap(id!(btn_press).as_str()).await?;
d.assert_text(
id!(btn_count).as_str(),
&res::str::counter_value(1_i64).format(),
)
.await
})
}
#[day::test]
fn store_round_trip() -> Case {
Case::headless().run(|t: Drive| async move {
let store = Store::open()?;
t.check_eq(store.count(), 0)
})
}
Case::new()is a GUI test;.page(..)is any piece, which the app’s test host shows alone while the case runs.Case::headless()has no page..drive(|d| async { .. })(or.run, which reads better for a headless body) is an async body over a [Drive]. Each op is awaited, so the app’s main loop turns between ops the way it does between a script’s steps: a transition settles, a capture paints..proves(kind),.proves_cap(cap),.proves_duty(name)say what a pass marks in the coverage tables, in the matrices’ own spelling (kind:day.button,cap:Announce,duty:set_a11y). Framework conformance tests can depend onday-specand use.proves(day_spec::kinds::BUTTON); ordinary app tests need no coverage declaration..requires(cap)skips the case with a recorded reason where the toolkit answersUnsupported;.requires_support(name, f)does the same for a piece’s ownsupport()answer. A case never asks which toolkit it is on. A case is also skipped, not failed, when a kind it proves renders a placeholder: the piece has no renderer on that toolkit (a native stepper on iOS, a map off the Apple platforms)..shot(name)takes a capture once the page shows, before the drive;d.shot(name)takes one mid-drive..timeout(secs)is how long the case may take, page and drive together; unset, the run’s limit applies (30 s, orday test --case-timeout). A case past its limit fails as timed out and the run moves on.
Drive speaks dayscript’s vocabulary: tap, input, toggle, set_value, select,
focus, submit, navigate, wait_idle, pause, shot, assert_text, assert_visible,
assert_missing, assert_value, assert_on, assert_focused, assert_route, a11y_audit,
assert_native (and assert_enabled, its enabled shorthand), assert_frame (and
assert_size), sample_pixel, assert_opened_url, and for lists, trees and navigation
activate, reorder, delete_row, swipe_row, scroll_to, expand, tree_move,
nav_back; for chrome and dialogs context_menu, menu, toolbar_press, toolbar_toggle,
dialogs_scripted, assert_presented, respond; for input tap_at, drag, hover,
hover_leave, pan, pinch, key; and assert_hidden, assert_announced.
Each is the dayscript step of that name, run in process with the step’s own retry window, so a
drive and a script mean the same thing by the same words; a step dayscript lacks is added to the
engine, where a script gets it as well. For a headless body, check(ok, what) and
check_eq(got, want) are the assertions. A failed op or check ends the test with its message.
Native checks
Day’s own assertions read Day’s tree, so on their own they prove the binding and the event
path but not that the platform’s widget shows what Day thinks it shows. In a conformance run,
assert_text, assert_value and assert_on are therefore each followed by assert_native of
the same fact, read back from the widget through Toolkit::read_native: its displayed text,
its value, its checked state (a toggle’s, or a button’s selected state). d.assert_native(id, NativeExpect { .. }) checks what those do
not cover (enabled, visible), and d.assert_enabled(id, on) checks enabled in Day and
natively at once.
assert_frame checks an element’s size and its origin relative to another element (or the
window content) in Day’s layout and in the native frame. sample_pixel reads one point of an
in-process capture, given as fractions of the element’s frame, and compares it with a
#rrggbb within a tolerance that absorbs color management (a Display P3 capture reads sRGB
red as #ea3323) but not a different color; the engine decodes the capture with its own small
PNG reader rather than linking a decoder into every app. While a run goes on, open_url (a
link, day::open_url) records the URL instead of opening it, so assert_opened_url can check
it and no browser starts on the machine running the tests.
A field a toolkit cannot read (a secure field’s masked text, a toolkit with no capture,
anything a backend has no getter for) does not fail the case: the report lists it as native_unread ("<id> <field>"), so the
conformance.json shows exactly what was proven natively and what only in Day’s tree.
The test host
A GUI case’s page needs somewhere to show. An app’s test build roots its content in
day::test_host(..):
pub fn root() -> impl Piece {
day::test_host(|| app_root())
}
The host shows the app’s own content until a run starts; then it shows each driven case’s page alone, built fresh for the case and disposed when the next one starts, so one case’s ids, signals and native views never meet another’s. When the run ends the app’s content returns. A GUI case in an app with no test host fails with that said.
When a case panics
A panic in a case’s drive, its headless body, or while its page is built fails that case with
the panic’s message, and the run goes on to the next case. Where the target aborts on panic
instead of unwinding (the web), the app still goes down and day test reports the lost run.
Where tests go
A piece’s cases go in a mod conformance at the end of the file that defines it, the way unit
tests go in mod tests, behind the crate’s conformance feature so a shipping app links none
of them:
// crates/day-pieces/src/leaves.rs, after the constructors
#[cfg(feature = "conformance")]
pub(crate) mod conformance {
use day_core::conformance::{Case, Drive};
use crate::*;
#[day_macros::test(day_core)]
fn button_press() -> Case { /* … */ }
}
- Names are
<piece>_<aspect>(button_press,text_field_secure), soday test 'button-*'selects a piece. A modifier’s cases use the modifier’s name (padding_insets). - One aspect per case, named for it, so a failure reads as a sentence.
- Ids only need to be unique within the case: the test host shows one case at a time.
- Declare each test once, with the attribute. There is no per-module roster or startup
registration call.
crates/day-pieces/src/conformance.rsholds the cases that belong to no piece and the conformance app’s browsing page. - What a case proves is declared with
.proves(kinds::X),.proves_modifier("padding"),.proves_cap(Cap::X)and.proves_duty("x").scripts/ci/conformance-coverage.pyreads those and reports, per family, what is proven and what is not. Itslint.shleg gates the families already complete (--require kinds,pieces: every built-in kind and every piece crate has a case, so a new one without one fails lint) and reports the rest;--checkgates them all once every family is covered.
The pieces in this repository (pieces/*) carry their cases the same way, in a
pub mod conformance behind each crate’s own conformance feature. The conformance app depends
on every one of them with that feature on. A dependency used only for its tests must enter
rustc’s dependency graph with use provider_crate as _; on every target. A piece from another repository is that repository’s to test; the conformance app
takes none. The coverage script counts a piece crate as proven when it has cases, and lint.sh
gates that family with the kinds (--require kinds,pieces).
An app’s own tests take the same shape in its own crate, with #[day::test], behind a feature
its test build turns on.
Registration
#[day::test] is the only test declaration. Native targets retain their static linkme
slice; WASM uses inventory constructors. A crate below day uses
#[day_macros::test(day_core)]. Public case names still replace underscores with hyphens;
discovery identities also include the package, version and module path. Duplicate compiled
case names fail before filtering. The runtime register API is for synthetic harness cases.
On WASM the attribute emits a descriptor export. Its address references the descriptor, so
rustc requires the object’s inclusion. The Day CLI acts as the final linker driver, records
these required exports before invoking rust-lld, then appends a versioned
day_test_expectations custom section to the output. This handles target cfg and LTO without
source scanners, archive parsing, a second compilation, or handwritten test names. Rustc’s
export contract and runtime inventory are independent: a retained descriptor does not hide
an omitted registration constructor.
The DOM host installs the manifest and invokes constructors once before app startup. tests
and run_tests compare exact compiled identities before any filter or capability skip. Missing,
unexpected or duplicate entries fail nonretryably, with the missing names and no executed tests.
Absent/malformed expectations fail closed, including when a packaging tool strips the custom
section. Custom WASM hosts must install the section with
day_core::conformance::install_web_registry. Bare Cargo builds lack the audited manifest and
cannot run WASM conformance tests; use day build. Ordinary app functionality is unaffected.
An entire crate removed from the compiler graph cannot contribute an export. Test-suite apps
can require providers in Cargo.toml; apps/conformance does this for every contributing crate:
[package.metadata.day.conformance]
required-providers = ["my-test-app", "my-widget-crate"]
A required provider with no test exports fails the web build, including an accidentally disabled
feature or missing use crate as _. This is a suite-level dependency contract, not a catalog of
individual tests. Tests intentionally disabled by cfg are absent from expectations. Removing a
provider from both Cargo and this contract is an explicit source change to review.
No whole-archive linking, forced LTO or codegen-unit restriction is needed. Inventory adds static nodes and constructor code, performs registration once, and traverses its list for discovery; it does not allocate per-entry nodes or add work to the rendering loop. Tests behind a disabled conformance feature contribute neither descriptors nor constructors. Other Day registries keep their existing implementations.
scripts/ci/conformance-registry.py exercises real WASM artifacts in debug, one-codegen-unit
debug, ordinary release and stripped full-LTO release. It covers unused dependency modules,
registration inside an uncalled generic helper, cfg on both sides of the attribute, repeated
initialization, missing/corrupt expectations, and deliberate loss of a constructor and of an
entire provider. The loss cases must fail; a green run of the surviving tests is insufficient.
Running tests
Under cargo test, on the mock
cargo test -p day-script --test conformance boots the mock toolkit with the conformance
app’s content and runs every registered case through the real engine. A case’s page builds,
its drive runs, its assertions read the same probe a script reads.
The harness turns on the mock’s native behavior (MockProbe::set_native_behavior): what a
native toolkit does unasked, which a unit test otherwise drives by hand. Lists and trees bind a
window of rows (on attach, after a reload, around a scrolled-to row), a cover reports its
size when presented and that it is hidden once dismissed, and the mock claims Cap::Toolbar and
Cap::Dialogs, whose edits and presentations it records. Those binds and reports happen at
the next sleep, where Day’s tree is free, so an op that looks for a row too early finds it on
its retry.
day test, on a toolkit
day test -t macos-appkit # every test in the project
day test -t macos-gtk 'text-field-*' # a glob over test names
day test -t ios-uikit --ios-simulator <udid> # the device flags day launch takes
day test -t android-mdc --shots always # keep every capture, not only a failure's
day test -t linux-gtk --case-timeout 0 # no per-case limit (a debugger attached)
day test -t web-dom --list # the registry, without running
day test is a launch with one generated script: it builds the project for the target
(--skip-build reuses the last build), launches it, sends the engine a run_tests step and
prints one line per test:
button-press ............................ ok (95 ms)
slider-range ............................ skipped Cap::Animation is Unsupported on this toolkit
text-field-secure ....................... FAILED assert_text tfs-len: "13" ≠ "12"
8 tests: 6 passed, 1 skipped, 1 failed
Results build/day/screenshots/macos-appkit/default/conformance.json
A failed test fails the run the way a failed script step does (exit 5). With no --project,
day test runs the project in the current directory; from the day checkout,
cargo run -q -p day-cli -- --project apps/conformance test -t <target> runs Day’s own cases.
--shots is on-failure by default: a failing test’s failed.png shows what the screen held
when the assertion failed.
The engine steps
Two dayscript steps carry it, usable from any script:
| step | fields | what it does |
|---|---|---|
tests | — | answers the registered tests (name, kind, what each proves and requires) in the reply’s data |
run_tests | filter? (globs), shots? (never, on-failure, always), timeout_secs?, case_timeout_secs? | runs the matching tests as one main-loop task; retryable while running, so the runner’s wait loop polls it; case_timeout_secs is each case’s limit where it sets none, 0 for none; the reply’s data is the report |
apps/conformance/dayscript/conformance.yaml is the whole run as a script, which is what CI
drives.
In VS Code
The Day extension lists every #[day::test] function under Day Tests in the
Test Explorer, with the play icon in the gutter beside it and one row per target ticked in the
Day view. Running the function runs day test for each of those targets and puts the verdicts on
the rows; a failure’s message carries the assertion and links its failed.png. Debug Test on
a desktop target hands the app to the installed Rust debugger with its engine open, and runs the
tests inside it: a breakpoint in the app holds the run. The extension’s own
testing page has the details.
What a run leaves
Beside the run’s screenshots, the same place a dayscript run writes:
build/day/screenshots/<target>/<variant>/conformance.json
build/day/screenshots/<target>/<variant>/tests/<test>/<shot>.png
build/day/screenshots/<target>/<variant>/tests/<test>/failed.png # on a failure
conformance.json is the contract between the CLI and whatever reads a run; there is no
command to produce or merge it, only this layout (with a device profile, <target>/<device>/<variant>/):
{
"schema": 1,
"target": "ios-uikit", "device": null, "variant": "default",
"day": "0.5.0 (release, branch main, 3fd74cc2)", "commit": "3fd74cc2…", "run": "18522271", "at": 1791304466,
"tests": {
"button-press": { "kind": "gui", "verdict": "pass", "ms": 260, "proves": ["kind:day.button"], "shots": ["default"] },
"text-field-secure": { "kind": "gui", "verdict": "fail", "ms": 840, "proves": ["kind:day.text_field", "duty:set_input_traits"],
"message": "assert_text tfs-len: \"7\" ≠ \"6\"", "shots": ["masked", "shown", "failed"] },
"slider-range": { "kind": "gui", "verdict": "skip", "reason": "Cap::Animation is Unsupported on this toolkit", "proves": ["kind:day.slider"], "shots": [] }
}
}
commit and run come from the CI environment (GITHUB_SHA, GITHUB_RUN_ID) and are absent
on a laptop rather than guessed; at is Unix seconds. caps holds what the toolkit answered for
every Cap during the run (N, E, -), which CI holds against the declared matrix.
The conformance app
apps/conformance is the app day test runs Day’s own cases in. It is almost empty: its
root is a test host around a nav over every registered GUI case, one route per case, so a
person can open any case on any toolkit and look at it, while a run shows each case alone. It
depends on day by path with the conformance feature, so it is built against the commit it
sits in and never against a published Day.
Only its own files are committed: Cargo.toml, src/, dayscript/, scripts/ and generate.sh. The
scaffold around them (Day.toml, build.rs, resource/, the platform/ host projects) is
day new app output, which generate.sh writes from the checkout’s own template, so the app
never runs on a host project an older template wrote. Run it once after a clone and again
after a template change; CI runs it on every leg.
Native ArkUI long-press tests
apps/conformance/scripts/harmony-long-press.py checks the existing
GestureKind::LongPress toolkit hook with real OS touch input. After building the conformance
app for harmony-arkui, run it with one connected, unlocked emulator/device and hdc on PATH:
target/debug/day --project apps/conformance build -t harmony-arkui
python3 apps/conformance/scripts/harmony-long-press.py
# Use another CLI binary with --day /path/to/day; --hdc selects the hdc executable.
The driver launches an opt-in root (DAY_NATIVE_LONG_PRESS_TEST=1), obtains coordinates from
the native UI hierarchy, and injects touches using the SDK’s uinput tool. Day’s engine only
controls fixture buttons and reads assertions; it never synthesizes a long-press event.
Checks cover short taps, one callback per hold despite repeated registration, local point
coordinates at the actual display density, scroll cancellation, disposal during a pending
hold, removal from the acceptance handler, repeated remounting, and native list drag arbitration
(the child hook owns its hit region; the rest of the row still drags). This is a separate
native-input suite, not a registered day test case or part of the shared CI conformance run; a synthetic
event test would not prove the recognizer works. The fixture does not introduce a public
.on_long_press API. Normal launches without the environment flag retain the usual test host.
Do not run this alongside another driver for the same app. It leaves the fixture visible for
inspection; a normal day launch or day test restores the regular conformance app.
Native ArkUI dialog tests
After building the conformance app for harmony-arkui, run:
python3 apps/conformance/scripts/harmony-dialogs.py
This requires one connected, unlocked HarmonyOS target and hdc on PATH; --day and
--hdc can select other executables. It launches DAY_NATIVE_DIALOG_TEST=1 and drives actual
ArkUI dialog buttons, native text editing, IME submit, Back and the modal mask. Checks cover
startup presentation, Unicode/quoted titles, prompt initial/empty/cancelled values,
action-sheet indices and safe-area placement, concurrent requests, queued dismissal,
closing during opening, immediate chained presentations, and light/dark surfaces. The fixture
remains visible afterwards.
The portable confirm-native-*, prompt-native-*, and alert-sheet-preserves-button-indices
cases run in the regular conformance suite on targets with Cap::Dialogs. They explicitly
select native presentation and answer through respond / respond_text, exercising the
public future and toolkit dismissal contract. Those assertions inspect pending Day requests;
they do not prove a native button was drawn or clicked. The separate Python suite supplies
that evidence and is currently an explicit local/emulator check, not a shared CI step.
Native ArkUI text-area tests
After building the conformance app for harmony-arkui, run:
python3 apps/conformance/scripts/harmony-textarea.py
Requires one unlocked target and hdc on PATH (--day/--hdc override executables). The
DAY_NATIVE_TEXTAREA_TEST=1 fixture starts read-only. OS input checks typing/deletion/newlines,
copy/cut/paste, app writes, reactive editing/selection changes, long-press selection, keyboard
selection, and unmount/remount. Assertions read both the native TextArea and the bound model.
Only fixture buttons and assertions use Day’s driver; no text events are injected.
Run this separately from other drivers for the same app. It leaves the fixture visible; normal
day launch/day test restores the test host. The portable text-area-attributes-reactive case
checks content preservation through policy changes on supporting backends. This native-input
suite supplies the OS-input evidence and is an explicit emulator check, not a shared CI step.
For TextArea .on_submit(...), run the companion suite:
python3 apps/conformance/scripts/harmony-textarea-submit.py
It launches DAY_NATIVE_TEXTAREA_SUBMIT_TEST=1 and uses real Enter/Shift+Enter keys, native paste,
and the soft keyboard’s Send button. It checks callback counts and current bound text,
Unicode selection/caret handling, native undo/redo, read-only suppression, callbacks that clear
or remove the editor, remounting, and ordinary multiline editors. The soft-keyboard check requires an IME
whose Send button is exposed as Send in the native accessibility tree (the test emulator’s
English keyboard); it never substitutes a synthetic submit event for that check.
The portable text-area-submit case checks the public callback and clearing contract using
injected text/submit events on every backend. As with text-field submission, this portable
case alone cannot prove native keyboard support.
Run only one driver for the app at a time.
Native ArkUI link tests
After building the conformance app for harmony-arkui, run:
python3 apps/conformance/scripts/harmony-links.py
The script launches DAY_NATIVE_LINK_TEST=1 on the connected emulator. It uses OS taps in
native Text bounds for every link activation; Day’s driver only operates fixture controls
and checks results. It verifies multiple targets in one label, Unicode targets, plain text
and trailing whitespace, first/last wrapped lines, selectable labels, TextBuilder links,
repeated target changes, linked/plain transitions, removal/remounting and default navigation.
The fixture uses fixed text and font sizes; run at the emulator’s default font scale. These
tests do not claim keyboard traversal or screen-reader coverage. Run separately from other
drivers for the app. A normal day launch or day test restores the regular test host.
The portable label-link-runs-reactive conformance case checks native text preservation
through rebuilding links and switching to/from plain text on all supporting targets. Existing
mock tests cover the shared LinkActivated handler, custom-handler and route policies.
In CI
ci.yml’s conformance job runs the app through daybrite/actions’ dayapp.yml on all nine
targets, with the run’s own CLI, one phone profile per mobile OS, and dayscript/conformance.yaml
as the script; its setup-command is generate.sh, run with that CLI. Each leg uploads its screenshots/ tree (so conformance.json and the captures
ride along) as conformance-screenshots-<target>[-<slug>]; conformance-results merges the
conformance.json files into one object keyed by the path each was written at
(<target>[/<device>]/<variant>), copies the captures beside it under shots/, and uploads the
pair as the conformance-results artifact. A failed test fails its leg.
The same job then runs scripts/ci/conformance-claims.py over the merged results. Each leg’s
caps (what its toolkit answered during the run) is held against
docs/coverage-matrix.md, which is generated from source: a disagreement
fails the job, naming the target and capability, because the published matrix would then be
wrong for that target (a ? cell, decided at run time, accepts any answer and reports it). The
script also writes verified-matrix.md into the artifact: per built-in kind and target, ✓
where every case proving the kind passed, ✗ where one failed, · where all were skipped. The screenshot
artifacts carry the caller’s artifact-prefix since 2026-10, which is what lets this call of
the workflow sit beside the showcase’s in one run.
What a pass means
A pass says the toolkit realized the pieces, the drive’s events reached the app’s signals, the
assertions held on day-core’s state, and the native widget reports the same text, value and
state, except for the fields conformance.json lists as native_unread. It does not say native
INPUT would have produced the same: the drive’s events are injected, as a script’s are. The
native reads are assert_native, a11y_audit (where the toolkit reads its accessibility tree
back), assert_no_placeholders, and the captures. Captures are illustration; a case asserts
effects, never looks.
Known gaps
What a pass does not yet cover, so a skip or an unread field reads as recorded:
- No capture on the web (
Cap::Snapshotis unsupported on DOM), so the pixel cases (shapes, backgrounds, opacity, transforms, overlays) skip there. Reading the computed styles throughread_nativeinstead is the planned answer. - No accessibility group on GTK and ArkUI: GTK 4 has no public getters for an accessible’s
label and value, and ArkUI’s accessibility text is empty unless Day set it, so
a11y_auditskips their nodes. - ArkUI’s menu and segmented pickers are built in ArkTS and report no text.
- Where Day’s content sits in a capture:
sample_pixelmaps a node’s frame into the in-app capture throughToolkit::snapshot_origin. Android answers it (its content capture runs under the status and navigation bars); elsewhere, rows beyond Day’s content are taken to be above it, true of AppKit’s title bar and the mobile status bar. - Input is injected, by design. Every input op delivers Day’s own event, the stream a
native recognizer would, so a gesture case proves the routing and the app’s handling, not
that the platform’s recognizer fires on a real press, pan or pinch. Driving real platform
input from a run (posted events,
adb input, Playwright) was considered and set aside: each platform needs its own driver, and those drivers are the flakiest part of any UI test harness. - Dialogs are answered through
respond, which resolves the request and has the toolkit dismiss the dialog it showed: a pass provespresentanddismissran on the toolkit and the answer came back, not that the dialog drew. A system dialog is its own window, out of reach of the in-app capture on several platforms. - Context menus and toolbars are driven through Day’s own model of them, the dispatch a native choice goes through; the native menu never opens, and on a phone a toolbar declared without a navigation host has no native bar to appear on.
- Visibility reads hidden flags and window membership; a view with opacity 0 or clipped away still reports visible.
Follow-ups
- The website’s coverage pages and per-piece galleries, read from the
conformance-resultsartifact of the latest green run onmain. --watchand a reusable detached app between runs; a--baselinecomparison against the last published results.- A scratch directory for headless tests, on the app’s own data directory per platform.
- The remaining built-in pieces’ cases.