├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ ├── mac_ci.yml │ └── linux_ci.yml ├── Cargo.toml ├── tests ├── observer.rs └── basic.rs ├── LICENSE ├── src ├── lib.rs ├── observer.rs ├── macros.rs └── functions.rs └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | target 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: gtk-rs 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gtk-test" 3 | version = "0.18.0" 4 | authors = ["The Gtk-rs Project Developers"] 5 | license = "MIT" 6 | description = "Crate to test GTK UIs" 7 | categories = ["api-bindings", "gui", "test"] 8 | homepage = "https://gtk-rs.org/" 9 | repository = "https://github.com/gtk-rs/gtk-test" 10 | keywords = ["gtk", "gtk-rs", "GUI", "test", "gnome"] 11 | documentation = "https://docs.rs/gtk-test" 12 | edition = "2021" 13 | 14 | [dependencies] 15 | enigo = "^0.0.14" 16 | gtk = "0.18" 17 | 18 | [[test]] 19 | harness = false 20 | name = "basic" 21 | 22 | [[test]] 23 | harness = false 24 | name = "observer" 25 | -------------------------------------------------------------------------------- /tests/observer.rs: -------------------------------------------------------------------------------- 1 | extern crate gtk; 2 | #[macro_use] 3 | extern crate gtk_test; 4 | 5 | use gtk::{prelude::GtkWindowExt, Window, WindowType}; 6 | 7 | fn main() { 8 | gtk::init().expect("initialization failed"); 9 | 10 | let window = Window::new(WindowType::Toplevel); 11 | 12 | let observer = observer_new!(window, connect_activate_focus, |_|); 13 | window.emit_activate_focus(); 14 | observer.wait(); 15 | let observer = observer_new!(window, connect_activate_focus, |w| { 16 | w.set_title("Title!"); 17 | }); 18 | window.emit_activate_focus(); 19 | observer.wait(); 20 | assert_title!(window, "Title!"); 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/mac_ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | name: MacOS CI 5 | 6 | jobs: 7 | check: 8 | name: Compile and Test 9 | runs-on: macOS-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install gtk3 and dependencies. 13 | run: brew install gtk+3 14 | 15 | - name: Install Rust 16 | uses: actions-rs/toolchain@v1 17 | with: 18 | profile: minimal 19 | toolchain: stable 20 | override: true 21 | 22 | - name: Check Code 23 | uses: actions-rs/cargo@v1 24 | with: 25 | command: check 26 | 27 | - name: Install Clippy 28 | run: rustup component add clippy 29 | 30 | - name: Run Clippy 31 | run: cargo clippy --all-targets -- -D warnings -A unknown-lints 32 | 33 | - name: Run Tests 34 | uses: actions-rs/cargo@v1 35 | with: 36 | command: test 37 | args: -- --test-threads=1 38 | -------------------------------------------------------------------------------- /.github/workflows/linux_ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | name: Linux CI 5 | 6 | jobs: 7 | check: 8 | name: Compile and Test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install gtk3 and dependencies. 13 | run: | 14 | sudo apt update 15 | sudo apt install \ 16 | libgtk-3-dev \ 17 | libxdo-dev 18 | 19 | - name: Install Rust 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | profile: minimal 23 | toolchain: stable 24 | override: true 25 | 26 | - name: Check Code 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: check 30 | 31 | - name: Install Clippy 32 | run: rustup component add clippy 33 | 34 | - name: Run Clippy 35 | run: cargo clippy --all-targets -- -D warnings -A unknown-lints 36 | 37 | - name: Run headless test 38 | uses: GabrielBB/xvfb-action@v1 39 | with: 40 | run: cargo test -- --test-threads=1 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Guillaume Gomez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/basic.rs: -------------------------------------------------------------------------------- 1 | extern crate gtk; 2 | #[macro_use] 3 | extern crate gtk_test; 4 | 5 | use gtk::{ 6 | prelude::ButtonExt, prelude::ContainerExt, prelude::GtkWindowExt, prelude::LabelExt, 7 | prelude::WidgetExt, Button, Label, Orientation, Window, WindowType, 8 | }; 9 | use gtk::glib::Propagation; 10 | 11 | pub fn init_ui() -> (Window, Label, Button) { 12 | gtk::init().unwrap(); 13 | 14 | let window = Window::new(WindowType::Toplevel); 15 | let b = gtk::Box::new(Orientation::Vertical, 0); 16 | let label = Label::new(Some("Test")); 17 | let but = Button::new(); 18 | 19 | let l = label.clone(); 20 | but.connect_clicked(move |_| { 21 | l.set_text("Clicked"); 22 | }); 23 | 24 | b.add(&label); 25 | b.add(&but); 26 | window.add(&b); 27 | window.show_all(); 28 | window.connect_delete_event(|_, _| { 29 | gtk::main_quit(); 30 | Propagation::Stop 31 | }); 32 | (window, label, but) 33 | } 34 | 35 | fn main() { 36 | let (w, l, b) = init_ui(); 37 | 38 | assert_text!(l, "Test"); 39 | w.activate_focus(); 40 | gtk_test::click(&b); 41 | assert_text!(l, "Clicked"); 42 | } 43 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | 3 | //! Crate to test UI interactions with [gtk-rs] crates. 4 | //! 5 | //! [gtk-rs]: https://gtk-rs.org 6 | //! 7 | //! Small example: 8 | //! 9 | //! ``` 10 | //! extern crate gtk; 11 | //! #[macro_use] 12 | //! extern crate gtk_test; 13 | //! 14 | //! use gtk::{prelude::ButtonExt, prelude::ContainerExt, prelude::GtkWindowExt, prelude::LabelExt, prelude::WidgetExt}; 15 | //! 16 | //! # fn main() { 17 | //! gtk::init().expect("GTK init failed"); 18 | //! 19 | //! let win = gtk::Window::new(gtk::WindowType::Toplevel); 20 | //! let but = gtk::Button::new(); 21 | //! 22 | //! but.set_label(""); // Otherwise, assert_label! call will fail. 23 | //! but.connect_clicked(|b| { 24 | //! b.set_label("clicked!"); 25 | //! }); 26 | //! 27 | //! win.add(&but); 28 | //! win.show_all(); 29 | //! win.activate_focus(); // Very important, otherwise tests will fail on OSX! 30 | //! 31 | //! assert_label!(but, ""); 32 | //! gtk_test::click(&but); 33 | //! gtk_test::wait(1000); // To be sure that GTK has updated the label's text. 34 | //! assert_label!(but, "clicked!"); 35 | //! # } 36 | //! ``` 37 | 38 | mod macros; 39 | 40 | mod functions; 41 | mod observer; 42 | 43 | pub use functions::*; 44 | pub use gtk; 45 | pub use gtk::gdk; 46 | pub use observer::Observer; 47 | -------------------------------------------------------------------------------- /src/observer.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::rc::Rc; 3 | 4 | /// Used to wait for a widget's signal. 5 | /// 6 | /// It's recommended to use it with the [`observer_new`] macro. 7 | /// 8 | /// Example: 9 | /// 10 | /// ``` 11 | /// extern crate gtk; 12 | /// #[macro_use] 13 | /// extern crate gtk_test; 14 | /// 15 | /// use gtk::prelude::GtkWindowExt; 16 | /// 17 | /// # fn main() { 18 | /// gtk::init().expect("initialization failed"); 19 | /// let window = gtk::Window::new(gtk::WindowType::Toplevel); 20 | /// 21 | /// let observer = observer_new!(window, connect_activate_focus, |_|); 22 | /// window.emit_activate_focus(); 23 | /// observer.wait(); 24 | /// # } 25 | /// ``` 26 | pub struct Observer { 27 | result: Rc>, 28 | } 29 | 30 | impl Observer { 31 | /// Returns a new observer. 32 | /// 33 | /// It's recommended to not use it directly as is but instead to use the [`observer_new`] macro. 34 | /// 35 | /// But anyway, here's an example using it as is: 36 | /// 37 | /// ``` 38 | /// extern crate gtk; 39 | /// #[macro_use] 40 | /// extern crate gtk_test; 41 | /// 42 | /// use gtk::prelude::GtkWindowExt; 43 | /// 44 | /// # fn main() { 45 | /// gtk::init().expect("GTK init failed"); 46 | /// 47 | /// let window = gtk::Window::new(gtk::WindowType::Toplevel); 48 | /// 49 | /// let observer = gtk_test::Observer::new(); 50 | /// let inner = observer.get_inner().clone(); 51 | /// window.connect_activate_focus(move |_| { 52 | /// *inner.borrow_mut() = true; 53 | /// }); 54 | /// 55 | /// window.emit_activate_focus(); 56 | /// observer.wait(); 57 | /// # } 58 | /// ``` 59 | pub fn new() -> Observer { 60 | Observer { 61 | result: Rc::new(RefCell::new(false)), 62 | } 63 | } 64 | 65 | /// Returns the inner field. Just don't use it. 66 | pub fn get_inner(&self) -> &Rc> { 67 | &self.result 68 | } 69 | 70 | /// Wait for the signal to be triggered. 71 | /// 72 | /// ``` 73 | /// extern crate gtk; 74 | /// #[macro_use] 75 | /// extern crate gtk_test; 76 | /// 77 | /// use gtk::prelude::GtkWindowExt; 78 | /// 79 | /// # fn main() { 80 | /// gtk::init().expect("initialization failed"); 81 | /// let window = gtk::Window::new(gtk::WindowType::Toplevel); 82 | /// 83 | /// let observer = observer_new!(window, connect_activate_focus, |_|); 84 | /// window.emit_activate_focus(); 85 | /// observer.wait(); 86 | /// # } 87 | /// ``` 88 | pub fn wait(&self) { 89 | loop { 90 | if let Ok(ref result) = self.result.try_borrow() { 91 | if **result { 92 | break; 93 | } 94 | } 95 | crate::run_loop(); 96 | } 97 | } 98 | } 99 | 100 | impl Default for Observer { 101 | fn default() -> Self { 102 | Self::new() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gtk-test 2 | 3 | This projects allows you to test your GTK's applications UI. It has to be used with [gtk-rs](https://gtk-rs.org) crates. 4 | 5 | ## How does it work? 6 | 7 | It's quite simple actually (even though you have to perform a few more things on OSX to make it work as expected...) : 8 | 9 | ```rust 10 | gtk::init().unwrap(); // You need to init GTK otherwise it'll just crash... 11 | ``` 12 | 13 | Then you build your UI as you would in normal time (using `Glade` or by hand). Only one thing actually changes: you must not call `gtk::main`! 14 | 15 | Once you have built your UI, just call the `gtk_test` macros/functions to test it. Just one note about this though: sometimes, you need to let time for GTK to process some events. For example, if you clicked on a button and you have an associated action to it, it's more careful to use `gtk_test::wait`. 16 | 17 | Another recommended thing is to give focus to the window in case you have to interact with it (to click on a button or to input some text...): 18 | 19 | ```rust 20 | let w = gtk::Window::new(); 21 | // ... 22 | w.activate_focus(); 23 | ``` 24 | 25 | ### General setup 26 | 27 | When running test, you need to specify that you only want **ONE** thread. To do so: 28 | 29 | ```bash 30 | cargo test -- --test-threads=1 31 | ``` 32 | 33 | Otherwise, GTK contexts might conflict into each others. 34 | 35 | ### Specific setup for OSX 36 | 37 | A few more things have to be done on OSX to make this work. First, you won't be able to add the `#[test]` attribute to your functions, it doesn't work. Instead, you have to write your test just like you would write a normal binary (so with a `main` function as entry point). 38 | 39 | A short example (you can find the full version in the `tests` folder of this repository): 40 | 41 | ```rust 42 | fn main() { 43 | let (w, l, b) = init_ui(); 44 | 45 | assert_text!(l, "Test"); 46 | w.activate_focus(); 47 | gtk_test::click(&b); 48 | gtk_test::wait(1000); // to be sure that GTK has updated the label's text 49 | assert_text!(l, "Clicked"); 50 | } 51 | ``` 52 | 53 | Then you need to add into your `Cargo.toml` file: 54 | 55 | ```toml 56 | [[test]] 57 | harness = false # This is the important line! 58 | name = "basic" 59 | ``` 60 | 61 | It allows your test to be run as a "normal" binary. 62 | 63 | ### Example? 64 | 65 | You can find a few in the tests folder. Just copy/paste it and you're good to go (don't forget to add the missing pieces in your `Cargo.toml` file!). 66 | 67 | ### Using it on CI? 68 | 69 | It's actually possible (only tested for GitHub Actions though). You "just" need a display server. Here's what you have to add in your CI configuration file to make it work: 70 | 71 | Install the following packages: 72 | 73 | * libgtk-3-dev 74 | * libxdo-dev 75 | 76 | Then run the application in a virtual display environment using [xvfb-action.](https://github.com/GabrielBB/xvfb-action) For example: 77 | 78 | ``` 79 | - name: Run headless test 80 | uses: GabrielBB/xvfb-action@v1 81 | with: 82 | run: cargo test -- --test-threads=1 83 | ``` 84 | 85 | Take a look at our `.github/workflows/linux_ci.yml` file to see how we set things up. 86 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// To check if the widget's label matches the given string. 2 | /// 3 | /// Example: 4 | /// 5 | /// ``` 6 | /// extern crate gtk; 7 | /// #[macro_use] 8 | /// extern crate gtk_test; 9 | /// 10 | /// use gtk::{Button, prelude::ButtonExt, prelude::LabelExt}; 11 | /// 12 | /// # fn main() { 13 | /// gtk::init().expect("GTK init failed"); 14 | /// let but = Button::new(); 15 | /// but.set_label("text"); 16 | /// assert_label!(but, "text"); 17 | /// # } 18 | /// ``` 19 | #[macro_export] 20 | macro_rules! assert_label { 21 | ($widget:expr, $string:expr) => { 22 | assert_eq!($widget.label().expect("get label"), $string.to_string()); 23 | }; 24 | } 25 | 26 | /// To check if the widget's text matches the given string. 27 | /// 28 | /// Example: 29 | /// 30 | /// ``` 31 | /// extern crate gtk; 32 | /// #[macro_use] 33 | /// extern crate gtk_test; 34 | /// 35 | /// use gtk::{Label, prelude::LabelExt}; 36 | /// 37 | /// # fn main() { 38 | /// gtk::init().expect("GTK init failed"); 39 | /// let label = Label::new(Some("I'm a label!")); 40 | /// assert_text!(label, "I'm a label!"); 41 | /// # } 42 | /// ``` 43 | #[macro_export] 44 | macro_rules! assert_text { 45 | ($widget:expr, $string:expr) => { 46 | assert_eq!($widget.text(), $string.to_string()); 47 | }; 48 | } 49 | 50 | /// To check if the widget's title matches the given string. 51 | /// 52 | /// Example: 53 | /// 54 | /// ``` 55 | /// extern crate gtk; 56 | /// #[macro_use] 57 | /// extern crate gtk_test; 58 | /// 59 | /// use gtk::{prelude::GtkWindowExt, Window, WindowType}; 60 | /// 61 | /// # fn main() { 62 | /// gtk::init().expect("GTK init failed"); 63 | /// let window = Window::new(WindowType::Toplevel); 64 | /// window.set_title("Fromage ?"); 65 | /// assert_title!(window, "Fromage ?"); 66 | /// # } 67 | /// ``` 68 | #[macro_export] 69 | macro_rules! assert_title { 70 | ($widget:expr, $string:expr) => { 71 | assert_eq!($widget.title().expect("get text"), $string.to_string()); 72 | }; 73 | } 74 | 75 | /// To check if the widget's name matches the given string. 76 | /// 77 | /// Example: 78 | /// 79 | /// ``` 80 | /// extern crate gtk; 81 | /// #[macro_use] 82 | /// extern crate gtk_test; 83 | /// 84 | /// use gtk::{prelude::WidgetExt, Button}; 85 | /// 86 | /// # fn main() { 87 | /// gtk::init().expect("GTK init failed"); 88 | /// let button = Button::new(); 89 | /// button.set_widget_name("Omelette"); 90 | /// assert_name!(button, "Omelette"); 91 | /// # } 92 | /// ``` 93 | #[macro_export] 94 | macro_rules! assert_name { 95 | ($widget:expr, $string:expr) => { 96 | assert_eq!($widget.widget_name(), $string.to_string()); 97 | }; 98 | } 99 | 100 | /// Create a new observer for signals. 101 | /// 102 | /// Example: 103 | /// 104 | /// ``` 105 | /// extern crate gtk; 106 | /// #[macro_use] 107 | /// extern crate gtk_test; 108 | /// 109 | /// use gtk::prelude::GtkWindowExt; 110 | /// 111 | /// # fn main() { 112 | /// gtk::init().expect("initialization failed"); 113 | /// let window = gtk::Window::new(gtk::WindowType::Toplevel); 114 | /// 115 | /// let observer = observer_new!(window, connect_activate_focus, |_|); 116 | /// window.emit_activate_focus(); 117 | /// observer.wait(); 118 | /// # } 119 | /// ``` 120 | /// 121 | /// You can also give a block to the macro that will be called when the signal is triggered: 122 | /// 123 | /// ``` 124 | /// extern crate gtk; 125 | /// #[macro_use] 126 | /// extern crate gtk_test; 127 | /// 128 | /// use gtk::prelude::GtkWindowExt; 129 | /// 130 | /// # fn main() { 131 | /// gtk::init().expect("initialization failed"); 132 | /// let window = gtk::Window::new(gtk::WindowType::Toplevel); 133 | /// 134 | /// let observer = observer_new!(window, connect_activate_focus, |w| { 135 | /// w.set_title("Caribou !"); 136 | /// }); 137 | /// window.emit_activate_focus(); 138 | /// observer.wait(); 139 | /// assert_title!(window, "Caribou !"); 140 | /// # } 141 | /// ``` 142 | #[macro_export] 143 | // TODO: should remove the signal after wait()? 144 | macro_rules! observer_new { 145 | ($widget:expr, $signal_name:ident, |$($arg:tt $(: $typ:ty)?),+|) => {{ 146 | let observer = $crate::Observer::new(); 147 | let res = (*observer.get_inner()).clone(); 148 | $widget.$signal_name(move |$($arg $(: $typ)?),+| { 149 | *res.borrow_mut() = true; 150 | }); 151 | observer 152 | }}; 153 | ($widget:expr, $signal_name:ident, |$($arg:tt $(: $typ:ty)?),+| $block:block) => {{ 154 | let observer = $crate::Observer::new(); 155 | let res = (*observer.get_inner()).clone(); 156 | $widget.$signal_name(move |$($arg $(: $typ)?),+| { 157 | *res.borrow_mut() = true; 158 | $block 159 | }); 160 | observer 161 | }} 162 | } 163 | -------------------------------------------------------------------------------- /src/functions.rs: -------------------------------------------------------------------------------- 1 | use enigo::{self, Enigo, KeyboardControllable, MouseButton, MouseControllable}; 2 | use gtk::gdk::keys::constants as key; 3 | use gtk::gdk::keys::Key; 4 | use gtk::glib::{Cast, ControlFlow, IsA, Object, Propagation, StaticType}; 5 | use gtk::prelude::{BinExt, ButtonExt, ContainerExt, EditableExt, ToolButtonExt, WidgetExt}; 6 | use gtk::{Bin, Button, Container, Entry, ToolButton, Widget, Window}; 7 | 8 | use crate::observer_new; 9 | 10 | /// Simulate a click on a widget. 11 | /// 12 | /// ## Warning! 13 | /// 14 | /// Please note that the click will "fail" if the window isn't on top of all other windows (this 15 | /// is a common issue on OSX). Don't forget to bring the button's window on top by using: 16 | /// 17 | /// ```ignore 18 | /// window.activate_focus(); 19 | /// ``` 20 | /// 21 | /// Example: 22 | /// 23 | /// ``` 24 | /// extern crate gtk; 25 | /// #[macro_use] 26 | /// extern crate gtk_test; 27 | /// 28 | /// use gtk::{Button, prelude::ButtonExt}; 29 | /// 30 | /// # fn main() { 31 | /// gtk::init().expect("GTK init failed"); 32 | /// let but = Button::new(); 33 | /// but.connect_clicked(|_| { 34 | /// println!("clicked"); 35 | /// }); 36 | /// gtk_test::click(&but); 37 | /// # } 38 | /// ``` 39 | pub fn click + IsA + WidgetExt + IsA>(widget: &W) { 40 | wait_for_draw(widget, || { 41 | let observer = if let Ok(tool_button) = widget.clone().dynamic_cast::() { 42 | observer_new!(tool_button, connect_clicked, |_|) 43 | } else if let Ok(tool_button) = widget.clone().dynamic_cast::