Skip to content

Fast headless UI tests

Real mail server scenarios

Run python3 scripts/test_real_mail.py on Linux with Docker and OpenSSL. A scenario name substring selects a smaller run, for example python3 scripts/test_real_mail.py held_server. --profile dev uses the debug build for development; the default test-ui profile is optimised. No mailbox credentials or external server are required. These tests are explicitly ignored in ordinary Cargo runs, so routine unit tests remain offline.

Each scenario owns a digest-pinned GreenMail container with random loopback TLS ports, fictional accounts, an in-memory credential backend and a separate SQLite cache. The runner trusts the repository's localhost test certificate only in its child process. Production TLS and hostname checks stay enabled. GreenMail validates explicit SMTP AUTH but permits unauthenticated delivery by design; this suite does not establish production SMTP relay policy.

The real App receives widget input through the existing iced_test harness. Its engine uses production IMAP/POP3/SMTP, cache, journals, background checks and IDLE. Only workspace and credential construction are replaced. Independent IMAP reads check physical message counts, exact Message-IDs, UIDs, flags and attachment bytes. Tests do not dispatch mail commands or manufacture provider receipts.

Coverage includes receiving/search, read-on-leave, flags, Archive/Trash/Move and Undo, folder creation/move/delete with cancelled reviews, selection beyond the 50-row page, scoped bulk work, drafts across navigation/restart, SMTP/Sent, reply headers, forwarding attachments and compact dark controls. Stress scenarios repeat flag, move, Send and Refresh inputs, switch screens during pending work and preserve edited drafts when new mail arrives.

A bounded TLS relay can delay or hold actual server replies while the independent oracle keeps working. Tests first observe a held response, then exercise local navigation and saving. A slow multi-megabyte download runs beside cached reading. Input p95 has a fixed 100 ms headless budget. New mail must appear within five seconds after SMTP acknowledgement, with ordinary polling set to an hour to exercise push. These are input-to-state/layout and arrival observations, not native window presentation or monitor latency measurements. Run timing scenarios without competing builds; thresholds are never relaxed for host load.

Logs, fictional caches, state observations, snapshots and timing reports remain under ignored artifacts/. Failures retain their evidence; owned containers are removed on ordinary completion and panic. A force-killed test process cannot run Rust destructors, so its labelled container may require explicit cleanup.

first_download_preserves_active_composer_and_focus holds the first mail page while a new draft is being typed. It checks that arrival retains the composer, continues typing into the same Subject field without refocusing, and preserves the full draft through restart. Automatic first-row selection yields to a current composer; explicit mail selection still opens the chosen reader.

This suite does not cover formatted-HTML renderer subscriptions, OS pickers, printing, tray/badge delivery, Google/CalDAV, remote-provider behaviour or Windows/macOS execution. Existing native scenarios remain necessary for those surfaces. Restart tests reopen the app/cache in the same test process after the engine drains; they are not process-crash tests.

Fixture scenarios

The native suite in scripts/e2e.py drives the real window on Xvfb and takes most of an hour. A second, much faster layer runs the same app inside cargo test, with no display, using the official iced_test crate. It lives in src/ui/simulator_tests/ and needs the test-support feature, so cargo test --all-features and the pre-commit hook run it.

How it works

Harness::start() builds the real App, starts the demo engine on an in-memory fixture store (the same workspace desktop.start opens) and waits for the first mail page. Input goes through the actual widget tree: click_at(x, y) sends a pointer move, press and release; key("ctrl+comma") takes xdotool-style chords; type_text presses one key per character. The coordinates match the native scenarios at the same window size, because the layout code and bundled Noto fonts are the same.

The harness does what the iced runtime would do. It keeps one widget-state cache across rebuilds, so text focus and scroll positions survive between messages, redraws after each update, applies widget operations such as focus requests, and answers window queries. iced_test::Simulator itself rebuilds widget state for every instance, which would lose focus mid-scenario, so it is used for snapshots only. Selectors and input events come from iced_test.

Background work is real. Engine events and every Task the app returns are forwarded into queues and handled on the test thread. Tests wait for observed widget focus before typing, including controls whose focus task is delayed. The app's one-second Tick subscription is emulated during input and while checks wait. Assertions do not sleep for a fixed time: expect("dialog", "Move") reads the same observation the native harness writes to its state file and waits with the shared hang deadline. Performance checks have their own fixed budgets.

What belongs here

Scenarios whose value is in UI logic: preferences and their saves, settings search and reveal, shortcut capture, conflicts and disabling, focus guards on mail shortcuts, Move dialog input, drafts and context menus, and dropdown dismissal. Each one names the native scenario it mirrors.

Keep the native scenario as well. AGENTS.md still requires real-control evidence, and only the native suite proves X11 input, window-manager focus, presented pixels, file pickers, the tray, badges, printing and timing. HTML rendering is also native only: the harness does not run the HTML renderer subscriptions.

Snapshots

Harness::snapshot() renders through Simulator::snapshot. Pixel hashes depend on the host's fallback fonts, because iced's font system also loads system fonts, so no reference images or hashes are committed. Scenarios compare snapshots taken in the same run instead, for example that switching appearance back restores identical pixels.

Adding a scenario

Put it in the matching module (mail.rs, preferences.rs or shortcuts.rs) as a plain async fn, add its name to that module's scenarios! list, start from Harness::start() or Harness::with_size(900., 640.), and follow the native batch step by step. scenarios! runs each one through harness::run, whose runtime and thread have 8 MiB stacks: under #[tokio::test] the demo engine overflowed Tokio's default 2 MiB worker stack in unoptimised Windows builds. Fixture options that the native harness passes as command-line flags (mail_actions, long_folders and similar) are not available here yet; scenarios needing them stay native only.