mirror of
https://github.com/apognu/tuigreet.git
synced 2024-09-11 07:25:29 +03:00
Compare commits
5 Commits
75c2706f0b
...
2cc9fd9f97
Author | SHA1 | Date | |
---|---|---|---|
|
2cc9fd9f97 | ||
|
51e7a60b7a | ||
|
0776e1cc42 | ||
|
d4eea0d70b | ||
|
4ca29169e5 |
5
.github/workflows/dev.yml
vendored
5
.github/workflows/dev.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: |
|
||||
sudo apt update && sudo apt install -y libnss-wrapper
|
||||
sudo apt update && sudo apt install -y libnss-wrapper scdoc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Test
|
||||
env:
|
||||
@ -32,6 +32,9 @@ jobs:
|
||||
run: |
|
||||
cargo test
|
||||
LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_
|
||||
- name: Generate manpage
|
||||
run: |
|
||||
scdoc < contrib/man/tuigreet-1.scd > /dev/null
|
||||
|
||||
build:
|
||||
strategy:
|
||||
|
5
.github/workflows/pr.yml
vendored
5
.github/workflows/pr.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: |
|
||||
sudo apt update && sudo apt install -y libnss-wrapper
|
||||
sudo apt update && sudo apt install -y libnss-wrapper scdoc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Test
|
||||
env:
|
||||
@ -32,6 +32,9 @@ jobs:
|
||||
run: |
|
||||
cargo test
|
||||
LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_
|
||||
- name: Generate manpage
|
||||
run: |
|
||||
scdoc < contrib/man/tuigreet-1.scd > /dev/null
|
||||
|
||||
build:
|
||||
strategy:
|
||||
|
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: |
|
||||
sudo apt update && sudo apt install -y libnss-wrapper
|
||||
sudo apt update && sudo apt install -y libnss-wrapper scdoc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Test
|
||||
env:
|
||||
@ -32,6 +32,9 @@ jobs:
|
||||
run: |
|
||||
cargo test
|
||||
LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_
|
||||
- name: Generate manpage
|
||||
run: |
|
||||
scdoc < contrib/man/tuigreet-1.scd > /dev/null
|
||||
|
||||
build:
|
||||
strategy:
|
||||
|
5
.github/workflows/tip.yml
vendored
5
.github/workflows/tip.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: |
|
||||
sudo apt update && sudo apt install -y libnss-wrapper
|
||||
sudo apt update && sudo apt install -y libnss-wrapper scdoc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Test
|
||||
env:
|
||||
@ -32,6 +32,9 @@ jobs:
|
||||
run: |
|
||||
cargo test
|
||||
LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_
|
||||
- name: Generate manpage
|
||||
run: |
|
||||
scdoc < contrib/man/tuigreet-1.scd > /dev/null
|
||||
|
||||
build:
|
||||
strategy:
|
||||
|
624
Cargo.lock
generated
624
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ default = []
|
||||
nsswrapper = []
|
||||
|
||||
[dependencies]
|
||||
ansi-to-tui = "4.1.0"
|
||||
chrono = { version = "^0.4", features = ["unstable-locales"] }
|
||||
crossterm = { version = "^0.27", features = ["event-stream"] }
|
||||
futures = "0.3"
|
||||
@ -24,11 +25,11 @@ lazy_static = "^1.4"
|
||||
nix = { version = "^0.28", features = ["feature"] }
|
||||
tui = { package = "ratatui", version = "^0.26", default-features = false, features = [
|
||||
"crossterm",
|
||||
"unstable"
|
||||
] }
|
||||
rust-embed = "^8.0"
|
||||
rust-ini = "^0.21"
|
||||
smart-default = "^0.7"
|
||||
textwrap = "^0.16"
|
||||
tokio = { version = "^1.2", default-features = false, features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
@ -47,3 +48,8 @@ tracing = "0.1.40"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
[dev-dependencies]
|
||||
greetd-stub = "0.3.0"
|
||||
tempfile = "3.10.1"
|
||||
unicode-width = "0.1.12"
|
||||
|
@ -69,7 +69,7 @@ The default configuration tends to be as minimal as possible, visually speaking,
|
||||
|
||||
The initial prompt container will be 80 column wide. You may change this with `--width` in case you need more space (for example, to account for large PAM challenge messages). Please refer to usage information (`--help`) for more customization options. Various padding settings are available through the `*-padding` options.
|
||||
|
||||
You can instruct `tuigreet` to remember the last username that successfully opened a session with the `--remember` option (that way, the username field will be pre-filled). Similarly, the command and session configuration can be retained between runs with the `--remember-session` option (when using this, the `--cmd` value is overridden by manual selections). You can also remember the selected session per user with the `--remember-user-session` flag. In this case, the selected session will only be saved on successful authentication.
|
||||
You can instruct `tuigreet` to remember the last username that successfully opened a session with the `--remember` option (that way, the username field will be pre-filled). Similarly, the command and session configuration can be retained between runs with the `--remember-session` option (when using this, the `--cmd` value is overridden by manual selections). You can also remember the selected session per user with the `--remember-user-session` flag. In this case, the selected session will only be saved on successful authentication. Check the [cache instructions](#cache-instructions) if `/var/cache/tuigreet` doesn't exist after installing tuigreet.
|
||||
|
||||
You may change the command that will be executed after opening a session by hitting `F2` and amending the command. Alternatively, you can list the system-declared sessions (or custom ones) by hitting `F3`. Power options are available through `F12`.
|
||||
|
||||
@ -85,6 +85,7 @@ $ cargo build --release
|
||||
# mv target/release/tuigreet /usr/local/bin/tuigreet
|
||||
```
|
||||
|
||||
<a id="cache-instructions"></a>
|
||||
Cache directory must be created for `--remember*` features to work. The directory must be owned by the user running the greeter.
|
||||
|
||||
```
|
||||
|
12
src/event.rs
12
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::<Result<TermEvent, ()>>();
|
||||
|
||||
let mut render_interval = tokio::time::interval(Duration::from_secs_f64(1.0 / FRAME_RATE));
|
||||
|
||||
loop {
|
||||
|
158
src/greeter.rs
158
src/greeter.rs
@ -2,6 +2,7 @@ use std::{
|
||||
convert::TryInto,
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsStr,
|
||||
fmt::{self, Display},
|
||||
path::PathBuf,
|
||||
process,
|
||||
@ -156,6 +157,10 @@ pub struct Greeter {
|
||||
|
||||
// Style object for the terminal UI
|
||||
pub theme: Theme,
|
||||
// Display the current time
|
||||
pub time: bool,
|
||||
// Time format
|
||||
pub time_format: Option<String>,
|
||||
// Greeting message (MOTD) to use to welcome the user.
|
||||
pub greeting: Option<String>,
|
||||
// Transaction message to show to the user.
|
||||
@ -200,7 +205,27 @@ impl Greeter {
|
||||
selected: 0,
|
||||
};
|
||||
|
||||
greeter.parse_options().await;
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
match env::var("GREETD_SOCK") {
|
||||
Ok(socket) => greeter.socket = socket,
|
||||
Err(_) => {
|
||||
eprintln!("GREETD_SOCK must be defined");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let args = env::args().collect::<Vec<String>>();
|
||||
|
||||
if let Err(err) = greeter.parse_options(&args).await {
|
||||
eprintln!("{err}");
|
||||
print_usage(Greeter::options());
|
||||
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
greeter.connect().await;
|
||||
}
|
||||
|
||||
let sessions = get_sessions(&greeter).unwrap_or_default();
|
||||
|
||||
@ -435,17 +460,15 @@ impl Greeter {
|
||||
}
|
||||
|
||||
// Parses command line arguments to configured the software accordingly.
|
||||
pub async fn parse_options(&mut self) {
|
||||
pub async fn parse_options<S>(&mut self, args: &[S]) -> Result<(), Box<dyn Error>>
|
||||
where
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let opts = Greeter::options();
|
||||
|
||||
self.config = match opts.parse(env::args().collect::<Vec<String>>()) {
|
||||
self.config = match opts.parse(args) {
|
||||
Ok(matches) => Some(matches),
|
||||
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
if self.config().opt_present("help") {
|
||||
@ -457,14 +480,6 @@ impl Greeter {
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
match env::var("GREETD_SOCK") {
|
||||
Ok(socket) => self.socket = socket,
|
||||
Err(_) => {
|
||||
eprintln!("GREETD_SOCK must be defined");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if self.config().opt_present("debug") {
|
||||
self.debug = true;
|
||||
|
||||
@ -475,9 +490,7 @@ impl Greeter {
|
||||
}
|
||||
|
||||
if self.config().opt_present("issue") && self.config().opt_present("greeting") {
|
||||
eprintln!("Only one of --issue and --greeting may be used at the same time");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
return Err("Only one of --issue and --greeting may be used at the same time".into());
|
||||
}
|
||||
|
||||
if self.config().opt_present("theme") {
|
||||
@ -489,9 +502,7 @@ impl Greeter {
|
||||
if self.config().opt_present("asterisks") {
|
||||
let asterisk = if let Some(value) = self.config().opt_str("asterisks-char") {
|
||||
if value.chars().count() < 1 {
|
||||
eprintln!("--asterisks-char must have at least one character as its value");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
return Err("--asterisks-char must have at least one character as its value".into());
|
||||
}
|
||||
|
||||
value
|
||||
@ -502,11 +513,14 @@ impl Greeter {
|
||||
self.secret_display = SecretDisplay::Character(asterisk);
|
||||
}
|
||||
|
||||
self.time = self.config().opt_present("time");
|
||||
|
||||
if let Some(format) = self.config().opt_str("time-format") {
|
||||
if StrftimeItems::new(&format).any(|item| item == Item::Error) {
|
||||
eprintln!("Invalid strftime format provided in --time-format");
|
||||
process::exit(1);
|
||||
return Err("Invalid strftime format provided in --time-format".into());
|
||||
}
|
||||
|
||||
self.time_format = Some(format);
|
||||
}
|
||||
|
||||
if self.config().opt_present("user-menu") {
|
||||
@ -519,8 +533,7 @@ impl Greeter {
|
||||
tracing::info!("min/max UIDs are {}/{}", min_uid, max_uid);
|
||||
|
||||
if min_uid >= max_uid {
|
||||
eprintln!("Minimum UID ({min_uid}) must be less than maximum UID ({max_uid})");
|
||||
process::exit(1);
|
||||
return Err("Minimum UID ({min_uid}) must be less than maximum UID ({max_uid})".into());
|
||||
}
|
||||
|
||||
self.users = Menu {
|
||||
@ -533,14 +546,10 @@ impl Greeter {
|
||||
}
|
||||
|
||||
if self.config().opt_present("remember-session") && self.config().opt_present("remember-user-session") {
|
||||
eprintln!("Only one of --remember-session and --remember-user-session may be used at the same time");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
return Err("Only one of --remember-session and --remember-user-session may be used at the same time".into());
|
||||
}
|
||||
if self.config().opt_present("remember-user-session") && !self.config().opt_present("remember") {
|
||||
eprintln!("--remember-session must be used with --remember");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
return Err("--remember-session must be used with --remember".into());
|
||||
}
|
||||
|
||||
self.remember = self.config().opt_present("remember");
|
||||
@ -592,12 +601,10 @@ impl Greeter {
|
||||
self.kb_power = self.config().opt_str("kb-power").map(|i| i.parse::<u8>().unwrap_or_default()).unwrap_or(12);
|
||||
|
||||
if self.kb_command == self.kb_sessions || self.kb_sessions == self.kb_power || self.kb_power == self.kb_command {
|
||||
eprintln!("keybindings must all be distinct");
|
||||
print_usage(opts);
|
||||
process::exit(1);
|
||||
return Err("keybindings must all be distinct".into());
|
||||
}
|
||||
|
||||
self.connect().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_prompt(&mut self, prompt: &str) {
|
||||
@ -632,7 +639,7 @@ fn print_version() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::Greeter;
|
||||
use crate::{ui::sessions::SessionSource, Greeter, SecretDisplay};
|
||||
|
||||
#[test]
|
||||
fn test_prompt_width() {
|
||||
@ -662,4 +669,79 @@ mod test {
|
||||
|
||||
assert_eq!(greeter.prompt, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_command_line_arguments() {
|
||||
let table: &[(&[&str], _, Option<fn(&Greeter)>)] = &[
|
||||
// No arguments
|
||||
(&[], true, None),
|
||||
// Valid combinations
|
||||
(&["--cmd", "hello"], true, None),
|
||||
(
|
||||
&[
|
||||
"--cmd",
|
||||
"uname",
|
||||
"--asterisks",
|
||||
"--asterisks-char",
|
||||
".",
|
||||
"--issue",
|
||||
"--time",
|
||||
"--prompt-padding",
|
||||
"0",
|
||||
"--window-padding",
|
||||
"1",
|
||||
"--container-padding",
|
||||
"12",
|
||||
"--user-menu",
|
||||
],
|
||||
true,
|
||||
Some(|greeter| {
|
||||
assert!(matches!(&greeter.session_source, SessionSource::Command(cmd) if cmd == "uname"));
|
||||
assert!(matches!(&greeter.secret_display, SecretDisplay::Character(c) if c == "."));
|
||||
assert_eq!(greeter.prompt_padding(), 0);
|
||||
assert_eq!(greeter.window_padding(), 1);
|
||||
assert_eq!(greeter.container_padding(), 13);
|
||||
assert_eq!(greeter.user_menu, true);
|
||||
assert!(matches!(greeter.xsession_wrapper.as_deref(), Some("startx /usr/bin/env")));
|
||||
}),
|
||||
),
|
||||
(
|
||||
&["--xsession-wrapper", "mywrapper.sh"],
|
||||
true,
|
||||
Some(|greeter| {
|
||||
assert!(matches!(greeter.xsession_wrapper.as_deref(), Some("mywrapper.sh")));
|
||||
}),
|
||||
),
|
||||
(
|
||||
&["--no-xsession-wrapper"],
|
||||
true,
|
||||
Some(|greeter| {
|
||||
assert!(matches!(greeter.xsession_wrapper, None));
|
||||
}),
|
||||
),
|
||||
// Invalid combinations
|
||||
(&["--remember-session", "--remember-user-session"], false, None),
|
||||
(&["--asterisk-char", ""], false, None),
|
||||
(&["--remember-user-session"], false, None),
|
||||
(&["--min-uid", "10000", "--max-uid", "5000"], false, None),
|
||||
(&["--issue", "--greeting", "Hello, world!"], false, None),
|
||||
(&["--kb-command", "F2", "--kb-sessions", "F2"], false, None),
|
||||
(&["--time-format", "%i %"], false, None),
|
||||
];
|
||||
|
||||
for (opts, valid, check) in table {
|
||||
let mut greeter = Greeter::default();
|
||||
|
||||
match valid {
|
||||
true => {
|
||||
assert!(matches!(greeter.parse_options(*opts).await, Ok(())), "{:?} cannot be parsed", opts);
|
||||
|
||||
if let Some(check) = check {
|
||||
check(&greeter);
|
||||
}
|
||||
}
|
||||
false => assert!(matches!(greeter.parse_options(*opts).await, Err(_))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -63,6 +63,9 @@ pub fn get_issue() -> Option<String> {
|
||||
.replace("\\v", uts.version().to_str().unwrap_or(""))
|
||||
.replace("\\n", uts.nodename().to_str().unwrap_or(""))
|
||||
.replace("\\m", uts.machine().to_str().unwrap_or(""))
|
||||
.replace("\\x1b", "\x1b")
|
||||
.replace("\\033", "\x1b")
|
||||
.replace("\\e", "\x1b")
|
||||
.replace("\\\\", "\\"),
|
||||
),
|
||||
|
||||
|
112
src/integration/auth.rs
Normal file
112
src/integration/auth.rs
Normal file
@ -0,0 +1,112 @@
|
||||
use libgreetd_stub::SessionOptions;
|
||||
|
||||
use super::common::IntegrationRunner;
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_ok() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
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".to_string(),
|
||||
password: "password".to_string(),
|
||||
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".to_string(),
|
||||
password: "password".to_string(),
|
||||
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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_bad_mfa() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
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("10").await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Authentication failed"));
|
||||
assert!(runner.output().await.contains("Password:"));
|
||||
}
|
||||
});
|
||||
|
||||
runner.join_until_end(events).await;
|
||||
}
|
212
src/integration/common/backend.rs
Normal file
212
src/integration/common/backend.rs
Normal file
@ -0,0 +1,212 @@
|
||||
#![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<bool>,
|
||||
width: u16,
|
||||
buffer: Arc<Mutex<Buffer>>,
|
||||
height: u16,
|
||||
cursor: bool,
|
||||
pos: (u16, u16),
|
||||
}
|
||||
|
||||
pub fn output(buffer: &Arc<Mutex<Buffer>>) -> 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;
|
||||
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);
|
||||
}
|
||||
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<Mutex<Buffer>>, mpsc::Receiver<bool>) {
|
||||
let buffer = Arc::new(Mutex::new(Buffer::empty(Rect::new(0, 0, width, height))));
|
||||
let (tx, rx) = mpsc::channel::<bool>(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<Item = (u16, u16, &'a Cell)>,
|
||||
{
|
||||
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<Rect> {
|
||||
Ok(Rect::new(0, 0, self.width, self.height))
|
||||
}
|
||||
|
||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||
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(())
|
||||
}
|
||||
}
|
181
src/integration/common/mod.rs
Normal file
181
src/integration/common/mod.rs
Normal file
@ -0,0 +1,181 @@
|
||||
mod backend;
|
||||
mod output;
|
||||
|
||||
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(super) use self::{
|
||||
backend::{output, TestBackend},
|
||||
output::*,
|
||||
};
|
||||
|
||||
pub(super) struct IntegrationRunner(Arc<RwLock<_IntegrationRunner>>);
|
||||
|
||||
struct _IntegrationRunner {
|
||||
server: Option<JoinHandle<()>>,
|
||||
client: Option<JoinHandle<()>>,
|
||||
|
||||
pub buffer: Arc<Mutex<Buffer>>,
|
||||
pub sender: Sender<Event>,
|
||||
pub tick: Receiver<bool>,
|
||||
}
|
||||
|
||||
impl Clone for IntegrationRunner {
|
||||
fn clone(&self) -> Self {
|
||||
IntegrationRunner(Arc::clone(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntegrationRunner {
|
||||
pub async fn new(opts: SessionOptions, builder: Option<fn(&mut Greeter)>) -> IntegrationRunner {
|
||||
IntegrationRunner::new_with_size(opts, builder, (200, 40)).await
|
||||
}
|
||||
|
||||
pub async fn new_with_size(opts: SessionOptions, builder: Option<fn(&mut Greeter)>, size: (u16, u16)) -> IntegrationRunner {
|
||||
let socket = NamedTempFile::new().unwrap().into_temp_path().to_path_buf();
|
||||
|
||||
let (backend, buffer, tick) = TestBackend::new(size.0, size.1);
|
||||
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) -> Output {
|
||||
Output(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);
|
||||
}
|
||||
}
|
||||
}
|
26
src/integration/common/output.rs
Normal file
26
src/integration/common/output.rs
Normal file
@ -0,0 +1,26 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
pub(in crate::integration) struct Output(pub String);
|
||||
|
||||
impl Deref for Output {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Output {
|
||||
pub fn debug_print(&self) {
|
||||
for line in self.lines() {
|
||||
println!("{}", line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_inspect(&self) {
|
||||
for line in self.lines() {
|
||||
println!("{:?}", line.as_bytes().iter().map(|c| *c as char).collect::<Vec<char>>());
|
||||
}
|
||||
}
|
||||
}
|
114
src/integration/display.rs
Normal file
114
src/integration/display.rs
Normal file
@ -0,0 +1,114 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Local;
|
||||
use libgreetd_stub::SessionOptions;
|
||||
|
||||
use super::common::IntegrationRunner;
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_greet() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let mut runner = IntegrationRunner::new(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.greeting = Some("Lorem ipsum dolor sit amet".to_string());
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = tokio::task::spawn({
|
||||
let mut runner = runner.clone();
|
||||
|
||||
async move {
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Lorem ipsum dolor sit amet"));
|
||||
}
|
||||
});
|
||||
|
||||
runner.join_until_end(events).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_wrapped_greet() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let mut runner = IntegrationRunner::new_with_size(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.greeting = Some("Lorem \x1b[31mipsum dolor sit amet".to_string());
|
||||
}),
|
||||
(20, 20),
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = tokio::task::spawn({
|
||||
let mut runner = runner.clone();
|
||||
|
||||
async move {
|
||||
runner.wait_for_render().await;
|
||||
|
||||
let output = runner.output().await;
|
||||
|
||||
assert!(output.contains("┌ Authenticate into┐"));
|
||||
assert!(output.contains("│ Lorem ipsum │"));
|
||||
assert!(output.contains("│ dolor sit amet │"));
|
||||
assert!(output.contains("└──────────────────┘"));
|
||||
}
|
||||
});
|
||||
|
||||
runner.join_until_end(events).await;
|
||||
}
|
||||
|
||||
const TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
|
||||
|
||||
// TODO
|
||||
// This could create a race condition if we do not mock time, because we rely on
|
||||
// being at the same second between the test instantiation and the tasks
|
||||
// running, which is not guaranteed.
|
||||
#[tokio::test]
|
||||
async fn show_time() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let tref = Local::now().format(&TIME_FORMAT).to_string();
|
||||
|
||||
let mut runner = IntegrationRunner::new(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.time = true;
|
||||
greeter.time_format = Some(TIME_FORMAT.to_string());
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = tokio::task::spawn({
|
||||
let mut runner = runner.clone();
|
||||
|
||||
async move {
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains(&tref));
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert_eq!(runner.output().await.contains(&tref), false);
|
||||
}
|
||||
});
|
||||
|
||||
runner.join_until_end(events).await;
|
||||
}
|
26
src/integration/exit.rs
Normal file
26
src/integration/exit.rs
Normal file
@ -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".to_string(),
|
||||
password: "password".to_string(),
|
||||
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;
|
||||
}
|
233
src/integration/menus.rs
Normal file
233
src/integration/menus.rs
Normal file
@ -0,0 +1,233 @@
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use libgreetd_stub::SessionOptions;
|
||||
|
||||
use crate::{
|
||||
power::PowerOption,
|
||||
ui::{common::menu::Menu, power::Power, sessions::Session, users::User},
|
||||
};
|
||||
|
||||
use super::common::IntegrationRunner;
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_command() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
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".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let mut runner = IntegrationRunner::new(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.sessions = Menu::<Session> {
|
||||
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".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let mut runner = IntegrationRunner::new(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.powers = Menu::<Power> {
|
||||
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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_menu() {
|
||||
let opts = SessionOptions {
|
||||
username: "apognu".to_string(),
|
||||
password: "password".to_string(),
|
||||
mfa: false,
|
||||
};
|
||||
|
||||
let mut runner = IntegrationRunner::new(
|
||||
opts,
|
||||
Some(|greeter| {
|
||||
greeter.user_menu = true;
|
||||
greeter.users = Menu::<User> {
|
||||
title: "The users".to_string(),
|
||||
options: vec![
|
||||
User {
|
||||
username: "apognu".to_string(),
|
||||
name: Some("Antoine POPINEAU".to_string()),
|
||||
},
|
||||
User {
|
||||
username: "bob".to_string(),
|
||||
name: Some("Bob JOE".to_string()),
|
||||
},
|
||||
],
|
||||
selected: 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = tokio::task::spawn({
|
||||
let mut runner = runner.clone();
|
||||
|
||||
async move {
|
||||
runner.wait_until_buffer_contains("select a user").await;
|
||||
|
||||
runner.send_key(KeyCode::Enter).await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Antoine POPINEAU"));
|
||||
assert!(runner.output().await.contains("Bob JOE"));
|
||||
|
||||
runner.send_key(KeyCode::Down).await;
|
||||
runner.send_key(KeyCode::Enter).await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Username: Bob JOE"));
|
||||
assert!(runner.output().await.contains("Password:"));
|
||||
|
||||
runner.send_key(KeyCode::Esc).await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
runner.wait_until_buffer_contains("select a user").await;
|
||||
|
||||
runner.send_text("otheruser").await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Username: otheruser"));
|
||||
assert!(runner.output().await.contains("Password:"));
|
||||
|
||||
runner.send_key(KeyCode::Esc).await;
|
||||
runner.send_key(KeyCode::Enter).await;
|
||||
runner.send_key(KeyCode::Up).await;
|
||||
runner.send_key(KeyCode::Enter).await;
|
||||
runner.wait_for_render().await;
|
||||
|
||||
assert!(runner.output().await.contains("Username: Antoine POPINEAU"));
|
||||
assert!(runner.output().await.contains("Password:"));
|
||||
|
||||
runner.send_text("password").await;
|
||||
}
|
||||
});
|
||||
|
||||
runner.join_until_client_exit(events).await;
|
||||
}
|
8
src/integration/mod.rs
Normal file
8
src/integration/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
mod common;
|
||||
|
||||
mod auth;
|
||||
mod display;
|
||||
mod exit;
|
||||
mod menus;
|
||||
mod movement;
|
||||
mod remember;
|
49
src/integration/movement.rs
Normal file
49
src/integration/movement.rs
Normal file
@ -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".to_string(),
|
||||
password: "password".to_string(),
|
||||
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;
|
||||
}
|
43
src/integration/remember.rs
Normal file
43
src/integration/remember.rs
Normal file
@ -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".to_string(),
|
||||
password: "password".to_string(),
|
||||
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;
|
||||
}
|
@ -323,8 +323,6 @@ mod test {
|
||||
let mut greeter = Greeter::default();
|
||||
greeter.xsession_wrapper = Some("startx /usr/bin/env".into());
|
||||
|
||||
println!("{:?}", greeter.xsession_wrapper);
|
||||
|
||||
let session = Session {
|
||||
slug: Some("thede".to_string()),
|
||||
name: "Session1".into(),
|
||||
|
37
src/main.rs
37
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;
|
||||
@ -25,12 +28,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::<AuthStatus>() {
|
||||
return;
|
||||
}
|
||||
@ -39,23 +49,25 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn Error>> {
|
||||
let mut events = Events::new().await;
|
||||
let mut greeter = Greeter::new(events.sender()).await;
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
async fn run<B>(backend: B, mut greeter: Greeter, mut events: Events) -> Result<(), Box<dyn Error>>
|
||||
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();
|
||||
@ -126,7 +138,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();
|
||||
|
||||
@ -137,7 +151,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();
|
||||
|
||||
@ -145,6 +161,7 @@ fn register_panic_handler() {
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn clear_screen() {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
|
||||
|
@ -9,6 +9,7 @@ pub mod users;
|
||||
mod util;
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
error::Error,
|
||||
io::{self, Write},
|
||||
sync::Arc,
|
||||
@ -17,7 +18,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 +39,12 @@ const STATUSBAR_INDEX: usize = 3;
|
||||
const STATUSBAR_LEFT_INDEX: usize = 1;
|
||||
const STATUSBAR_RIGHT_INDEX: usize = 2;
|
||||
|
||||
pub(super) type Backend = CrosstermBackend<io::Stdout>;
|
||||
pub(super) type Term = Terminal<Backend>;
|
||||
pub(super) type Frame<'a> = CrosstermFrame<'a>;
|
||||
|
||||
pub async fn draw(greeter: Arc<RwLock<Greeter>>, terminal: &mut Term) -> Result<(), Box<dyn Error>> {
|
||||
pub async fn draw<B>(greeter: Arc<RwLock<Greeter>>, terminal: &mut Terminal<B>) -> Result<(), Box<dyn Error>>
|
||||
where
|
||||
B: tui::backend::Backend,
|
||||
{
|
||||
let mut greeter = greeter.write().await;
|
||||
let hide_cursor = should_hide_cursor(&greeter);
|
||||
|
||||
@ -64,7 +65,7 @@ pub async fn draw(greeter: Arc<RwLock<Greeter>>, terminal: &mut Term) -> Result<
|
||||
)
|
||||
.split(size);
|
||||
|
||||
if greeter.config().opt_present("time") {
|
||||
if greeter.time {
|
||||
let time_text = Span::from(get_time(&greeter));
|
||||
let time = Paragraph::new(time_text).alignment(Alignment::Center).style(theme.of(&[Themed::Time]));
|
||||
|
||||
@ -133,9 +134,9 @@ pub async fn draw(greeter: Arc<RwLock<Greeter>>, terminal: &mut Term) -> Result<
|
||||
}
|
||||
|
||||
fn get_time(greeter: &Greeter) -> String {
|
||||
let format = match greeter.config().opt_str("time-format") {
|
||||
Some(format) => format,
|
||||
None => fl!("date"),
|
||||
let format = match &greeter.time_format {
|
||||
Some(format) => Cow::Borrowed(format),
|
||||
None => Cow::Owned(fl!("date")),
|
||||
};
|
||||
|
||||
Local::now().format_localized(&format, greeter.locale).to_string()
|
||||
|
@ -3,14 +3,14 @@ use std::error::Error;
|
||||
use rand::{prelude::StdRng, Rng, SeedableRng};
|
||||
use tui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
text::{Span, Text},
|
||||
text::Span,
|
||||
widgets::{Block, BorderType, Borders, Paragraph},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
info::get_hostname,
|
||||
ui::{prompt_value, util::*, Frame},
|
||||
Greeter, Mode, SecretDisplay, GreetAlign
|
||||
GreetAlign, Greeter, Mode, SecretDisplay,
|
||||
};
|
||||
|
||||
use super::common::style::Themed;
|
||||
@ -30,7 +30,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
|
||||
let greeting_alignment = match greeter.greet_align() {
|
||||
GreetAlign::Center => Alignment::Center,
|
||||
GreetAlign::Left => Alignment::Left,
|
||||
GreetAlign::Right => Alignment::Right
|
||||
GreetAlign::Right => Alignment::Right,
|
||||
};
|
||||
|
||||
let container = Rect::new(x, y, width, height);
|
||||
@ -62,9 +62,8 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
|
||||
let chunks = Layout::default().direction(Direction::Vertical).constraints(constraints.as_ref()).split(frame);
|
||||
let cursor = chunks[USERNAME_INDEX];
|
||||
|
||||
if let Some(greeting) = &greeting {
|
||||
let greeting_text = greeting.trim_end();
|
||||
let greeting_label = Paragraph::new(greeting_text).alignment(greeting_alignment).style(theme.of(&[Themed::Greet]));
|
||||
if let Some(greeting) = greeting {
|
||||
let greeting_label = greeting.alignment(greeting_alignment).style(theme.of(&[Themed::Greet]));
|
||||
|
||||
f.render_widget(greeting_label, chunks[GREETING_INDEX]);
|
||||
}
|
||||
@ -137,8 +136,7 @@ pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box<dyn
|
||||
}
|
||||
|
||||
if let Some(message) = message {
|
||||
let message_text = Text::from(message);
|
||||
let message = Paragraph::new(message_text).alignment(Alignment::Center);
|
||||
let message = message.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(message, Rect::new(x, y + height, width, message_height));
|
||||
}
|
||||
|
@ -1,4 +1,9 @@
|
||||
use tui::prelude::Rect;
|
||||
use ansi_to_tui::IntoText;
|
||||
use tui::{
|
||||
prelude::Rect,
|
||||
text::Text,
|
||||
widgets::{Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::{Greeter, Mode};
|
||||
|
||||
@ -96,25 +101,31 @@ pub fn get_cursor_offset(greeter: &mut Greeter, length: usize) -> i16 {
|
||||
offset
|
||||
}
|
||||
|
||||
pub fn get_greeting_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option<String>, u16) {
|
||||
pub fn get_greeting_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option<Paragraph>, u16) {
|
||||
if let Some(greeting) = &greeter.greeting {
|
||||
let width = greeter.width();
|
||||
let wrapped = textwrap::fill(greeting, (width - (2 * padding)) as usize);
|
||||
let height = wrapped.trim_end().matches('\n').count();
|
||||
|
||||
(Some(wrapped), height as u16 + 2)
|
||||
let text = match greeting.clone().trim().into_text() {
|
||||
Ok(text) => text,
|
||||
Err(_) => Text::raw(greeting),
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(text.clone()).wrap(Wrap { trim: true });
|
||||
let height = paragraph.line_count(width - (2 * padding)) + 1;
|
||||
|
||||
(Some(paragraph), height as u16)
|
||||
} else {
|
||||
(None, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_message_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option<String>, u16) {
|
||||
pub fn get_message_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option<Paragraph>, u16) {
|
||||
if let Some(message) = &greeter.message {
|
||||
let width = greeter.width();
|
||||
let wrapped = textwrap::fill(message.trim_end(), width as usize - 4);
|
||||
let height = wrapped.trim_end().matches('\n').count();
|
||||
let paragraph = Paragraph::new(message.trim_end()).wrap(Wrap { trim: true });
|
||||
let height = paragraph.line_count(width - 4);
|
||||
|
||||
(Some(wrapped), height as u16 + padding)
|
||||
(Some(paragraph), height as u16 + padding)
|
||||
} else {
|
||||
(None, fallback)
|
||||
}
|
||||
@ -122,7 +133,12 @@ pub fn get_message_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Op
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use tui::prelude::Rect;
|
||||
use tui::{
|
||||
prelude::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ui::util::{get_greeting_height, get_height},
|
||||
@ -243,24 +259,58 @@ mod test {
|
||||
#[test]
|
||||
fn greeting_height_one_line() {
|
||||
let mut greeter = Greeter::default();
|
||||
greeter.config = Greeter::options().parse(&["--width", "10", "--container-padding", "1"]).ok();
|
||||
greeter.greeting = Some("Hello".into());
|
||||
greeter.config = Greeter::options().parse(&["--width", "15", "--container-padding", "1"]).ok();
|
||||
greeter.greeting = Some("Hello World".into());
|
||||
|
||||
let (text, width) = get_greeting_height(&greeter, 1, 0);
|
||||
let (_, height) = get_greeting_height(&greeter, 1, 0);
|
||||
|
||||
assert!(matches!(text.as_deref(), Some("Hello")));
|
||||
assert_eq!(width, 2);
|
||||
assert_eq!(height, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greeting_height_two_lines() {
|
||||
let mut greeter = Greeter::default();
|
||||
greeter.config = Greeter::options().parse(&["--width", "10", "--container-padding", "1"]).ok();
|
||||
greeter.config = Greeter::options().parse(&["--width", "8", "--container-padding", "1"]).ok();
|
||||
greeter.greeting = Some("Hello World".into());
|
||||
|
||||
let (text, width) = get_greeting_height(&greeter, 1, 0);
|
||||
let (_, height) = get_greeting_height(&greeter, 1, 0);
|
||||
|
||||
assert!(matches!(text.as_deref(), Some("Hello\nWorld")));
|
||||
assert_eq!(width, 3);
|
||||
assert_eq!(height, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_greeting_height_one_line() {
|
||||
let mut greeter = Greeter::default();
|
||||
greeter.config = Greeter::options().parse(&["--width", "15", "--container-padding", "1"]).ok();
|
||||
greeter.greeting = Some("\x1b[31mHello\x1b[0m World".into());
|
||||
|
||||
let (text, height) = get_greeting_height(&greeter, 1, 0);
|
||||
|
||||
let expected = Paragraph::new(Text::from(vec![Line::from(vec![
|
||||
Span::styled("Hello", Style::default().fg(Color::Red)),
|
||||
Span::styled(" World", Style::reset()),
|
||||
])]))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
assert_eq!(text, Some(expected));
|
||||
assert_eq!(height, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_greeting_height_two_lines() {
|
||||
let mut greeter = Greeter::default();
|
||||
greeter.config = Greeter::options().parse(&["--width", "8", "--container-padding", "1"]).ok();
|
||||
greeter.greeting = Some("\x1b[31mHello\x1b[0m World".into());
|
||||
|
||||
let (text, height) = get_greeting_height(&greeter, 1, 0);
|
||||
|
||||
let expected = Paragraph::new(Text::from(vec![Line::from(vec![
|
||||
Span::styled("Hello", Style::default().fg(Color::Red)),
|
||||
Span::styled(" World", Style::reset()),
|
||||
])]))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
assert_eq!(text, Some(expected));
|
||||
assert_eq!(height, 3);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user