From e0ec54d1e5923de88338ca60a59dbe82f8e0727e Mon Sep 17 00:00:00 2001 From: Antoine POPINEAU Date: Mon, 29 Apr 2024 09:14:52 +0200 Subject: [PATCH] Added infrastructure to do integration testing. --- Cargo.lock | 125 ++++++++++++----- Cargo.toml | 5 + src/event.rs | 12 +- src/greeter.rs | 1 + src/integration/common/backend.rs | 214 ++++++++++++++++++++++++++++++ src/integration/common/mod.rs | 173 ++++++++++++++++++++++++ src/integration/exit.rs | 26 ++++ src/integration/menus.rs | 157 ++++++++++++++++++++++ src/integration/mod.rs | 7 + src/integration/movement.rs | 49 +++++++ src/integration/remember.rs | 43 ++++++ src/integration/simple.rs | 82 ++++++++++++ src/main.rs | 37 ++++-- src/ui/mod.rs | 8 +- 14 files changed, 889 insertions(+), 50 deletions(-) create mode 100644 src/integration/common/backend.rs create mode 100644 src/integration/common/mod.rs create mode 100644 src/integration/exit.rs create mode 100644 src/integration/menus.rs create mode 100644 src/integration/mod.rs create mode 100644 src/integration/movement.rs create mode 100644 src/integration/remember.rs create mode 100644 src/integration/simple.rs diff --git a/Cargo.lock b/Cargo.lock index ec263af..8cab39d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,12 +97,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.5.0" @@ -253,7 +247,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.5.0", + "bitflags", "crossterm_winapi", "futures-core", "libc", @@ -353,6 +347,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + [[package]] name = "find-crate" version = "0.6.3" @@ -531,6 +541,17 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "greetd-stub" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea0c791b4529519211981d4bf5c8bf77b95ac91816b7cde5348888b11469c28" +dependencies = [ + "getopts", + "greetd_ipc", + "tokio", +] + [[package]] name = "greetd_ipc" version = "0.10.0" @@ -546,9 +567,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", @@ -726,9 +747,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "locale_config" @@ -745,9 +772,9 @@ dependencies = [ [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -810,7 +837,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.5.0", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -913,9 +940,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" dependencies = [ "lock_api", "parking_lot_core", @@ -923,15 +950,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.5", ] [[package]] @@ -1048,7 +1075,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" dependencies = [ - "bitflags 2.5.0", + "bitflags", "cassowary", "compact_str", "crossterm", @@ -1064,11 +1091,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags 1.3.2", + "bitflags", ] [[package]] @@ -1157,6 +1184,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.15" @@ -1201,18 +1241,18 @@ checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba" [[package]] name = "serde" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.199" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc" dependencies = [ "proc-macro2", "quote", @@ -1323,9 +1363,9 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", @@ -1396,6 +1436,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -1641,6 +1693,7 @@ dependencies = [ "crossterm", "futures", "getopts", + "greetd-stub", "greetd_ipc", "i18n-embed", "i18n-embed-fl", @@ -1651,12 +1704,14 @@ dependencies = [ "rust-embed", "rust-ini", "smart-default", + "tempfile", "textwrap", "tokio", "tracing", "tracing-appender", "tracing-subscriber", "unic-langid", + "unicode-width", "uzers", "zeroize", ] @@ -1715,9 +1770,9 @@ checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" [[package]] name = "uzers" @@ -1829,9 +1884,9 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134306a13c5647ad6453e8deaec55d3a44d6021970129e6188735e74bf546697" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ "windows-sys 0.52.0", ] @@ -1992,9 +2047,9 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c976aaaa0e1f90dbb21e9587cdaf1d9679a1cde8875c0d6bd83ab96a208352" +checksum = "14b9415ee827af173ebb3f15f9083df5a122eb93572ec28741fb153356ea2578" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 23f75fe..16fdf13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,3 +47,8 @@ tracing = "0.1.40" [profile.release] lto = true + +[dev-dependencies] +greetd-stub = "0.2.0" +tempfile = "3.10.1" +unicode-width = "0.1.12" diff --git a/src/event.rs b/src/event.rs index 74f4138..cb97540 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,12 +1,15 @@ use std::time::Duration; -use crossterm::event::{Event as TermEvent, EventStream, KeyEvent}; +use crossterm::event::{Event as TermEvent, KeyEvent}; use futures::{future::FutureExt, StreamExt}; use tokio::{ process::Command, sync::mpsc::{self, Sender}, }; +#[cfg(not(test))] +use crossterm::event::EventStream; + use crate::AuthStatus; const FRAME_RATE: f64 = 2.0; @@ -31,7 +34,14 @@ impl Events { let tx = tx.clone(); async move { + #[cfg(not(test))] let mut stream = EventStream::new(); + + // In tests, we are not capturing events from the terminal, so we need + // to replace the crossterm::EventStream with a dummy pending stream. + #[cfg(test)] + let mut stream = futures::stream::pending::>(); + let mut render_interval = tokio::time::interval(Duration::from_secs_f64(1.0 / FRAME_RATE)); loop { diff --git a/src/greeter.rs b/src/greeter.rs index 85d846d..60bfd90 100644 --- a/src/greeter.rs +++ b/src/greeter.rs @@ -193,6 +193,7 @@ impl Greeter { selected: 0, }; + #[cfg(not(test))] greeter.parse_options().await; greeter.sessions = Menu { diff --git a/src/integration/common/backend.rs b/src/integration/common/backend.rs new file mode 100644 index 0000000..1856171 --- /dev/null +++ b/src/integration/common/backend.rs @@ -0,0 +1,214 @@ +#![allow(unused_must_use)] + +/* + Copied and adapted from the codebase of ratatui. + + Repository: https://github.com/ratatui-org/ratatui + License: https://github.com/ratatui-org/ratatui/blob/main/LICENSE + File: https://github.com/ratatui-org/ratatui/blob/f4637d40c35e068fd60d17c9a42b9114667c9861/src/backend/test.rs + + The MIT License (MIT) + + Copyright (c) 2016-2022 Florian Dehau + Copyright (c) 2023-2024 The Ratatui Developers + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ +use std::{ + fmt::Write, + io, + sync::{Arc, Mutex}, +}; + +use tokio::sync::mpsc; +use unicode_width::UnicodeWidthStr; + +use tui::{ + backend::{Backend, ClearType, WindowSize}, + buffer::{Buffer, Cell}, + layout::{Rect, Size}, +}; + +#[derive(Clone)] +pub struct TestBackend { + tick: mpsc::Sender, + width: u16, + buffer: Arc>, + height: u16, + cursor: bool, + pos: (u16, u16), +} + +pub fn output(buffer: &Arc>) -> String { + let buffer = buffer.lock().unwrap(); + + let mut view = String::with_capacity(buffer.content.len() + buffer.area.height as usize * 3); + for cells in buffer.content.chunks(buffer.area.width as usize) { + let mut overwritten = vec![]; + let mut skip: usize = 0; + view.push('"'); + for (x, c) in cells.iter().enumerate() { + if skip == 0 { + view.push_str(c.symbol()); + } else { + overwritten.push((x, c.symbol())); + } + skip = std::cmp::max(skip, c.symbol().width()).saturating_sub(1); + } + view.push('"'); + if !overwritten.is_empty() { + write!(&mut view, " Hidden by multi-width symbols: {overwritten:?}").unwrap(); + } + view.push('\n'); + } + view +} + +impl TestBackend { + pub fn new(width: u16, height: u16) -> (Self, Arc>, mpsc::Receiver) { + let buffer = Arc::new(Mutex::new(Buffer::empty(Rect::new(0, 0, width, height)))); + let (tx, rx) = mpsc::channel::(10); + + let backend = Self { + tick: tx, + width, + height, + buffer: buffer.clone(), + cursor: false, + pos: (0, 0), + }; + + (backend, buffer, rx) + } +} + +impl Backend for TestBackend { + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + let mut buffer = self.buffer.lock().unwrap(); + + for (x, y, c) in content { + let cell = buffer.get_mut(x, y); + *cell = c.clone(); + } + + let sender = self.tick.clone(); + + std::thread::spawn(move || { + sender.blocking_send(true); + }); + + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.cursor = false; + Ok(()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.cursor = true; + Ok(()) + } + + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + Ok(self.pos) + } + + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + self.pos = (x, y); + Ok(()) + } + + fn clear(&mut self) -> io::Result<()> { + self.buffer.lock().unwrap().reset(); + Ok(()) + } + + fn clear_region(&mut self, clear_type: tui::backend::ClearType) -> io::Result<()> { + let buffer = self.buffer.clone(); + let mut buffer = buffer.lock().unwrap(); + + match clear_type { + ClearType::All => self.clear()?, + ClearType::AfterCursor => { + let index = buffer.index_of(self.pos.0, self.pos.1) + 1; + buffer.content[index..].fill(Cell::default()); + } + ClearType::BeforeCursor => { + let index = buffer.index_of(self.pos.0, self.pos.1); + buffer.content[..index].fill(Cell::default()); + } + ClearType::CurrentLine => { + let line_start_index = buffer.index_of(0, self.pos.1); + let line_end_index = buffer.index_of(self.width - 1, self.pos.1); + buffer.content[line_start_index..=line_end_index].fill(Cell::default()); + } + ClearType::UntilNewLine => { + let index = buffer.index_of(self.pos.0, self.pos.1); + let line_end_index = buffer.index_of(self.width - 1, self.pos.1); + buffer.content[index..=line_end_index].fill(Cell::default()); + } + } + Ok(()) + } + + fn append_lines(&mut self, n: u16) -> io::Result<()> { + let (cur_x, cur_y) = self.get_cursor()?; + + let new_cursor_x = cur_x.saturating_add(1).min(self.width.saturating_sub(1)); + + let max_y = self.height.saturating_sub(1); + let lines_after_cursor = max_y.saturating_sub(cur_y); + if n > lines_after_cursor { + let rotate_by = n.saturating_sub(lines_after_cursor).min(max_y); + + if rotate_by == self.height - 1 { + self.clear()?; + } + + self.set_cursor(0, rotate_by)?; + self.clear_region(ClearType::BeforeCursor)?; + self.buffer.lock().unwrap().content.rotate_left((self.width * rotate_by).into()); + } + + let new_cursor_y = cur_y.saturating_add(n).min(max_y); + self.set_cursor(new_cursor_x, new_cursor_y)?; + + Ok(()) + } + + fn size(&self) -> io::Result { + Ok(Rect::new(0, 0, self.width, self.height)) + } + + fn window_size(&mut self) -> io::Result { + static WINDOW_PIXEL_SIZE: Size = Size { width: 640, height: 480 }; + Ok(WindowSize { + columns_rows: (self.width, self.height).into(), + pixels: WINDOW_PIXEL_SIZE, + }) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/src/integration/common/mod.rs b/src/integration/common/mod.rs new file mode 100644 index 0000000..9252b7f --- /dev/null +++ b/src/integration/common/mod.rs @@ -0,0 +1,173 @@ +mod backend; + +use std::{ + panic, + sync::{Arc, Mutex}, + time::Duration, +}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use libgreetd_stub::SessionOptions; +use tempfile::NamedTempFile; +use tokio::{ + sync::{ + mpsc::{Receiver, Sender}, + RwLock, + }, + task::{JoinError, JoinHandle}, +}; +use tui::buffer::Buffer; + +use crate::{ + event::{Event, Events}, + ui::sessions::SessionSource, + Greeter, +}; + +pub use self::backend::{output, TestBackend}; + +pub struct IntegrationRunner(Arc>); + +struct _IntegrationRunner { + server: Option>, + client: Option>, + + pub buffer: Arc>, + pub sender: Sender, + pub tick: Receiver, +} + +impl Clone for IntegrationRunner { + fn clone(&self) -> Self { + IntegrationRunner(Arc::clone(&self.0)) + } +} + +impl IntegrationRunner { + pub async fn new(opts: SessionOptions<'static>, builder: Option) -> IntegrationRunner { + let socket = NamedTempFile::new().unwrap().into_temp_path().to_path_buf(); + + let (backend, buffer, tick) = TestBackend::new(200, 200); + let events = Events::new().await; + let sender = events.sender(); + + let server = tokio::task::spawn({ + let socket = socket.clone(); + + async move { + libgreetd_stub::start(&socket, &opts).await; + } + }); + + let client = tokio::task::spawn(async move { + let mut greeter = Greeter::new(events.sender()).await; + greeter.session_source = SessionSource::Command("uname".to_string()); + + if let Some(builder) = builder { + builder(&mut greeter); + } + + if greeter.config.is_none() { + greeter.config = Greeter::options().parse(&[""]).ok(); + } + + greeter.logfile = "/tmp/tuigreet.log".to_string(); + greeter.socket = socket.to_str().unwrap().to_string(); + greeter.events = Some(events.sender()); + greeter.connect().await; + + let _ = crate::run(backend, greeter, events).await; + }); + + IntegrationRunner(Arc::new(RwLock::new(_IntegrationRunner { + server: Some(server), + client: Some(client), + buffer, + sender, + tick, + }))) + } + + pub async fn join_until_client_exit(&mut self, mut events: JoinHandle<()>) { + let (mut server, mut client) = { + let mut runner = self.0.write().await; + + (runner.server.take().unwrap(), runner.client.take().unwrap()) + }; + + let mut exited = false; + + while !exited { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(5)) => break, + _ = (&mut server) => {} + _ = (&mut client) => { exited = true; }, + ret = (&mut events), if !events.is_finished() => rethrow(ret), + } + } + + assert!(exited, "tuigreet did not exit"); + } + + pub async fn join_until_end(&mut self, events: JoinHandle<()>) { + let (server, client) = { + let mut runner = self.0.write().await; + + (runner.server.take().unwrap(), runner.client.take().unwrap()) + }; + + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(5)) => {}, + _ = server => {} + _ = client => {}, + ret = events => rethrow(ret), + } + } + + #[allow(unused)] + pub async fn wait_until_buffer_contains(&mut self, needle: &str) { + loop { + if output(&self.0.read().await.buffer).contains(needle) { + return; + } + + self.wait_for_render().await; + } + } + + #[allow(unused, unused_must_use)] + pub async fn send_key(&self, key: KeyCode) { + self.0.write().await.sender.send(Event::Key(KeyEvent::new(key, KeyModifiers::empty()))).await; + } + + #[allow(unused, unused_must_use)] + pub async fn send_modified_key(&self, key: KeyCode, modifiers: KeyModifiers) { + self.0.write().await.sender.send(Event::Key(KeyEvent::new(key, modifiers))).await; + } + + #[allow(unused, unused_must_use)] + pub async fn send_text(&self, text: &str) { + for char in text.chars() { + self.0.write().await.sender.send(Event::Key(KeyEvent::new(KeyCode::Char(char), KeyModifiers::empty()))).await; + } + + self.0.write().await.sender.send(Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))).await; + } + + #[allow(unused)] + pub async fn wait_for_render(&mut self) { + self.0.write().await.tick.recv().await; + } + + pub async fn output(&self) -> String { + output(&self.0.read().await.buffer) + } +} + +fn rethrow(result: Result<(), JoinError>) { + if let Err(err) = result { + if let Ok(panick) = err.try_into_panic() { + panic::resume_unwind(panick); + } + } +} diff --git a/src/integration/exit.rs b/src/integration/exit.rs new file mode 100644 index 0000000..719d56d --- /dev/null +++ b/src/integration/exit.rs @@ -0,0 +1,26 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use libgreetd_stub::SessionOptions; + +use super::common::IntegrationRunner; + +#[tokio::test] +async fn exit() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.send_modified_key(KeyCode::Char('x'), KeyModifiers::CONTROL).await; + runner.wait_for_render().await; + } + }); + + runner.join_until_client_exit(events).await; +} diff --git a/src/integration/menus.rs b/src/integration/menus.rs new file mode 100644 index 0000000..6c41818 --- /dev/null +++ b/src/integration/menus.rs @@ -0,0 +1,157 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use libgreetd_stub::SessionOptions; + +use crate::{ + power::PowerOption, + ui::{common::menu::Menu, power::Power, sessions::Session}, +}; + +use super::common::IntegrationRunner; + +#[tokio::test] +async fn change_command() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_key(KeyCode::F(3)).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("CMD uname")); + + runner.send_key(KeyCode::F(2)).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Change session command")); + assert!(runner.output().await.contains("New command: uname")); + + runner.send_modified_key(KeyCode::Char('u'), KeyModifiers::CONTROL).await; + runner.send_text("mynewcommand").await; + runner.send_key(KeyCode::Enter).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("CMD mynewcommand")); + } + }); + + runner.join_until_end(events).await; +} + +#[tokio::test] +async fn session_menu() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new( + opts, + Some(|greeter| { + greeter.sessions = Menu:: { + title: "List of sessions".to_string(), + options: vec![ + Session { + name: "My Session".to_string(), + ..Default::default() + }, + Session { + name: "Second Session".to_string(), + ..Default::default() + }, + ], + selected: 0, + }; + }), + ) + .await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_key(KeyCode::F(3)).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("List of sessions")); + assert!(runner.output().await.contains("My Session")); + assert!(runner.output().await.contains("Second Session")); + + runner.send_key(KeyCode::Down).await; + runner.send_key(KeyCode::Down).await; + runner.send_key(KeyCode::Enter).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("CMD Second Session")); + + runner.send_key(KeyCode::F(3)).await; + runner.wait_for_render().await; + runner.send_key(KeyCode::Up).await; + runner.send_key(KeyCode::Up).await; + runner.send_key(KeyCode::Enter).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("CMD My Session")); + } + }); + + runner.join_until_end(events).await; +} + +#[tokio::test] +async fn power_menu() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new( + opts, + Some(|greeter| { + greeter.powers = Menu:: { + title: "What to do?".to_string(), + options: vec![ + Power { + action: PowerOption::Shutdown, + label: "Turn it off".to_string(), + ..Default::default() + }, + Power { + action: PowerOption::Reboot, + label: "And back on again".to_string(), + ..Default::default() + }, + ], + selected: 0, + }; + }), + ) + .await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_key(KeyCode::F(12)).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("What to do?")); + assert!(runner.output().await.contains("Turn it off")); + assert!(runner.output().await.contains("And back on again")); + } + }); + + runner.join_until_end(events).await; +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs new file mode 100644 index 0000000..46eb5dc --- /dev/null +++ b/src/integration/mod.rs @@ -0,0 +1,7 @@ +mod common; + +mod exit; +mod menus; +mod movement; +mod remember; +mod simple; diff --git a/src/integration/movement.rs b/src/integration/movement.rs new file mode 100644 index 0000000..dae3213 --- /dev/null +++ b/src/integration/movement.rs @@ -0,0 +1,49 @@ +use crossterm::event::{KeyCode, KeyModifiers}; +use libgreetd_stub::SessionOptions; + +use super::common::IntegrationRunner; + +#[tokio::test] +async fn keyboard_movement() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + for char in "apognu".chars() { + runner.send_key(KeyCode::Char(char)).await; + } + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Username: apognu")); + + runner.send_key(KeyCode::Left).await; + runner.send_key(KeyCode::Char('l')).await; + runner.send_key(KeyCode::Right).await; + runner.send_key(KeyCode::Char('r')).await; + runner.send_modified_key(KeyCode::Char('a'), KeyModifiers::CONTROL).await; + runner.send_key(KeyCode::Char('a')).await; + runner.send_modified_key(KeyCode::Char('e'), KeyModifiers::CONTROL).await; + runner.send_key(KeyCode::Char('e')).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Username: aapognlure")); + + runner.send_key(KeyCode::Left).await; + runner.send_modified_key(KeyCode::Char('u'), KeyModifiers::CONTROL).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Username: ")); + } + }); + + runner.join_until_end(events).await; +} diff --git a/src/integration/remember.rs b/src/integration/remember.rs new file mode 100644 index 0000000..bb4d1bb --- /dev/null +++ b/src/integration/remember.rs @@ -0,0 +1,43 @@ +use crossterm::event::KeyCode; +use libgreetd_stub::SessionOptions; + +use crate::ui::common::masked::MaskedString; + +use super::common::IntegrationRunner; + +#[tokio::test] +async fn remember_username() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new( + opts, + Some(|greeter| { + greeter.remember = true; + greeter.username = MaskedString::from("apognu".to_string(), None); + }), + ) + .await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + + assert!(runner.output().await.contains("Username: apognu")); + + runner.wait_until_buffer_contains("Password:").await; + runner.send_key(KeyCode::Esc).await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Username: ")); + assert!(!runner.output().await.contains("Password:")); + } + }); + + runner.join_until_end(events).await; +} diff --git a/src/integration/simple.rs b/src/integration/simple.rs new file mode 100644 index 0000000..7df368a --- /dev/null +++ b/src/integration/simple.rs @@ -0,0 +1,82 @@ +use libgreetd_stub::SessionOptions; + +use super::common::IntegrationRunner; + +#[tokio::test] +async fn authentication_ok() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_text("apognu").await; + runner.wait_until_buffer_contains("Password:").await; + runner.send_text("password").await; + } + }); + + runner.join_until_client_exit(events).await; +} + +#[tokio::test] +async fn authentication_bad_password() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: false, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + { + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_text("apognu").await; + runner.wait_until_buffer_contains("Password:").await; + runner.send_text("password2").await; + runner.wait_for_render().await; + + assert!(runner.output().await.contains("Authentication failed")); + } + } + }); + + runner.join_until_end(events).await; +} + +#[tokio::test] +async fn authentication_ok_mfa() { + let opts = SessionOptions { + username: "apognu", + password: "password", + mfa: true, + }; + + let mut runner = IntegrationRunner::new(opts, None).await; + + let events = tokio::task::spawn({ + let mut runner = runner.clone(); + + async move { + runner.wait_until_buffer_contains("Username:").await; + runner.send_text("apognu").await; + runner.wait_until_buffer_contains("Password:").await; + runner.send_text("password").await; + runner.wait_until_buffer_contains("7 + 2 =").await; + runner.send_text("9").await; + } + }); + + runner.join_until_client_exit(events).await; +} diff --git a/src/main.rs b/src/main.rs index 4fc5c05..eb2f47a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,11 +12,14 @@ mod keyboard; mod power; mod ui; +#[cfg(test)] +mod integration; + use std::{error::Error, fs::OpenOptions, io, process, sync::Arc}; use crossterm::{ execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{disable_raw_mode, LeaveAlternateScreen}, }; use event::Event; use greetd_ipc::Request; @@ -24,12 +27,19 @@ use tokio::sync::RwLock; use tracing_appender::non_blocking::WorkerGuard; use tui::{backend::CrosstermBackend, Terminal}; +#[cfg(not(test))] +use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen}; + pub use self::greeter::*; use self::{event::Events, ipc::Ipc}; #[tokio::main] async fn main() { - if let Err(error) = run().await { + let backend = CrosstermBackend::new(io::stdout()); + let events = Events::new().await; + let greeter = Greeter::new(events.sender()).await; + + if let Err(error) = run(backend, greeter, events).await { if let Some(AuthStatus::Success) = error.downcast_ref::() { return; } @@ -38,23 +48,25 @@ async fn main() { } } -async fn run() -> Result<(), Box> { - let mut events = Events::new().await; - let mut greeter = Greeter::new(events.sender()).await; - let mut stdout = io::stdout(); - +async fn run(backend: B, mut greeter: Greeter, mut events: Events) -> Result<(), Box> +where + B: tui::backend::Backend, +{ let _guard = init_logger(&greeter); tracing::info!("tuigreet started"); register_panic_handler(); - enable_raw_mode()?; - execute!(stdout, EnterAlternateScreen)?; + #[cfg(not(test))] + { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + } - let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + #[cfg(not(test))] terminal.clear()?; let ipc = Ipc::new(); @@ -116,7 +128,9 @@ async fn exit(greeter: &mut Greeter, status: AuthStatus) { AuthStatus::Cancel | AuthStatus::Failure => Ipc::cancel(greeter).await, } + #[cfg(not(test))] clear_screen(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = disable_raw_mode(); @@ -127,7 +141,9 @@ fn register_panic_handler() { let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { + #[cfg(not(test))] clear_screen(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = disable_raw_mode(); @@ -135,6 +151,7 @@ fn register_panic_handler() { })); } +#[cfg(not(test))] pub fn clear_screen() { let backend = CrosstermBackend::new(io::stdout()); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e90f143..a77f6d9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,7 +17,6 @@ use std::{ use chrono::prelude::*; use tokio::sync::RwLock; use tui::{ - backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, style::Modifier, text::{Line, Span}, @@ -39,11 +38,12 @@ const STATUSBAR_INDEX: usize = 3; const STATUSBAR_LEFT_INDEX: usize = 1; const STATUSBAR_RIGHT_INDEX: usize = 2; -pub(super) type Backend = CrosstermBackend; -pub(super) type Term = Terminal; pub(super) type Frame<'a> = CrosstermFrame<'a>; -pub async fn draw(greeter: Arc>, terminal: &mut Term) -> Result<(), Box> { +pub async fn draw(greeter: Arc>, terminal: &mut Terminal) -> Result<(), Box> +where + B: tui::backend::Backend, +{ let mut greeter = greeter.write().await; let hide_cursor = should_hide_cursor(&greeter);