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

GroupSteps
Waitingwait_for (an id appears; timeout_secs raises its budget), wait_idle, pause
Actingtap (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 fromto)
Navigationnavigate, nav_back, assert_route
Window chromemenu (item/key/path), toolbar (item, plus text/key or on), close_window (window)
Assertingassert_visible, assert_text (timeout_secs raises its budget), assert_value, assert_focused, assert_no_placeholders (allow lists expected gaps)
Web viewsweb_eval (id, script, text/contains; timeout_secs raises its budget for cold engine startup or slow page loads)
Dialogsassert_presented, respond (a button index, prompt text, file path, or dismiss)
Evidencescreenshot (window captures a secondary window), a11y_audit
Exitexpect_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, day turns 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,ar builds 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.yaml after each relaunch puts you back on the screen you’re editing.
  • Accessibility audits: the a11y_audit step 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:

targethow
linux-gtk, macos-gtk, windows-gtkrenders its own snapshot through a scale transform, on any display, a 1× xvfb included
linux-qt, macos-qt, windows-qtrenders the widget into a pixmap with that device pixel ratio
web-domthe driver browser’s viewport and device scale factor
macos-appkitreads 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-xamlreads 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.