Skip to main content

iced_test/
lib.rs

1//! Test your `iced` applications in headless mode.
2//!
3//! # Basic Usage
4//! Let's assume we want to test [the classical counter interface].
5//!
6//! First, we will want to create a [`Simulator`] of our interface:
7//!
8//! ```rust,no_run
9//! # struct Counter { value: i64 }
10//! # impl Counter {
11//! #    pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
12//! # }
13//! use iced_test::simulator;
14//!
15//! let mut counter = Counter { value: 0 };
16//! let mut ui = simulator(counter.view());
17//! ```
18//!
19//! Now we can simulate a user interacting with our interface. Let's use [`Simulator::click`] to click
20//! the counter buttons:
21//!
22//! ```rust,no_run
23//! # struct Counter { value: i64 }
24//! # impl Counter {
25//! #    pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
26//! # }
27//! # use iced_test::simulator;
28//! #
29//! # let mut counter = Counter { value: 0 };
30//! # let mut ui = simulator(counter.view());
31//! #
32//! let _ = ui.click("+");
33//! let _ = ui.click("+");
34//! let _ = ui.click("-");
35//! ```
36//!
37//! [`Simulator::click`] takes a type implementing the [`Selector`] trait. A [`Selector`] describes a way to query the widgets of an interface.
38//! In this case, we leverage the [`Selector`] implementation of `&str`, which selects a widget by the text it contains.
39//!
40//! We can now process any messages produced by these interactions and then assert that the final value of our counter is
41//! indeed `1`!
42//!
43//! ```rust,no_run
44//! # struct Counter { value: i64 }
45//! # impl Counter {
46//! #    pub fn update(&mut self, message: ()) {}
47//! #    pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
48//! # }
49//! # use iced_test::simulator;
50//! #
51//! # let mut counter = Counter { value: 0 };
52//! # let mut ui = simulator(counter.view());
53//! #
54//! # let _ = ui.click("+");
55//! # let _ = ui.click("+");
56//! # let _ = ui.click("-");
57//! #
58//! for message in ui.into_messages() {
59//!     counter.update(message);
60//! }
61//!
62//! assert_eq!(counter.value, 1);
63//! ```
64//!
65//! We can even rebuild the interface to make sure the counter _displays_ the proper value with [`Simulator::find`]:
66//!
67//! ```rust,no_run
68//! # struct Counter { value: i64 }
69//! # impl Counter {
70//! #    pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
71//! # }
72//! # use iced_test::simulator;
73//! #
74//! # let mut counter = Counter { value: 0 };
75//! let mut ui = simulator(counter.view());
76//!
77//! assert!(ui.find("1").is_ok(), "Counter should display 1!");
78//! ```
79//!
80//! And that's it! That's the gist of testing `iced` applications!
81//!
82//! [`Simulator`] contains additional operations you can use to simulate more interactions—like [`tap_key`](Simulator::tap_key) or
83//! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)!
84//!
85//! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface
86pub use iced_futures as futures;
87pub use iced_program as program;
88pub use iced_renderer as renderer;
89pub use iced_runtime as runtime;
90pub use iced_runtime::core;
91
92pub use iced_selector as selector;
93
94pub mod emulator;
95pub mod ice;
96pub mod instruction;
97pub mod simulator;
98
99mod error;
100
101pub use emulator::Emulator;
102pub use error::Error;
103pub use ice::Ice;
104pub use instruction::Instruction;
105pub use selector::AccessibleMatch;
106pub use selector::Selector;
107pub use selector::{Bounded, FindAll, Target, by_label, by_role, id, is_focused};
108pub use simulator::{Simulator, simulator};
109
110use crate::core::Size;
111use crate::core::theme;
112use crate::core::time::{Duration, Instant};
113use crate::core::window;
114
115use std::path::Path;
116
117/// Runs an [`Ice`] test suite for the given [`Program`](program::Program).
118///
119/// Any `.ice` tests will be parsed from the given directory and executed in
120/// an [`Emulator`] of the given [`Program`](program::Program).
121///
122/// Remember that an [`Emulator`] executes the real thing! Side effects _will_
123/// take place. It is up to you to ensure your tests have reproducible environments
124/// by leveraging [`Preset`][program::Preset].
125pub fn run<P: program::Program + 'static>(
126    program: P,
127    tests_dir: impl AsRef<Path>,
128) -> Result<(), Error> {
129    use crate::futures::futures::StreamExt;
130    use crate::futures::futures::channel::mpsc;
131    use crate::futures::futures::executor;
132
133    use std::ffi::OsStr;
134    use std::fs;
135
136    let errors_dir = tests_dir.as_ref().join("errors");
137
138    if errors_dir.exists() {
139        fs::remove_dir_all(&errors_dir)?;
140    }
141
142    let files = fs::read_dir(tests_dir)?;
143    let mut tests = Vec::new();
144
145    for file in files {
146        let file = file?;
147
148        if file.path().extension().and_then(OsStr::to_str) != Some("ice") {
149            continue;
150        }
151
152        let content = fs::read_to_string(file.path())?;
153
154        match Ice::parse(&content) {
155            Ok(ice) => {
156                let preset = if let Some(preset) = &ice.preset {
157                    let Some(preset) = program
158                        .presets()
159                        .iter()
160                        .find(|candidate| candidate.name() == preset)
161                    else {
162                        return Err(Error::PresetNotFound {
163                            name: preset.to_owned(),
164                            available: program
165                                .presets()
166                                .iter()
167                                .map(program::Preset::name)
168                                .map(str::to_owned)
169                                .collect(),
170                        });
171                    };
172
173                    Some(preset)
174                } else {
175                    None
176                };
177
178                tests.push((file, ice, preset));
179            }
180            Err(error) => {
181                return Err(Error::IceParsingFailed {
182                    file: file.path().to_path_buf(),
183                    error,
184                });
185            }
186        }
187    }
188
189    // TODO: Concurrent runtimes
190    for (file, ice, preset) in tests {
191        let (sender, mut receiver) = mpsc::channel(1);
192
193        let mut emulator = Emulator::with_preset(sender, &program, ice.mode, ice.viewport, preset);
194
195        let mut instructions = ice.instructions.iter();
196        let mut current = 0;
197
198        loop {
199            let event = executor::block_on(receiver.next())
200                .expect("emulator runtime should never stop on its own");
201
202            match event {
203                emulator::Event::Action(action) => {
204                    emulator.perform(&program, action);
205                }
206                emulator::Event::Failed(instruction) => {
207                    fs::create_dir_all(&errors_dir)?;
208
209                    let theme = emulator
210                        .theme(&program)
211                        .unwrap_or_else(|| <P::Theme as theme::Base>::default(theme::Mode::None));
212
213                    let screenshot = emulator.screenshot(&program, &theme, 2.0);
214
215                    let image = fs::File::create(
216                        errors_dir.join(
217                            file.path()
218                                .with_extension("png")
219                                .file_name()
220                                .expect("Test must have a filename"),
221                        ),
222                    )?;
223
224                    let mut encoder =
225                        png::Encoder::new(image, screenshot.size.width, screenshot.size.height);
226                    encoder.set_color(png::ColorType::Rgba);
227
228                    let mut writer = encoder.write_header()?;
229                    writer.write_image_data(&screenshot.rgba)?;
230                    writer.finish()?;
231
232                    let reproduction = Ice {
233                        viewport: ice.viewport,
234                        mode: ice.mode,
235                        preset: ice.preset,
236                        instructions: ice.instructions[..current].to_vec(),
237                    };
238
239                    fs::write(errors_dir.join(file.file_name()), reproduction.to_string())?;
240
241                    return Err(Error::IceTestingFailed {
242                        file: file.path().to_path_buf(),
243                        instruction,
244                    });
245                }
246                emulator::Event::Ready => {
247                    let Some(instruction) = instructions.next() else {
248                        break;
249                    };
250
251                    emulator.run(&program, instruction);
252                    current += 1;
253                }
254            }
255        }
256    }
257
258    Ok(())
259}
260
261/// Takes a screenshot of the given [`Program`](program::Program) with the given theme, viewport,
262/// and scale factor after running it for the given [`Duration`].
263pub fn screenshot<P: program::Program + 'static>(
264    program: &P,
265    theme: &P::Theme,
266    viewport: impl Into<Size>,
267    scale_factor: f32,
268    duration: Duration,
269) -> window::Screenshot {
270    use crate::runtime::futures::futures::channel::mpsc;
271
272    let (sender, mut receiver) = mpsc::channel(100);
273
274    let mut emulator = Emulator::new(sender, program, emulator::Mode::Immediate, viewport.into());
275
276    let start = Instant::now();
277
278    loop {
279        if let Ok(event) = receiver.try_recv() {
280            match event {
281                emulator::Event::Action(action) => {
282                    emulator.perform(program, action);
283                }
284                emulator::Event::Failed(_) => {
285                    unreachable!("no instructions should be executed during a screenshot");
286                }
287                emulator::Event::Ready => {}
288            }
289        }
290
291        if start.elapsed() >= duration {
292            break;
293        }
294
295        std::thread::sleep(Duration::from_millis(1));
296    }
297
298    emulator.screenshot(program, theme, scale_factor)
299}