From 70a01138b77f9c5724a35a6ef750b9ae1ab9f2dc Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Tue, 22 Oct 2024 12:39:00 +0200 Subject: [PATCH] Add egui testing library (#5166) - closes #3491 - closes #3926 This adds a testing library to egui based on [kittest](https://github.com/rerun-io/kittest). Kittest is a new [AccessKit](https://github.com/AccessKit/accesskit/)-based testing library. The api is inspired by the js [testing-library](https://testing-library.com/) where the idea is also to query the dom based on accessibility attributes. We made kittest with egui in mind but it should work with any rust gui framework with AccessKit support. It currently has support for: - running the egui app, frame by frame - building the AccessKit tree - ergonomic queries via kittest - via e.g. get_by_name, get_by_role - simulating events based on the accesskit node id - creating arbitrary events based on Harness::input_mut - rendering screenshots via wgpu - snapshot tests with these screenshots A simple test looks like this: ```rust fn main() { let mut checked = false; let app = |ctx: &Context| { CentralPanel::default().show(ctx, |ui| { ui.checkbox(&mut checked, "Check me!"); }); }; let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app); let checkbox = harness.get_by_name("Check me!"); assert_eq!(checkbox.toggled(), Some(Toggled::False)); checkbox.click(); harness.run(); let checkbox = harness.get_by_name("Check me!"); assert_eq!(checkbox.toggled(), Some(Toggled::True)); // You can even render the ui and do image snapshot tests #[cfg(all(feature = "wgpu", feature = "snapshot"))] egui_kittest::image_snapshot(&egui_kittest::wgpu::TestRenderer::new().render(&harness), "readme_example"); } ``` ~Since getting wgpu to run in ci is a hassle, I'm taking another shot at creating a software renderer for egui (ideally without a huge dependency like skia)~ (this didn't work as well as I hoped and it turns out in CI you can just run tests on a mac runner which comes with a real GPU) Here is a example of a failed snapshot test in ci, it will say which snapshot failed and upload an artifact with the before / after and diff images: https://github.com/emilk/egui/actions/runs/11183049487/job/31090724606?pr=5166 --- .gitattributes | 1 + .github/workflows/rust.yml | 41 +++- .gitignore | 2 + Cargo.lock | 77 ++++++- Cargo.toml | 5 + crates/egui_demo_lib/Cargo.toml | 7 +- .../src/demo/demo_app_windows.rs | 49 ++++ crates/egui_demo_lib/src/demo/text_edit.rs | 34 +++ .../egui_demo_lib/src/demo/widget_gallery.rs | 28 +++ .../snapshots/demos/B\303\251zier Curve.png" | 3 + .../tests/snapshots/demos/Code Editor.png | 3 + .../tests/snapshots/demos/Code Example.png | 3 + .../tests/snapshots/demos/Context Menus.png | 3 + .../tests/snapshots/demos/Dancing Strings.png | 3 + .../tests/snapshots/demos/Drag and Drop.png | 3 + .../tests/snapshots/demos/Extra Viewport.png | 3 + .../tests/snapshots/demos/Font Book.png | 3 + .../tests/snapshots/demos/Frame.png | 3 + .../tests/snapshots/demos/Highlighting.png | 3 + .../snapshots/demos/Interactive Container.png | 3 + .../tests/snapshots/demos/Misc Demos.png | 3 + .../tests/snapshots/demos/Multi Touch.png | 3 + .../tests/snapshots/demos/Painting.png | 3 + .../tests/snapshots/demos/Pan Zoom.png | 3 + .../tests/snapshots/demos/Panels.png | 3 + .../tests/snapshots/demos/Scrolling.png | 3 + .../tests/snapshots/demos/Sliders.png | 3 + .../tests/snapshots/demos/Strip.png | 3 + .../tests/snapshots/demos/Table.png | 3 + .../tests/snapshots/demos/Text Layout.png | 3 + .../tests/snapshots/demos/TextEdit.png | 3 + .../tests/snapshots/demos/Tooltips.png | 3 + .../tests/snapshots/demos/Undo Redo.png | 3 + .../tests/snapshots/demos/Window Options.png | 3 + .../tests/snapshots/widget_gallery.png | 3 + crates/egui_kittest/Cargo.toml | 47 ++++ crates/egui_kittest/README.md | 35 +++ crates/egui_kittest/src/builder.rs | 55 +++++ crates/egui_kittest/src/event.rs | 182 +++++++++++++++ crates/egui_kittest/src/lib.rs | 186 +++++++++++++++ crates/egui_kittest/src/snapshot.rs | 214 ++++++++++++++++++ crates/egui_kittest/src/texture_to_image.rs | 83 +++++++ crates/egui_kittest/src/wgpu.rs | 141 ++++++++++++ .../tests/snapshots/readme_example.png | 3 + deny.toml | 5 +- 45 files changed, 1262 insertions(+), 11 deletions(-) create mode 100644 "crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Code Example.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Font Book.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Frame.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Painting.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Panels.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Sliders.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Strip.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Table.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png create mode 100644 crates/egui_demo_lib/tests/snapshots/demos/Window Options.png create mode 100644 crates/egui_demo_lib/tests/snapshots/widget_gallery.png create mode 100644 crates/egui_kittest/Cargo.toml create mode 100644 crates/egui_kittest/README.md create mode 100644 crates/egui_kittest/src/builder.rs create mode 100644 crates/egui_kittest/src/event.rs create mode 100644 crates/egui_kittest/src/lib.rs create mode 100644 crates/egui_kittest/src/snapshot.rs create mode 100644 crates/egui_kittest/src/texture_to_image.rs create mode 100644 crates/egui_kittest/src/wgpu.rs create mode 100644 crates/egui_kittest/tests/snapshots/readme_example.png diff --git a/.gitattributes b/.gitattributes index b1f5e1192e4..bffb89d4ad0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto eol=lf Cargo.lock linguist-generated=false +**/tests/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1e8e31bbcf1..077580dcafb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + with: + lfs: true - uses: dtolnay/rust-toolchain@master with: @@ -60,18 +62,12 @@ jobs: - name: cargo check -p test_egui_extras_compilation run: cargo check -p test_egui_extras_compilation - - name: Test doc-tests - run: cargo test --doc --all-features - - name: cargo doc --lib run: cargo doc --lib --no-deps --all-features - name: cargo doc --document-private-items run: cargo doc --document-private-items --no-deps --all-features - - name: Test - run: cargo test --all-features - - name: clippy run: cargo clippy --all-targets --all-features -- -D warnings @@ -222,3 +218,36 @@ jobs: - name: Check hello_world run: cargo check -p hello_world + + # --------------------------------------------------------------------------- + + tests: + name: Run tests + # We run the tests on macOS because it will run with a actual GPU + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.76.0 + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + + - name: Run tests + # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) + run: cargo test + + - name: Run doc-tests + # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) + run: cargo test --doc + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: "**/tests/snapshots" diff --git a/.gitignore b/.gitignore index 7db0b9d06fb..887de9da3de 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ **/target **/target_ra **/target_wasm +**/tests/snapshots/**/*.diff.png +**/tests/snapshots/**/*.new.png /.*.json /.vscode /media/* diff --git a/Cargo.lock b/Cargo.lock index 47d5d30b6b8..43a25113bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,6 +614,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.5.0" @@ -818,6 +824,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "com" version = "0.6.0" @@ -1097,6 +1113,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dify" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11217d469eafa3b809ad84651eb9797ccbb440b4a916d5d85cb1b994e89787f6" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1304,9 +1333,12 @@ dependencies = [ "criterion", "document-features", "egui", + "egui_demo_lib", "egui_extras", + "egui_kittest", "serde", "unicode_names2", + "wgpu", ] [[package]] @@ -1348,6 +1380,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.29.1" +dependencies = [ + "dify", + "document-features", + "egui", + "egui-wgpu", + "image", + "kittest", + "pollster", + "wgpu", +] + [[package]] name = "ehttp" version = "0.5.0" @@ -1767,6 +1813,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.10" @@ -2130,12 +2185,12 @@ dependencies = [ [[package]] name = "image" -version = "0.25.0" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645" +checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" dependencies = [ "bytemuck", - "byteorder", + "byteorder-lite", "color_quant", "gif", "num-traits", @@ -2290,6 +2345,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.1.0" +source = "git+https://github.com/rerun-io/kittest?branch=main#1336a504aefd05f7e9aa7c9237ae44ba9e72acdd" +dependencies = [ + "accesskit", + "accesskit_consumer", + "parking_lot", +] + [[package]] name = "kurbo" version = "0.9.5" @@ -2299,6 +2364,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.155" diff --git a/Cargo.toml b/Cargo.toml index 9e2fac41cf8..c50a51d76e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/egui_demo_lib", "crates/egui_extras", "crates/egui_glow", + "crates/egui_kittest", "crates/egui-wgpu", "crates/egui-winit", "crates/egui", @@ -64,6 +65,7 @@ egui_extras = { version = "0.29.1", path = "crates/egui_extras", default-feature egui-wgpu = { version = "0.29.1", path = "crates/egui-wgpu", default-features = false } egui_demo_lib = { version = "0.29.1", path = "crates/egui_demo_lib", default-features = false } egui_glow = { version = "0.29.1", path = "crates/egui_glow", default-features = false } +egui_kittest = { version = "0.29.1", path = "crates/egui_kittest", default-features = false } eframe = { version = "0.29.1", path = "crates/eframe", default-features = false } ahash = { version = "0.8.11", default-features = false, features = [ @@ -73,15 +75,18 @@ ahash = { version = "0.8.11", default-features = false, features = [ backtrace = "0.3" bytemuck = "1.7.2" criterion = { version = "0.5.1", default-features = false } +dify = { version = "0.7", default-features = false } document-features = " 0.2.8" glow = "0.14" glutin = "0.32.0" glutin-winit = "0.5.0" home = "0.5.9" image = { version = "0.25", default-features = false } +kittest = { git = "https://github.com/rerun-io/kittest", version = "0.1", branch = "main"} log = { version = "0.4", features = ["std"] } nohash-hasher = "0.2" parking_lot = "0.12" +pollster = "0.3" puffin = "0.19" puffin_http = "0.16" ron = "0.8" diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index 28b0592c228..f8f1f47f686 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -55,8 +55,13 @@ serde = { workspace = true, optional = true } [dev-dependencies] -criterion.workspace = true +# when running tests we always want to use the `chrono` feature +egui_demo_lib = { workspace = true, features = ["chrono"] } +criterion.workspace = true +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +wgpu = { workspace = true, features = ["metal"] } +egui = { workspace = true, features = ["default_fonts"] } [[bench]] name = "benchmark" diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index d55cc6aff3f..6a6becd599d 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -377,3 +377,52 @@ fn file_menu_button(ui: &mut Ui) { } }); } + +#[cfg(test)] +mod tests { + use crate::demo::demo_app_windows::Demos; + use egui::Vec2; + use egui_kittest::kittest::Queryable; + use egui_kittest::Harness; + + #[test] + fn demos_should_match_snapshot() { + let demos = Demos::default(); + + let mut errors = Vec::new(); + + for mut demo in demos.demos { + // Remove the emoji from the demo name + let name = demo + .name() + .split_once(' ') + .map_or(demo.name(), |(_, name)| name); + + // Widget Gallery needs to be customized (to set a specific date) and has its own test + if name == "Widget Gallery" { + continue; + } + + let mut harness = Harness::new(|ctx| { + demo.show(ctx, &mut true); + }); + + let window = harness.node().children().next().unwrap(); + // TODO(lucasmerlin): Windows should probably have a label? + //let window = harness.get_by_name(name); + + let size = window.raw_bounds().expect("window bounds").size(); + harness.set_size(Vec2::new(size.width as f32, size.height as f32)); + + // Run the app for some more frames... + harness.run(); + + let result = harness.try_wgpu_snapshot(&format!("demos/{name}")); + if let Err(err) = result { + errors.push(err); + } + } + + assert!(errors.is_empty(), "Errors: {errors:#?}"); + } +} diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 9743306c746..96763625fca 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -112,3 +112,37 @@ impl crate::View for TextEditDemo { }); } } + +#[cfg(test)] +mod tests { + use egui::{accesskit, CentralPanel}; + use egui_kittest::kittest::{Key, Queryable}; + use egui_kittest::Harness; + + #[test] + pub fn should_type() { + let mut text = "Hello, world!".to_owned(); + let mut harness = Harness::new(move |ctx| { + CentralPanel::default().show(ctx, |ui| { + ui.text_edit_singleline(&mut text); + }); + }); + + harness.run(); + + let text_edit = harness.get_by_role(accesskit::Role::TextInput); + assert_eq!(text_edit.value().as_deref(), Some("Hello, world!")); + + text_edit.key_combination(&[Key::Command, Key::A]); + text_edit.type_text("Hi "); + + harness.run(); + harness + .get_by_role(accesskit::Role::TextInput) + .type_text("there!"); + + harness.run(); + let text_edit = harness.get_by_role(accesskit::Role::TextInput); + assert_eq!(text_edit.value().as_deref(), Some("Hi there!")); + } +} diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 7be6e20c040..60c9bccf0a9 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -285,3 +285,31 @@ fn doc_link_label_with_crate<'a>( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::View; + use egui::{CentralPanel, Context, Vec2}; + use egui_kittest::Harness; + + #[test] + pub fn should_match_screenshot() { + let mut demo = WidgetGallery { + // If we don't set a fixed date, the snapshot test will fail. + date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), + ..Default::default() + }; + let app = |ctx: &Context| { + CentralPanel::default().show(ctx, |ui| { + demo.ui(ui); + }); + }; + let harness = Harness::builder() + .with_size(Vec2::new(380.0, 550.0)) + .with_dpi(2.0) + .build(app); + + harness.wgpu_snapshot("widget_gallery"); + } +} diff --git "a/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" "b/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" new file mode 100644 index 00000000000..ad7d9becfaa --- /dev/null +++ "b/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a725aa81433f301fda4ff8a28be869366332964995d1ae4ed996591596eb7e2 +size 31461 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png new file mode 100644 index 00000000000..252c7c8f205 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36028d85f49ee77562250214237def2b676ecc9ed413d2fd8afc473d61289ca1 +size 32761 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png new file mode 100644 index 00000000000..093b2c6a33b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8d4f004ee11ea68ae0f30657601b6e51403fcc3ca91fa5b8cdcb58585d8d40d +size 78318 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png new file mode 100644 index 00000000000..c29267377b7 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:684648bea4ef5ce138fc25dbe7576e3937a797e87f2244cb3656ff8b4c2777f5 +size 11574 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png new file mode 100644 index 00000000000..bdc4739c77f --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad38bff7cc5661be43e730e1b34c444b571b24b9f50791209496a1687610dd3d +size 20543 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png b/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png new file mode 100644 index 00000000000..035d4c13f7b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff78748f2571c49638d8fe8fdc859aaa5181758aad65498b7217551350fb9138 +size 20672 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png b/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png new file mode 100644 index 00000000000..2d48a7e085f --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dee66004cc47f5e27aaac34d137ff005eedf70cbfa3fbe43153dfd5c09d5e18 +size 10610 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png b/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png new file mode 100644 index 00000000000..69201f86154 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d1086b789f1fe0a8085c86f5b6a5ae7ecb53020f385b84775d6812ebc9d74a3 +size 132349 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Frame.png b/crates/egui_demo_lib/tests/snapshots/demos/Frame.png new file mode 100644 index 00000000000..ff4d08bafe8 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08be378c01e376aab6e99ba3158519bbd7b301e815dc3447b57c9abab558977f +size 24237 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png b/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png new file mode 100644 index 00000000000..a3cab2a3097 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53097b2c26ebcba8b8ad657ed8e52ca40261155e96dbbfca1e8eb01fce25d290 +size 17586 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png b/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png new file mode 100644 index 00000000000..2bfbf20f91e --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9c8395e6b4287b92d85a52ca2d47750f67abeb0ad88c6b42264bfe2e62fd09d +size 22283 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png new file mode 100644 index 00000000000..80cb5b5a177 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38d21b6f8c364f86ad759e88ea1068649c23c58ded5d2953ba8ff1c83b46112f +size 63884 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png new file mode 100644 index 00000000000..23bad456c8c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83162f8c496a55230375dbc4cc636cfacf63049c913904bea9d06bdb56e63da6 +size 36282 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Painting.png b/crates/egui_demo_lib/tests/snapshots/demos/Painting.png new file mode 100644 index 00000000000..89a17e67406 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Painting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2537c681d1ffceb5cf4bf19d11295891525c96aea0b1422ab28f133021185be0 +size 17451 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png b/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png new file mode 100644 index 00000000000..7ba225feae8 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79ce1dbf7627579d4e10de6494e34d8fd9685506d7b35cb3c9148f90f8c01366 +size 25144 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png new file mode 100644 index 00000000000..585c126360c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5068df8549ffc91028addfec6f851f12a4de80e208b50b39e4d44b6aa2c7240e +size 261946 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png new file mode 100644 index 00000000000..440a51f3871 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be2ac005fd5aafa293e21b162c22a09078e46d2d45b6208ce0f7841eeb05314a +size 183934 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png new file mode 100644 index 00000000000..e3a213176e9 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e3436906f7ac459b7f4330a286937722e78ad885ae1e90f75be566e970a8ca7 +size 116899 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Strip.png b/crates/egui_demo_lib/tests/snapshots/demos/Strip.png new file mode 100644 index 00000000000..69ec9e88230 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df7dabf726620ab5205ce153f692d1ba02365848ead7b79c95b873d5121d52a6 +size 25850 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Table.png b/crates/egui_demo_lib/tests/snapshots/demos/Table.png new file mode 100644 index 00000000000..9f3618ba039 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Table.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae6c2e3aad43cfad3322340ff7045ec50ba01d58feb7b8acc5ba062a8a5c9ab8 +size 70230 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png b/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png new file mode 100644 index 00000000000..ff972ae484c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec0c2efff75cb8d621f5a4ea59f9fa8d3076521ca34f4499e07fb9dc8681d7ba +size 65916 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png new file mode 100644 index 00000000000..7af3611b924 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c04aee0a3a77a3691bb601a93871117500be917e0896138fda43251454ec04c2 +size 20988 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png new file mode 100644 index 00000000000..4e7d8a9230b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:814d863deaa4fa029044da1783db87744f0d82e874edd6cbab16e712ed8715aa +size 59881 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png b/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png new file mode 100644 index 00000000000..a635cdfabd0 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e682f5cb9ecb1bdf89281c2ba1612078e70e97f28c76facc64d717e4015ced6a +size 12977 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png new file mode 100644 index 00000000000..f28774030aa --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15acfb041cc53ef9bd966d6edd53a6b692cdb645ae5cf34bc20e70d403371c30 +size 34809 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png new file mode 100644 index 00000000000..273b85a6303 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc632962f8894c4f20a48c9b9e57d60470f3f83ef7f19d05854dba718610a2f +size 161820 diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml new file mode 100644 index 00000000000..5063b59658b --- /dev/null +++ b/crates/egui_kittest/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "egui_kittest" +version.workspace = true +authors = ["Lucas Meurer ", "Emil Ernerfeldt "] +description = "Testing library for egui based on kittest and AccessKit" +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui" +license.workspace = true +readme = "./README.md" +repository = "https://github.com/emilk/egui" +categories = ["gui", "development-tools::testing", "accessibility"] +keywords = ["gui", "immediate", "egui", "testing", "accesskit"] +include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +# Adds a wgpu-based test renderer. +wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image"] + +# Adds a dify-based image snapshot utility. +snapshot = ["dep:dify", "dep:image", "image/png"] + + +[dependencies] +kittest.workspace = true +egui = { workspace = true, features = ["accesskit"] } + +# wgpu dependencies +egui-wgpu = { workspace = true, optional = true } +pollster = { workspace = true, optional = true } +image = { workspace = true, optional = true } + +# snapshot dependencies +dify = { workspace = true, optional = true } + +## Enable this when generating docs. +document-features = { workspace = true, optional = true } + +[dev-dependencies] +wgpu = { workspace = true, features = ["metal"] } +image = { workspace = true, features = ["png"] } +egui = { workspace = true, features = ["default_fonts"] } + +[lints] +workspace = true diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md new file mode 100644 index 00000000000..86ccf551506 --- /dev/null +++ b/crates/egui_kittest/README.md @@ -0,0 +1,35 @@ +# egui_kittest + +Ui testing library for egui, based on [kittest](https://github.com/rerun-io/kittest) (an [AccessKit](https://github.com/AccessKit/accesskit) based testing library). + +```rust +use egui::accesskit::{Role, Toggled}; +use egui::{CentralPanel, Context, TextEdit, Vec2}; +use egui_kittest::Harness; +use kittest::Queryable; +use std::cell::RefCell; + +fn main() { + let mut checked = false; + let app = |ctx: &Context| { + CentralPanel::default().show(ctx, |ui| { + ui.checkbox(&mut checked, "Check me!"); + }); + }; + + let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app); + + let checkbox = harness.get_by_name("Check me!"); + assert_eq!(checkbox.toggled(), Some(Toggled::False)); + checkbox.click(); + + harness.run(); + + let checkbox = harness.get_by_name("Check me!"); + assert_eq!(checkbox.toggled(), Some(Toggled::True)); + + // You can even render the ui and do image snapshot tests + #[cfg(all(feature = "wgpu", feature = "snapshot"))] + harness.wgpu_snapshot("readme_example"); +} +``` diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs new file mode 100644 index 00000000000..bf1daf3622b --- /dev/null +++ b/crates/egui_kittest/src/builder.rs @@ -0,0 +1,55 @@ +use crate::Harness; +use egui::{Pos2, Rect, Vec2}; + +/// Builder for [`Harness`]. +pub struct HarnessBuilder { + pub(crate) screen_rect: Rect, + pub(crate) dpi: f32, +} + +impl Default for HarnessBuilder { + fn default() -> Self { + Self { + screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)), + dpi: 1.0, + } + } +} + +impl HarnessBuilder { + /// Set the size of the window. + #[inline] + pub fn with_size(mut self, size: impl Into) -> Self { + let size = size.into(); + self.screen_rect.set_width(size.x); + self.screen_rect.set_height(size.y); + self + } + + /// Set the DPI of the window. + #[inline] + pub fn with_dpi(mut self, dpi: f32) -> Self { + self.dpi = dpi; + self + } + + /// Create a new Harness with the given app closure. + /// + /// The ui closure will immediately be called once to create the initial ui. + /// + /// # Example + /// ```rust + /// # use egui::CentralPanel; + /// # use egui_kittest::Harness; + /// let mut harness = Harness::builder() + /// .with_size(egui::Vec2::new(300.0, 200.0)) + /// .build(|ctx| { + /// CentralPanel::default().show(ctx, |ui| { + /// ui.label("Hello, world!"); + /// }); + /// }); + /// ``` + pub fn build<'a>(self, app: impl FnMut(&egui::Context) + 'a) -> Harness<'a> { + Harness::from_builder(&self, app) + } +} diff --git a/crates/egui_kittest/src/event.rs b/crates/egui_kittest/src/event.rs new file mode 100644 index 00000000000..5ac07488d0c --- /dev/null +++ b/crates/egui_kittest/src/event.rs @@ -0,0 +1,182 @@ +use egui::Event::PointerButton; +use egui::{Event, Modifiers, Pos2}; +use kittest::{ElementState, MouseButton, SimulatedEvent}; + +#[derive(Default)] +pub(crate) struct EventState { + modifiers: Modifiers, + last_mouse_pos: Pos2, +} + +impl EventState { + pub fn kittest_event_to_egui(&mut self, event: kittest::Event) -> Option { + match event { + kittest::Event::ActionRequest(e) => Some(Event::AccessKitActionRequest(e)), + kittest::Event::Simulated(e) => match e { + SimulatedEvent::CursorMoved { position } => { + self.last_mouse_pos = Pos2::new(position.x as f32, position.y as f32); + Some(Event::PointerMoved(Pos2::new( + position.x as f32, + position.y as f32, + ))) + } + SimulatedEvent::MouseInput { state, button } => { + pointer_button_to_egui(button).map(|button| PointerButton { + button, + modifiers: self.modifiers, + pos: self.last_mouse_pos, + pressed: matches!(state, ElementState::Pressed), + }) + } + SimulatedEvent::Ime(text) => Some(Event::Text(text)), + SimulatedEvent::KeyInput { state, key } => { + match key { + kittest::Key::Alt => { + self.modifiers.alt = matches!(state, ElementState::Pressed); + } + kittest::Key::Command => { + self.modifiers.command = matches!(state, ElementState::Pressed); + } + kittest::Key::Control => { + self.modifiers.ctrl = matches!(state, ElementState::Pressed); + } + kittest::Key::Shift => { + self.modifiers.shift = matches!(state, ElementState::Pressed); + } + _ => {} + } + kittest_key_to_egui(key).map(|key| Event::Key { + key, + modifiers: self.modifiers, + pressed: matches!(state, ElementState::Pressed), + repeat: false, + physical_key: None, + }) + } + }, + } + } +} + +pub fn kittest_key_to_egui(value: kittest::Key) -> Option { + use egui::Key as EKey; + use kittest::Key; + match value { + Key::ArrowDown => Some(EKey::ArrowDown), + Key::ArrowLeft => Some(EKey::ArrowLeft), + Key::ArrowRight => Some(EKey::ArrowRight), + Key::ArrowUp => Some(EKey::ArrowUp), + Key::Escape => Some(EKey::Escape), + Key::Tab => Some(EKey::Tab), + Key::Backspace => Some(EKey::Backspace), + Key::Enter => Some(EKey::Enter), + Key::Space => Some(EKey::Space), + Key::Insert => Some(EKey::Insert), + Key::Delete => Some(EKey::Delete), + Key::Home => Some(EKey::Home), + Key::End => Some(EKey::End), + Key::PageUp => Some(EKey::PageUp), + Key::PageDown => Some(EKey::PageDown), + Key::Copy => Some(EKey::Copy), + Key::Cut => Some(EKey::Cut), + Key::Paste => Some(EKey::Paste), + Key::Colon => Some(EKey::Colon), + Key::Comma => Some(EKey::Comma), + Key::Backslash => Some(EKey::Backslash), + Key::Slash => Some(EKey::Slash), + Key::Pipe => Some(EKey::Pipe), + Key::Questionmark => Some(EKey::Questionmark), + Key::OpenBracket => Some(EKey::OpenBracket), + Key::CloseBracket => Some(EKey::CloseBracket), + Key::Backtick => Some(EKey::Backtick), + Key::Minus => Some(EKey::Minus), + Key::Period => Some(EKey::Period), + Key::Plus => Some(EKey::Plus), + Key::Equals => Some(EKey::Equals), + Key::Semicolon => Some(EKey::Semicolon), + Key::Quote => Some(EKey::Quote), + Key::Num0 => Some(EKey::Num0), + Key::Num1 => Some(EKey::Num1), + Key::Num2 => Some(EKey::Num2), + Key::Num3 => Some(EKey::Num3), + Key::Num4 => Some(EKey::Num4), + Key::Num5 => Some(EKey::Num5), + Key::Num6 => Some(EKey::Num6), + Key::Num7 => Some(EKey::Num7), + Key::Num8 => Some(EKey::Num8), + Key::Num9 => Some(EKey::Num9), + Key::A => Some(EKey::A), + Key::B => Some(EKey::B), + Key::C => Some(EKey::C), + Key::D => Some(EKey::D), + Key::E => Some(EKey::E), + Key::F => Some(EKey::F), + Key::G => Some(EKey::G), + Key::H => Some(EKey::H), + Key::I => Some(EKey::I), + Key::J => Some(EKey::J), + Key::K => Some(EKey::K), + Key::L => Some(EKey::L), + Key::M => Some(EKey::M), + Key::N => Some(EKey::N), + Key::O => Some(EKey::O), + Key::P => Some(EKey::P), + Key::Q => Some(EKey::Q), + Key::R => Some(EKey::R), + Key::S => Some(EKey::S), + Key::T => Some(EKey::T), + Key::U => Some(EKey::U), + Key::V => Some(EKey::V), + Key::W => Some(EKey::W), + Key::X => Some(EKey::X), + Key::Y => Some(EKey::Y), + Key::Z => Some(EKey::Z), + Key::F1 => Some(EKey::F1), + Key::F2 => Some(EKey::F2), + Key::F3 => Some(EKey::F3), + Key::F4 => Some(EKey::F4), + Key::F5 => Some(EKey::F5), + Key::F6 => Some(EKey::F6), + Key::F7 => Some(EKey::F7), + Key::F8 => Some(EKey::F8), + Key::F9 => Some(EKey::F9), + Key::F10 => Some(EKey::F10), + Key::F11 => Some(EKey::F11), + Key::F12 => Some(EKey::F12), + Key::F13 => Some(EKey::F13), + Key::F14 => Some(EKey::F14), + Key::F15 => Some(EKey::F15), + Key::F16 => Some(EKey::F16), + Key::F17 => Some(EKey::F17), + Key::F18 => Some(EKey::F18), + Key::F19 => Some(EKey::F19), + Key::F20 => Some(EKey::F20), + Key::F21 => Some(EKey::F21), + Key::F22 => Some(EKey::F22), + Key::F23 => Some(EKey::F23), + Key::F24 => Some(EKey::F24), + Key::F25 => Some(EKey::F25), + Key::F26 => Some(EKey::F26), + Key::F27 => Some(EKey::F27), + Key::F28 => Some(EKey::F28), + Key::F29 => Some(EKey::F29), + Key::F30 => Some(EKey::F30), + Key::F31 => Some(EKey::F31), + Key::F32 => Some(EKey::F32), + Key::F33 => Some(EKey::F33), + Key::F34 => Some(EKey::F34), + Key::F35 => Some(EKey::F35), + _ => None, + } +} + +pub fn pointer_button_to_egui(value: MouseButton) -> Option { + match value { + MouseButton::Left => Some(egui::PointerButton::Primary), + MouseButton::Right => Some(egui::PointerButton::Secondary), + MouseButton::Middle => Some(egui::PointerButton::Middle), + MouseButton::Back => Some(egui::PointerButton::Extra1), + MouseButton::Forward => Some(egui::PointerButton::Extra2), + MouseButton::Other(_) => None, + } +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs new file mode 100644 index 00000000000..f4f00ad652f --- /dev/null +++ b/crates/egui_kittest/src/lib.rs @@ -0,0 +1,186 @@ +#![doc = include_str!("../README.md")] +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] + +mod builder; +mod event; +#[cfg(feature = "snapshot")] +mod snapshot; + +#[cfg(feature = "snapshot")] +pub use snapshot::*; +use std::fmt::{Debug, Formatter}; +#[cfg(feature = "wgpu")] +mod texture_to_image; +#[cfg(feature = "wgpu")] +pub mod wgpu; + +pub use kittest; +use std::mem; + +use crate::event::EventState; +pub use builder::*; +use egui::{Pos2, Rect, TexturesDelta, Vec2, ViewportId}; +use kittest::{Node, Queryable}; + +/// The test Harness. This contains everything needed to run the test. +/// Create a new Harness using [`Harness::new`] or [`Harness::builder`]. +pub struct Harness<'a> { + pub ctx: egui::Context, + input: egui::RawInput, + kittest: kittest::State, + output: egui::FullOutput, + texture_deltas: Vec, + update_fn: Box, + event_state: EventState, +} + +impl<'a> Debug for Harness<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.kittest.fmt(f) + } +} + +impl<'a> Harness<'a> { + pub(crate) fn from_builder( + builder: &HarnessBuilder, + mut app: impl FnMut(&egui::Context) + 'a, + ) -> Self { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut input = egui::RawInput { + screen_rect: Some(builder.screen_rect), + ..Default::default() + }; + let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap(); + viewport.native_pixels_per_point = Some(builder.dpi); + + // We need to run egui for a single frame so that the AccessKit state can be initialized + // and users can immediately start querying for widgets. + let mut output = ctx.run(input.clone(), &mut app); + + let mut harness = Self { + update_fn: Box::new(app), + ctx, + input, + kittest: kittest::State::new( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ), + texture_deltas: vec![mem::take(&mut output.textures_delta)], + output, + event_state: EventState::default(), + }; + // Run the harness until it is stable, ensuring that all Areas are shown and animations are done + harness.run(); + harness + } + + pub fn builder() -> HarnessBuilder { + HarnessBuilder::default() + } + + /// Create a new Harness with the given app closure. + /// + /// The ui closure will immediately be called once to create the initial ui. + /// + /// If you e.g. want to customize the size of the window, you can use [`Harness::builder`]. + /// + /// # Example + /// ```rust + /// # use egui::CentralPanel; + /// # use egui_kittest::Harness; + /// let mut harness = Harness::new(|ctx| { + /// CentralPanel::default().show(ctx, |ui| { + /// ui.label("Hello, world!"); + /// }); + /// }); + /// ``` + pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self { + Self::builder().build(app) + } + + /// Set the size of the window. + /// Note: If you only want to set the size once at the beginning, + /// prefer using [`HarnessBuilder::with_size`]. + #[inline] + pub fn set_size(&mut self, size: Vec2) -> &mut Self { + self.input.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, size)); + self + } + + /// Set the DPI of the window. + /// Note: If you only want to set the DPI once at the beginning, + /// prefer using [`HarnessBuilder::with_dpi`]. + #[inline] + pub fn set_dpi(&mut self, dpi: f32) -> &mut Self { + self.ctx.set_pixels_per_point(dpi); + self + } + + /// Run a frame. + /// This will call the app closure with the current context and update the Harness. + pub fn step(&mut self) { + for event in self.kittest.take_events() { + if let Some(event) = self.event_state.kittest_event_to_egui(event) { + self.input.events.push(event); + } + } + + let mut output = self.ctx.run(self.input.take(), self.update_fn.as_mut()); + self.kittest.update( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ); + self.texture_deltas + .push(mem::take(&mut output.textures_delta)); + self.output = output; + } + + /// Run a few frames. + /// This will soon be changed to run the app until it is "stable", meaning + /// - all animations are done + /// - no more repaints are requested + pub fn run(&mut self) { + const STEPS: usize = 2; + for _ in 0..STEPS { + self.step(); + } + } + + /// Access the [`egui::RawInput`] for the next frame. + pub fn input(&self) -> &egui::RawInput { + &self.input + } + + /// Access the [`egui::RawInput`] for the next frame mutably. + pub fn input_mut(&mut self) -> &mut egui::RawInput { + &mut self.input + } + + /// Access the [`egui::FullOutput`] for the last frame. + pub fn output(&self) -> &egui::FullOutput { + &self.output + } + + /// Access the [`kittest::State`]. + pub fn kittest_state(&self) -> &kittest::State { + &self.kittest + } +} + +impl<'t, 'n, 'h> Queryable<'t, 'n> for Harness<'h> +where + 'n: 't, +{ + fn node(&'n self) -> Node<'t> { + self.kittest_state().node() + } +} diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs new file mode 100644 index 00000000000..a2c31a44130 --- /dev/null +++ b/crates/egui_kittest/src/snapshot.rs @@ -0,0 +1,214 @@ +use crate::Harness; +use image::ImageError; +use std::fmt::Display; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum SnapshotError { + /// Image did not match snapshot + Diff { + /// Count of pixels that were different + diff: i32, + + /// Path where the diff image was saved + diff_path: PathBuf, + }, + + /// Error opening the existing snapshot (it probably doesn't exist, check the + /// [`ImageError`] for more information) + OpenSnapshot { + /// Path where the snapshot was expected to be + path: PathBuf, + + /// The error that occurred + err: ImageError, + }, + + /// The size of the image did not match the snapshot + SizeMismatch { + /// Expected size + expected: (u32, u32), + + /// Actual size + actual: (u32, u32), + }, + + /// Error writing the snapshot output + WriteSnapshot { + /// Path where a file was expected to be written + path: PathBuf, + + /// The error that occurred + err: ImageError, + }, +} + +impl Display for SnapshotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Diff { diff, diff_path } => { + write!( + f, + "Image did not match snapshot. Diff: {diff}, {diff_path:?}" + ) + } + Self::OpenSnapshot { path, err } => match err { + ImageError::IoError(io) => match io.kind() { + ErrorKind::NotFound => { + write!(f, "Missing snapshot: {path:?}") + } + err => { + write!(f, "Error reading snapshot: {err:?}\nAt: {path:?}") + } + }, + err => { + write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}") + } + }, + Self::SizeMismatch { expected, actual } => { + write!( + f, + "Image size did not match snapshot. Expected: {expected:?}, Actual: {actual:?}" + ) + } + Self::WriteSnapshot { path, err } => { + write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}") + } + } + } +} + +/// Image snapshot test. +/// +/// # Errors +/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error +/// reading or writing the snapshot. +pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), SnapshotError> { + let snapshots_path = Path::new("tests/snapshots"); + + let path = snapshots_path.join(format!("{name}.png")); + std::fs::create_dir_all(path.parent().expect("Could not get snapshot folder")).ok(); + + let diff_path = snapshots_path.join(format!("{name}.diff.png")); + let current_path = snapshots_path.join(format!("{name}.new.png")); + + current + .save(¤t_path) + .map_err(|err| SnapshotError::WriteSnapshot { + err, + path: current_path, + })?; + + let previous = match image::open(&path) { + Ok(image) => image.to_rgba8(), + Err(err) => { + maybe_update_snapshot(&path, current)?; + return Err(SnapshotError::OpenSnapshot { path, err }); + } + }; + + if previous.dimensions() != current.dimensions() { + maybe_update_snapshot(&path, current)?; + return Err(SnapshotError::SizeMismatch { + expected: previous.dimensions(), + actual: current.dimensions(), + }); + } + + // Looking at dify's source code, the threshold is based on the distance between two colors in + // YIQ color space. + // The default is 0.1, but we'll try 0.0 because ideally the output should not change at all. + // We might have to increase the threshold if there are minor differences when running tests + // on different gpus or different backends. + let threshold = 0.0; + let result = dify::diff::get_results( + previous, + current.clone(), + threshold, + true, + None, + &None, + &None, + ); + + if let Some((diff, result_image)) = result { + result_image + .save(diff_path.clone()) + .map_err(|err| SnapshotError::WriteSnapshot { + path: diff_path.clone(), + err, + })?; + maybe_update_snapshot(&path, current)?; + return Err(SnapshotError::Diff { diff, diff_path }); + } else { + // Delete old diff if it exists + std::fs::remove_file(diff_path).ok(); + } + + Ok(()) +} + +fn should_update_snapshots() -> bool { + std::env::var("UPDATE_SNAPSHOTS").is_ok() +} + +fn maybe_update_snapshot( + snapshot_path: &Path, + current: &image::RgbaImage, +) -> Result<(), SnapshotError> { + if should_update_snapshots() { + current + .save(snapshot_path) + .map_err(|err| SnapshotError::WriteSnapshot { + err, + path: snapshot_path.into(), + })?; + println!("Updated snapshot: {snapshot_path:?}"); + } + Ok(()) +} + +/// Image snapshot test. +/// +/// # Panics +/// Panics if the image does not match the snapshot or if there was an error reading or writing the +/// snapshot. +#[track_caller] +pub fn image_snapshot(current: &image::RgbaImage, name: &str) { + match try_image_snapshot(current, name) { + Ok(_) => {} + Err(err) => { + panic!("{}", err); + } + } +} + +#[cfg(feature = "wgpu")] +impl Harness<'_> { + /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// + /// # Errors + /// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error + /// reading or writing the snapshot. + #[track_caller] + pub fn try_wgpu_snapshot(&self, name: &str) -> Result<(), SnapshotError> { + let image = crate::wgpu::TestRenderer::new().render(self); + try_image_snapshot(&image, name) + } + + /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// + /// # Panics + /// Panics if the image does not match the snapshot or if there was an error reading or writing the + /// snapshot. + #[track_caller] + pub fn wgpu_snapshot(&self, name: &str) { + match self.try_wgpu_snapshot(name) { + Ok(_) => {} + Err(err) => { + panic!("{}", err); + } + } + } +} diff --git a/crates/egui_kittest/src/texture_to_image.rs b/crates/egui_kittest/src/texture_to_image.rs new file mode 100644 index 00000000000..98803ac8a37 --- /dev/null +++ b/crates/egui_kittest/src/texture_to_image.rs @@ -0,0 +1,83 @@ +use egui_wgpu::wgpu; +use egui_wgpu::wgpu::{Device, Extent3d, Queue, Texture}; +use image::RgbaImage; +use std::iter; +use std::mem::size_of; +use std::sync::mpsc::channel; + +pub(crate) fn texture_to_image(device: &Device, queue: &Queue, texture: &Texture) -> RgbaImage { + let buffer_dimensions = + BufferDimensions::new(texture.width() as usize, texture.height() as usize); + + let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Texture to bytes output buffer"), + size: (buffer_dimensions.padded_bytes_per_row * buffer_dimensions.height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Texture to bytes encoder"), + }); + + // Copy the data from the texture to the buffer + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::ImageCopyBuffer { + buffer: &output_buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(buffer_dimensions.padded_bytes_per_row as u32), + rows_per_image: None, + }, + }, + Extent3d { + width: texture.width(), + height: texture.height(), + depth_or_array_layers: 1, + }, + ); + + let submission_index = queue.submit(iter::once(encoder.finish())); + + // Note that we're not calling `.await` here. + let buffer_slice = output_buffer.slice(..); + // Sets the buffer up for mapping, sending over the result of the mapping back to us when it is finished. + let (sender, receiver) = channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |v| drop(sender.send(v))); + + // Poll the device in a blocking manner so that our future resolves. + device.poll(wgpu::Maintain::WaitForSubmissionIndex(submission_index)); + + receiver.recv().unwrap().unwrap(); + let buffer_slice = output_buffer.slice(..); + let data = buffer_slice.get_mapped_range(); + let data = data + .chunks_exact(buffer_dimensions.padded_bytes_per_row) + .flat_map(|row| row.iter().take(buffer_dimensions.unpadded_bytes_per_row)) + .copied() + .collect::>(); + + RgbaImage::from_raw(texture.width(), texture.height(), data).expect("Failed to create image") +} + +struct BufferDimensions { + height: usize, + unpadded_bytes_per_row: usize, + padded_bytes_per_row: usize, +} + +impl BufferDimensions { + fn new(width: usize, height: usize) -> Self { + let bytes_per_pixel = size_of::(); + let unpadded_bytes_per_row = width * bytes_per_pixel; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded_bytes_per_row_padding = (align - unpadded_bytes_per_row % align) % align; + let padded_bytes_per_row = unpadded_bytes_per_row + padded_bytes_per_row_padding; + Self { + height, + unpadded_bytes_per_row, + padded_bytes_per_row, + } + } +} diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs new file mode 100644 index 00000000000..c79f411433a --- /dev/null +++ b/crates/egui_kittest/src/wgpu.rs @@ -0,0 +1,141 @@ +use crate::texture_to_image::texture_to_image; +use crate::Harness; +use egui_wgpu::wgpu::{Backends, InstanceDescriptor, StoreOp, TextureFormat}; +use egui_wgpu::{wgpu, ScreenDescriptor}; +use image::RgbaImage; +use std::iter::once; +use wgpu::Maintain; + +pub struct TestRenderer { + device: wgpu::Device, + queue: wgpu::Queue, + dithering: bool, +} + +impl Default for TestRenderer { + fn default() -> Self { + Self::new() + } +} + +impl TestRenderer { + pub fn new() -> Self { + let instance = wgpu::Instance::new(InstanceDescriptor::default()); + + let adapters = instance.enumerate_adapters(Backends::all()); + let adapter = adapters.first().expect("No adapter found"); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("Egui Device"), + memory_hints: Default::default(), + required_limits: Default::default(), + required_features: Default::default(), + }, + None, + )) + .expect("Failed to create device"); + + Self::create(device, queue) + } + + pub fn create(device: wgpu::Device, queue: wgpu::Queue) -> Self { + Self { + device, + queue, + dithering: false, + } + } + + #[inline] + pub fn with_dithering(mut self, dithering: bool) -> Self { + self.dithering = dithering; + self + } + + pub fn render(&mut self, harness: &Harness<'_>) -> RgbaImage { + let mut renderer = egui_wgpu::Renderer::new( + &self.device, + TextureFormat::Rgba8Unorm, + None, + 1, + self.dithering, + ); + + for delta in &harness.texture_deltas { + for (id, image_delta) in &delta.set { + renderer.update_texture(&self.device, &self.queue, *id, image_delta); + } + } + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Egui Command Encoder"), + }); + + let size = harness.ctx.screen_rect().size() * harness.ctx.pixels_per_point(); + let screen = ScreenDescriptor { + pixels_per_point: harness.ctx.pixels_per_point(), + size_in_pixels: [size.x.round() as u32, size.y.round() as u32], + }; + + let tessellated = harness.ctx.tessellate( + harness.output().shapes.clone(), + harness.ctx.pixels_per_point(), + ); + + let user_buffers = renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, + &tessellated, + &screen, + ); + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Egui Texture"), + size: wgpu::Extent3d { + width: screen.size_in_pixels[0], + height: screen.size_in_pixels[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Egui Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &texture_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }) + .forget_lifetime(); + + renderer.render(&mut pass, &tessellated, &screen); + } + + self.queue + .submit(user_buffers.into_iter().chain(once(encoder.finish()))); + + self.device.poll(Maintain::Wait); + + texture_to_image(&self.device, &self.queue, &texture) + } +} diff --git a/crates/egui_kittest/tests/snapshots/readme_example.png b/crates/egui_kittest/tests/snapshots/readme_example.png new file mode 100644 index 00000000000..66b21e7f4bf --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/readme_example.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36c1b432140456ea5cbb687076b1c910aea8b31affd33a0ece22218f60af2d6e +size 2296 diff --git a/deny.toml b/deny.toml index f5291700dfc..5c946782b35 100644 --- a/deny.toml +++ b/deny.toml @@ -60,7 +60,6 @@ skip = [ { name = "windows-core" }, # old version via accesskit_windows { name = "windows" }, # old version via accesskit_windows { name = "glow" }, # wgpu uses an old `glow`, but realistically no one uses _both_ `egui_wgpu` and `egui_glow`, so we won't get a duplicate dependency - ] skip-tree = [ { name = "criterion" }, # dev-dependency @@ -109,3 +108,7 @@ license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] [sources] unknown-registry = "deny" unknown-git = "deny" + +allow-git = [ + "https://github.com/rerun-io/kittest", # TODO(lucasmerlin): remove this once the kittest crate is published" +]