Testing with dayscript
dayscript is Day’s YAML format for testing a running app. A script can navigate, enter text, tap controls, check results, and capture screenshots. The engine runs inside the app and finds controls by stable identifiers, so a walkthrough can be reused across targets. Wait steps check conditions rather than depending on fixed delays.
A script
Give the controls you want to test stable IDs. For the counter from Getting started, add IDs to the label and button:
label(move || format!("Count: {}", count.get())).id("counter-label"),
button("Add one")
.action(move || count.update(|n| *n += 1))
.id("increment-button"),
Keep these two pieces inside the existing column. Save this script as
dayscript/counter.yaml:
name: counter
flow:
- wait_for: { id: increment-button }
- tap: { id: increment-button }
- assert_value: { id: counter-label, value: "Count: 1" }
- screenshot: counter
The script waits for the button, presses it once, checks the label, and captures the result. It assumes a fresh launch with the count at zero.
Run it on a configured target:
day launch -p macos-appkit --script dayscript/counter.yaml
day launch -p android-mdc --script dayscript/counter.yaml
day launch builds, starts the app with scripting enabled, executes the steps, and
exits nonzero if any assertion fails (exit code 5). Screenshots land under
build/day/screenshots/<target>/<subdir>/, where the subdirectory is the --variant name when
given, else the locale, else default. Several --script flags run in sequence, and
--locale makes the run a localization test at the same time; assertions can reference Fluent
keys instead of literal strings, so the same script passes in every language.
The step vocabulary
| Group | Steps |
|---|---|
| Waiting | wait_for (an id appears; timeout_secs raises its budget), wait_idle, pause |
| Acting | tap (repeat), input, set_value, toggle, select, activate (invoke a list row without changing selection), submit, focus, scroll_to (to an edge, an x/y offset, or an element to reveal), reorder (list row from → to) |
| Navigation | navigate, nav_back, assert_route |
| Window chrome | menu (item/key/path), toolbar (item, plus text/key or on), close_window (window) |
| Asserting | assert_visible, assert_text (timeout_secs raises its budget), assert_value, assert_focused, assert_no_placeholders (allow lists expected gaps) |
| Web views | web_eval (id, script, text/contains; timeout_secs raises its budget for cold engine startup or slow page loads) |
| Dialogs | assert_presented, respond (a button index, prompt text, file path, or dismiss) |
| Evidence | screenshot (window captures a secondary window), a11y_audit |
| Exit | expect_exit (the app must terminate within within seconds; always the last step) |
input, assert_text, and toolbar accept a Fluent key (with args) in place of literal
text, resolved in the run’s locale, so one script passes in every language.
nav_back reads the window’s width class the same way the app does. A window wide enough to keep
the detail beside the list never pushed a page, so the step passes there without moving anything,
while a compact window still fails when it finds nothing to pop. One script can therefore drive a
phone and a tablet, since the same build stacks on one and splits on the other.
A bare nav_back pops the navigation model and updates the toolkit. nav_back: { native: true } presses the platform’s back instead — the navigation bar’s button on iOS, the
system back on Android — so the toolkit handles the back action: the bar’s veto for a guarded
page, the native pop, and the report back to Day. That is the code a real tap runs and the bare
step never reaches, which is why a walkthrough that guards a page should back out of it both
ways. Desktops have no native back for a nested stack, so gate the step only_on: [uikit, mdc].
The native form waits for the toolkit’s transitions to settle before pressing, as screenshot
does, so it never races the push it is meant to undo.
Every locating step waits (bounded, five seconds by default) rather than failing instantly, so scripts need no hand-tuned sleeps. Acting steps synthesize Day events on the main thread between reactive updates. Native behavior can still differ between toolkits, so run the walkthrough on the targets you intend to ship. Target elements by ids you know to be interactive, and scroll explicitly when a step needs an element brought into view.
Any step can be gated per target: skip_on: drops it on the named targets or toolkits (skip_on: [web-dom]), and only_on: is its mirror, for a step whose expectations differ per target (an
assert_no_placeholders allow list, say). One walkthrough then covers every
backend.
The same two gates match a build flavor, written flavor:<name>, with
flavor:none for the base app. A paid build and a free one then share one walkthrough:
- assert_text: { id: welcome-title, text: "Welcome to Notes", skip_on: [flavor:paid] }
- assert_text: { id: welcome-title, text: "Welcome to Notes Pro", only_on: [flavor:paid] }
How it works
The engine lives in day-script, compiled into your app. It activates only when the launcher enables it: the
launcher passes a localhost port and a one-time token through the environment; without them the
engine never binds a socket, in debug or release. Steps arrive as JSON over that socket and
execute on the main thread between reactive flushes:
day launch --script … your app process
┌───────────────┐ localhost ┌────────────────────────────────┐
│ script runner │ ───────────► │ day-script engine │
│ (in the CLI) │ step + token│ id → node (day-core index) │
└───────────────┘ ◄─────────── │ synthesize Day event / assert │
reply: ok / error / png└────────────────────────────────┘
A tap runs the same action path a user’s tap would; input goes through the controlled-text
machinery; screenshot asks the toolkit for a native window snapshot. Because steps interleave
with the reactive turn, “wait until idle” has a real definition (the reactive queue is empty and
layout is clean) rather than a timeout heuristic.
What it’s for beyond tests
The same scripts serve several jobs:
- CI walkthroughs: every push builds the showcase on all targets and runs the walkthrough;
the gallery is those screenshots. A content-validation step catches blank captures.
On an Android emulator,
dayturns off the system’s “isn’t responding” and crash dialogs and its “Viewing full screen” hint, and closes any of them already on screen before each screenshot. One launch covers the whole appearance matrix:day launch --themes light,dark --locales en,fr,arbuilds once and runs the script per theme × locale, naming each variant’s screenshot directory after it. - Iteration: Day has no hot reload, so
--script goto-settings.yamlafter each relaunch puts you back on the screen you’re editing. - Accessibility audits: the
a11y_auditstep diffs the native accessibility tree against your declarations (details). - Agent verification: AI coding agents use dayscript to check their own work: write a change, run a script, read the assertions (for agents).
Capture size
A scripted run captures the desktop toolkits and the web build at a stated pixel size: 2560×1600 by default, which is a 1280×800-point window rendered at 2×. It is one of the four sizes the Mac App Store takes for a screenshot (1280×800, 1440×900, 2560×1600, 2880×1800), and every desktop-class target produces the same size, so a gallery row lines up across platforms. Phones and tablets capture their device’s own panel.
Change it per app in Day.toml:
[screenshots]
desktop-size = "2880x1800" # pixels; "window" captures at the app's own [window] size
desktop-scale = 2 # the window is desktop-size / desktop-scale points
or for one run, with a flag or an environment variable, which is how a CI workflow overrides it without editing the app:
day launch -p macos-appkit --script dayscript/walkthrough.yaml --capture-size 1440x900@1
DAY_CAPTURE_SIZE=window day launch -p linux-gtk --script dayscript/walkthrough.yaml
The flag wins over the variable, and the variable over Day.toml. A size has to be a whole
number of points at its scale; 2561x1600 is refused.
day launch passes the window to the app as DAY_WINDOW and the scale as DAY_CAPTURE_SCALE.
Set DAY_WINDOW yourself (--env DAY_WINDOW=500x640) and yours is kept, which is how a
narrow-layout run stays narrow. How each target reaches the scale:
| target | how |
|---|---|
linux-gtk, macos-gtk, windows-gtk | renders its own snapshot through a scale transform, on any display, a 1× xvfb included |
linux-qt, macos-qt, windows-qt | renders the widget into a pixmap with that device pixel ratio |
web-dom | the driver browser’s viewport and device scale factor |
macos-appkit | reads the window server’s pixels, so the scale is the display’s. Where no attached display has it (a CI runner’s is 1×), day launch creates a HiDPI virtual display for the run and the app opens its window there. DAY_CAPTURE_DISPLAY=native turns that off; =virtual forces it |
windows-xaml | reads the window’s real pixels at the desktop’s scale, frame included. At 100% scaling the default capture is 1280×800, which the Mac App Store also takes |
Recording
day::record captures the taps, edits, selections, row activations, and navigation an app receives and turns them
back into a dayscript. It observes the one point every backend funnels its events through, so it
needs no per-toolkit code.
Record headlessly from the CLI:
day launch -p macos-appkit --record recording.yaml
Drive the app by hand; recording.yaml is rewritten as you go and holds everything up to the last
action even if the app is killed. Because it’s an ordinary dayscript, you replay it on any target:
day launch -p android-mdc --script recording.yaml
Or record and replay inside the app. day::record::start_into(buffer) streams the script into a
Signal<String> you can bind a text_area to; day::play_script(&yaml) replays one in-process
through the same engine --script drives. The showcase’s Scripting page is a working example
(Record, move around, Stop, edit, Play). exclude_prefix keeps a UI’s own record and stop controls
out of its recording.
The recorder skips the same gestures the engine cannot inject. It captures actions on elements you gave ids; positional taps, slider drags, and native OS chrome are not recorded, so a recording is a starting point you edit rather than a pixel-exact replay.
Logging actions without recording
The same observer can log instead of capture. day::record::log_actions(true), or
DAY_LOG_ACTIONS=1 on any Day app without a rebuild, echoes every action to stdout in the same
vocabulary and keeps nothing:
dayscript ▸ navigate → dates "Date & time"
dayscript ▸ tap list-shuffle "Shuffle"
dayscript ▸ select unit-picker = 1 "Units"
The log shows the actions a recording would capture. Use it to preview a walkthrough or
include the interaction sequence in a bug report. The Showcase turns it on at launch; DAY_LOG_ACTIONS=0
silences it. Logging and recording are independent: start a recording underneath a log and each
action still prints once, with the prefix naming the mode (day record ▸).
Limits
dayscript can only see what Day owns. It cannot type through the native IME, verify the software
keyboard, drive OS permission prompts or file dialogs, or assert native animations. The project’s
practice is scripted coverage for everything Day-side plus a short manual pass per platform for
those native surfaces. Unit-level testing below the UI has a separate tool: the mock
toolkit runs your Pieces headlessly in cargo test.