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
This commit is contained in:
lucasmerlin 2024-10-22 12:39:00 +02:00 committed by GitHub
parent 707cd03357
commit 70a01138b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1262 additions and 11 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
* text=auto eol=lf
Cargo.lock linguist-generated=false
**/tests/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text

View File

@ -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"

2
.gitignore vendored
View File

@ -2,6 +2,8 @@
**/target
**/target_ra
**/target_wasm
**/tests/snapshots/**/*.diff.png
**/tests/snapshots/**/*.new.png
/.*.json
/.vscode
/media/*

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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:#?}");
}
}

View File

@ -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!"));
}
}

View File

@ -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");
}
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a725aa81433f301fda4ff8a28be869366332964995d1ae4ed996591596eb7e2
size 31461

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36028d85f49ee77562250214237def2b676ecc9ed413d2fd8afc473d61289ca1
size 32761

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b8d4f004ee11ea68ae0f30657601b6e51403fcc3ca91fa5b8cdcb58585d8d40d
size 78318

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:684648bea4ef5ce138fc25dbe7576e3937a797e87f2244cb3656ff8b4c2777f5
size 11574

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad38bff7cc5661be43e730e1b34c444b571b24b9f50791209496a1687610dd3d
size 20543

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff78748f2571c49638d8fe8fdc859aaa5181758aad65498b7217551350fb9138
size 20672

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9dee66004cc47f5e27aaac34d137ff005eedf70cbfa3fbe43153dfd5c09d5e18
size 10610

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d1086b789f1fe0a8085c86f5b6a5ae7ecb53020f385b84775d6812ebc9d74a3
size 132349

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08be378c01e376aab6e99ba3158519bbd7b301e815dc3447b57c9abab558977f
size 24237

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53097b2c26ebcba8b8ad657ed8e52ca40261155e96dbbfca1e8eb01fce25d290
size 17586

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9c8395e6b4287b92d85a52ca2d47750f67abeb0ad88c6b42264bfe2e62fd09d
size 22283

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38d21b6f8c364f86ad759e88ea1068649c23c58ded5d2953ba8ff1c83b46112f
size 63884

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83162f8c496a55230375dbc4cc636cfacf63049c913904bea9d06bdb56e63da6
size 36282

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2537c681d1ffceb5cf4bf19d11295891525c96aea0b1422ab28f133021185be0
size 17451

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79ce1dbf7627579d4e10de6494e34d8fd9685506d7b35cb3c9148f90f8c01366
size 25144

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5068df8549ffc91028addfec6f851f12a4de80e208b50b39e4d44b6aa2c7240e
size 261946

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:be2ac005fd5aafa293e21b162c22a09078e46d2d45b6208ce0f7841eeb05314a
size 183934

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2e3436906f7ac459b7f4330a286937722e78ad885ae1e90f75be566e970a8ca7
size 116899

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df7dabf726620ab5205ce153f692d1ba02365848ead7b79c95b873d5121d52a6
size 25850

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae6c2e3aad43cfad3322340ff7045ec50ba01d58feb7b8acc5ba062a8a5c9ab8
size 70230

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ec0c2efff75cb8d621f5a4ea59f9fa8d3076521ca34f4499e07fb9dc8681d7ba
size 65916

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c04aee0a3a77a3691bb601a93871117500be917e0896138fda43251454ec04c2
size 20988

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:814d863deaa4fa029044da1783db87744f0d82e874edd6cbab16e712ed8715aa
size 59881

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e682f5cb9ecb1bdf89281c2ba1612078e70e97f28c76facc64d717e4015ced6a
size 12977

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:15acfb041cc53ef9bd966d6edd53a6b692cdb645ae5cf34bc20e70d403371c30
size 34809

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5dc632962f8894c4f20a48c9b9e57d60470f3f83ef7f19d05854dba718610a2f
size 161820

View File

@ -0,0 +1,47 @@
[package]
name = "egui_kittest"
version.workspace = true
authors = ["Lucas Meurer <lucasmeurer96@gmail.com>", "Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
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

View File

@ -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");
}
```

View File

@ -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<Vec2>) -> 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)
}
}

View File

@ -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<egui::Event> {
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<egui::Key> {
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<egui::PointerButton> {
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,
}
}

View File

@ -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<TexturesDelta>,
update_fn: Box<dyn FnMut(&egui::Context) + 'a>,
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()
}
}

View File

@ -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(&current_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);
}
}
}
}

View File

@ -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::<Vec<_>>();
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::<u32>();
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,
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36c1b432140456ea5cbb687076b1c910aea8b31affd33a0ece22218f60af2d6e
size 2296

View File

@ -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"
]