From 5fc8741fb5c74b7903c141d44ab7ee4880878084 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 16 Oct 2025 16:15:06 +0200 Subject: [PATCH 1/5] Add `kittest.toml` config file --- Cargo.lock | 56 +++++++++++- Cargo.toml | 1 + crates/egui_kittest/Cargo.toml | 6 +- crates/egui_kittest/README.md | 27 ++++++ crates/egui_kittest/src/config.rs | 128 ++++++++++++++++++++++++++++ crates/egui_kittest/src/lib.rs | 2 + crates/egui_kittest/src/snapshot.rs | 43 ++++++++-- kittest.toml | 10 +++ 8 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 crates/egui_kittest/src/config.rs create mode 100644 kittest.toml diff --git a/Cargo.lock b/Cargo.lock index cecaf69354a..edcb91cfd36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,7 +1457,9 @@ dependencies = [ "kittest", "open", "pollster", + "serde", "tempfile", + "toml", "wgpu", ] @@ -4036,6 +4038,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "serial_windows" version = "0.1.0" @@ -4491,12 +4502,36 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "toml" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.24" @@ -4504,10 +4539,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.6.8", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tracing" version = "0.1.40" @@ -5645,9 +5695,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.3" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index dfdb595c302..d3650d1d830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,7 @@ syntect = { version = "5.3.0", default-features = false } tempfile = "3.23.0" thiserror = "2.0.17" tokio = "1.47.1" +toml = "0.9" type-map = "0.5.1" unicode_names2 = { version = "2.0.0", default-features = false } unicode-segmentation = "1.12.0" diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index a8141384065..d481c86c7e8 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -34,9 +34,11 @@ x11 = ["eframe?/x11"] [dependencies] -kittest.workspace = true -egui = { workspace = true, features = ["accesskit"] } eframe = { workspace = true, optional = true } +egui = { workspace = true, features = ["accesskit"] } +kittest.workspace = true +serde.workspace = true +toml.workspace = true # wgpu dependencies egui-wgpu = { workspace = true, optional = true } diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index b774572f600..8c3c4bc30b1 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -38,6 +38,33 @@ fn main() { } ``` +## Configuration + +You can configure test settings via a `kittest.toml` file in your workspace root. +All possible settings and their defaults: +```toml +# path to the snapshot directory +output_path = "tests/snapshots" + +# default threshold for image comparison tests +threshold = 0.6 + +# default failed_pixel_count_threshold +failed_pixel_count_threshold = 0 + +[windows] +threshold = 0.6 +failed_pixel_count_threshold = 0 + +[macos] +threshold = 0.6 +failed_pixel_count_threshold = 0 + +[linux] +threshold = 0.6 +failed_pixel_count_threshold = 0 +``` + ## Snapshot testing There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features. Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory. diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs new file mode 100644 index 00000000000..5a782cfa1cd --- /dev/null +++ b/crates/egui_kittest/src/config.rs @@ -0,0 +1,128 @@ +use crate::OsThreshold; +use std::io; +use std::path::PathBuf; + +/// Configuration for `egui_kittest`. +/// +/// It's loaded once (per process) by searching for a `kittest.toml` file in the project root +/// (the directory containing `Cargo.lock`). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + output_path: PathBuf, + + threshold: f32, + failed_pixel_count_threshold: usize, + + windows: OsConfig, + mac: OsConfig, + linux: OsConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + output_path: PathBuf::from("tests/snapshots"), + threshold: 0.6, + failed_pixel_count_threshold: 0, + windows: Default::default(), + mac: Default::default(), + linux: Default::default(), + } + } +} +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct OsConfig { + threshold: Option, + failed_pixel_count_threshold: Option, +} + +fn find_project_root() -> io::Result { + let mut current_dir = std::env::current_dir()?; + + loop { + // Check if Cargo.toml exists in this directory + if current_dir.join("Cargo.lock").exists() { + return Ok(current_dir); + } + + // Move up one directory + if !current_dir.pop() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Project root not found", + )); + } + } +} + +fn load_config() -> Config { + let project_root = find_project_root(); + + if let Ok(project_root) = project_root { + let config_path = project_root.join("kittest.toml"); + if config_path.exists() { + let config_str = + std::fs::read_to_string(config_path).expect("Failed to read config file"); + match toml::from_str(&config_str) { + Ok(config) => return config, + Err(e) => panic!("Failed to parse config file: {e}") + }; + } + } + + Config::default() +} + +pub fn config() -> &'static Config { + Config::get() +} + +impl Config { + pub fn get() -> &'static Self { + static INSTANCE: std::sync::LazyLock = std::sync::LazyLock::new(load_config); + &INSTANCE + } + + pub fn os_threshold(&self) -> OsThreshold { + let fallback = self.threshold; + OsThreshold { + windows: self.windows.threshold.unwrap_or(fallback), + macos: self.mac.threshold.unwrap_or(fallback), + linux: self.linux.threshold.unwrap_or(fallback), + fallback, + } + } + + pub fn os_failed_pixel_count_threshold(&self) -> OsThreshold { + let fallback = self.failed_pixel_count_threshold; + OsThreshold { + windows: self.windows.failed_pixel_count_threshold.unwrap_or(fallback), + macos: self.mac.failed_pixel_count_threshold.unwrap_or(fallback), + linux: self.linux.failed_pixel_count_threshold.unwrap_or(fallback), + fallback, + } + } + + /// The threshold. + /// + /// Default is 1.0. + pub fn threshold(&self) -> f32 { + self.os_threshold().threshold() + } + + /// The number of pixels that can differ before the test is considered failed. + /// + /// Default is 0. + pub fn failed_pixel_count_threshold(&self) -> usize { + self.os_failed_pixel_count_threshold().threshold() + } + + /// The output path for image snapshots. + /// + /// Default is "tests/snapshots". + pub fn output_path(&self) -> PathBuf { + self.output_path.clone() + } +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index c8112f47bbc..95f2af3e79a 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -13,6 +13,7 @@ use std::fmt::{Debug, Display, Formatter}; use std::time::Duration; mod app_kind; +mod config; mod node; mod renderer; #[cfg(feature = "wgpu")] @@ -20,6 +21,7 @@ mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; +pub(crate) use config::config; pub use kittest; use crate::app_kind::AppKind; diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index f6511c451a0..7a6453ce42d 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -1,20 +1,24 @@ -use crate::Harness; -use image::ImageError; use std::fmt::Display; use std::io::ErrorKind; use std::path::PathBuf; +use image::ImageError; + +use crate::{Harness, config}; + pub type SnapshotResult = Result<(), SnapshotError>; #[non_exhaustive] #[derive(Clone, Debug)] pub struct SnapshotOptions { /// The threshold for the image comparison. + /// /// The default is `0.6` (which is enough for most egui tests to pass across different /// wgpu backends). pub threshold: f32, /// The number of pixels that can differ before the snapshot is considered a failure. + /// /// Preferably, you should use `threshold` to control the sensitivity of the image comparison. /// As a last resort, you can use this to allow a certain number of pixels to differ. /// If `None`, the default is `0` (meaning no pixels can differ). @@ -22,6 +26,7 @@ pub struct SnapshotOptions { pub failed_pixel_count_threshold: usize, /// The path where the snapshots will be saved. + /// /// The default is `tests/snapshots`. pub output_path: PathBuf, } @@ -30,7 +35,9 @@ pub struct SnapshotOptions { /// /// This is useful if you want to set different thresholds for different operating systems. /// -/// The default values are 0 / 0.0 +/// [`OsThreshold::default`] gets the default from the config file (`kittest.toml`). +/// For `usize`, it's the `failed_pixel_count_threshold` value. +/// For `f32`, it's the `threshold` value. /// /// Example usage: /// ```no_run @@ -53,12 +60,36 @@ pub struct OsThreshold { pub fallback: T, } +impl Default for OsThreshold { + /// Returns the default `failed_pixel_count_threshold` as configured in `kittest.toml` + /// + /// The default is `0`. + fn default() -> Self { + config().os_failed_pixel_count_threshold() + } +} + +impl Default for OsThreshold { + /// Returns the default `threshold` as configured in `kittest.toml` + /// + /// The default is `0.6`. + fn default() -> Self { + config().os_threshold() + } +} + impl From for OsThreshold { fn from(value: usize) -> Self { Self::new(value) } } +impl From for OsThreshold { + fn from(value: f32) -> Self { + Self::new(value) + } +} + impl OsThreshold where T: Copy, @@ -123,9 +154,9 @@ impl From> for f32 { impl Default for SnapshotOptions { fn default() -> Self { Self { - threshold: 0.6, - output_path: PathBuf::from("tests/snapshots"), - failed_pixel_count_threshold: 0, // Default is 0, meaning no pixels can differ + threshold: config().threshold(), + output_path: config().output_path(), + failed_pixel_count_threshold: config().failed_pixel_count_threshold(), } } } diff --git a/kittest.toml b/kittest.toml new file mode 100644 index 00000000000..0ab020cb08e --- /dev/null +++ b/kittest.toml @@ -0,0 +1,10 @@ +output_path = "tests/snapshots" + +# Other oses get a higher threshold so they can still run tests locally without failures due to small rendering +# differences. +# To update snapshots, update them via ./scripts/update_snapshots_from_ci.sh or via kitdiff +threshold = 2.0 + +[mac] +# Since our CI runs snapshot tests on macOS, this is our source of truth. +threshold = 0.1 From 0204fac8789baecefa9efc830bd222216595aaf1 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 16 Oct 2025 16:20:44 +0200 Subject: [PATCH 2/5] Fmt --- crates/egui_kittest/src/config.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index 5a782cfa1cd..13be9abb827 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -67,7 +67,7 @@ fn load_config() -> Config { std::fs::read_to_string(config_path).expect("Failed to read config file"); match toml::from_str(&config_str) { Ok(config) => return config, - Err(e) => panic!("Failed to parse config file: {e}") + Err(e) => panic!("Failed to parse config file: {e}"), }; } } @@ -98,7 +98,10 @@ impl Config { pub fn os_failed_pixel_count_threshold(&self) -> OsThreshold { let fallback = self.failed_pixel_count_threshold; OsThreshold { - windows: self.windows.failed_pixel_count_threshold.unwrap_or(fallback), + windows: self + .windows + .failed_pixel_count_threshold + .unwrap_or(fallback), macos: self.mac.failed_pixel_count_threshold.unwrap_or(fallback), linux: self.linux.failed_pixel_count_threshold.unwrap_or(fallback), fallback, From 92a6e4f298a9645be4c55a58d5f9211a33e9ed20 Mon Sep 17 00:00:00 2001 From: lucasmerlin <8009393+lucasmerlin@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:25:14 +0000 Subject: [PATCH 3/5] Update snapshot images --- .../egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png index 1344edcfb16..0fc009f8a2f 100644 --- a/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png +++ b/crates/egui_demo_lib/tests/snapshots/rendering_test/dpi_1.67.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b7d7e290b97a8042af3af3cd9ceb274950cf607dd7e9cd6c71d5a113d3b57a5 -size 1206155 +oid sha256:3a3a9aa8383abfe4580be2cc9987f8123aeabf36bf8ec06029a9af64b9500ec9 +size 1206157 From bac6960f3fca632904f1fae68faa4d481e0bf316 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 16 Oct 2025 16:28:05 +0200 Subject: [PATCH 4/5] Increase threshold again --- kittest.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kittest.toml b/kittest.toml index 0ab020cb08e..5fa14b64fd2 100644 --- a/kittest.toml +++ b/kittest.toml @@ -7,4 +7,4 @@ threshold = 2.0 [mac] # Since our CI runs snapshot tests on macOS, this is our source of truth. -threshold = 0.1 +threshold = 0.6 From 014147b5400a9d7667b7eb97ca47ff0137f41347 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 16 Oct 2025 16:31:00 +0200 Subject: [PATCH 5/5] Use toml 0.8 to avoid duplicate toml_datetime --- Cargo.lock | 46 ++++++++++++---------------------------------- Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index edcb91cfd36..a756e3dd012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4040,11 +4040,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -4504,17 +4504,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.6" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" dependencies = [ - "indexmap", - "serde_core", + "serde", "serde_spanned", - "toml_datetime 0.7.3", - "toml_parser", - "toml_writer", - "winnow", + "toml_datetime", + "toml_edit", ] [[package]] @@ -4522,14 +4519,8 @@ name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" - -[[package]] -name = "toml_datetime" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -4539,25 +4530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap", - "toml_datetime 0.6.8", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" -dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", "winnow", ] -[[package]] -name = "toml_writer" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" - [[package]] name = "tracing" version = "0.1.40" diff --git a/Cargo.toml b/Cargo.toml index d3650d1d830..e305d65a7fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,7 +131,7 @@ syntect = { version = "5.3.0", default-features = false } tempfile = "3.23.0" thiserror = "2.0.17" tokio = "1.47.1" -toml = "0.9" +toml = "0.8" type-map = "0.5.1" unicode_names2 = { version = "2.0.0", default-features = false } unicode-segmentation = "1.12.0"