From 6423baebd92e45c9be85157c443dff42e65d8d14 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Sep 2023 00:00:00 +0000 Subject: refactor: rebuild app skeleton Create skeleton for a complete rebuild of the prototype as a production ready product. Includes design patterns for: - dependency injection - unit testing with dependency mocking - integration testing - error handling - config storage BREAKING-CHANGE: ground-up redesign with incompatible protocol standards --- test_utils/Cargo.toml | 12 +++ test_utils/src/lib.rs | 280 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 test_utils/Cargo.toml create mode 100644 test_utils/src/lib.rs (limited to 'test_utils') diff --git a/test_utils/Cargo.toml b/test_utils/Cargo.toml new file mode 100644 index 0000000..e1f6090 --- /dev/null +++ b/test_utils/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test_utils" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.75" +assert_cmd = "2.0.12" +dialoguer = "0.10.4" +directories = "5.0.1" +rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" } +strip-ansi-escapes = "0.2.0" diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs new file mode 100644 index 0000000..495e8d2 --- /dev/null +++ b/test_utils/src/lib.rs @@ -0,0 +1,280 @@ +use std::ffi::OsStr; + +use anyhow::{ensure, Context, Result}; +use dialoguer::theme::{ColorfulTheme, Theme}; +use directories::ProjectDirs; +use rexpect::session::{Options, PtySession}; +use strip_ansi_escapes::strip_str; + +pub static TEST_KEY_1_NSEC: &str = + "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; + +pub static TEST_KEY_2_NSEC: &str = + "nsec1ypglg6nj6ep0g2qmyfqcv2al502gje3jvpwye6mthmkvj93tqkesknv6qm"; + +/// wrapper for a cli testing tool - currently wraps rexpect and dialoguer +/// +/// 1. allow more accurate articulation of expected behaviour +/// 2. provide flexibility to swap rexpect for a tool that better maps to +/// expected behaviour +/// 3. provides flexability to swap dialoguer with another cli interaction tool +pub struct CliTester { + rexpect_session: PtySession, + formatter: ColorfulTheme, +} + +impl CliTester { + pub fn expect_input(&mut self, prompt: &str) -> Result { + let mut i = CliTesterInputPrompt { + tester: self, + prompt: prompt.to_string(), + }; + i.prompt(false).context("initial input prompt")?; + Ok(i) + } + + pub fn expect_input_eventually(&mut self, prompt: &str) -> Result { + let mut i = CliTesterInputPrompt { + tester: self, + prompt: prompt.to_string(), + }; + i.prompt(true).context("initial input prompt")?; + Ok(i) + } +} + +pub struct CliTesterInputPrompt<'a> { + tester: &'a mut CliTester, + prompt: String, +} + +impl CliTesterInputPrompt<'_> { + fn prompt(&mut self, eventually: bool) -> Result<&mut Self> { + let mut s = String::new(); + self.tester + .formatter + .format_prompt(&mut s, self.prompt.as_str()) + .expect("diagluer theme formatter should succeed"); + s.push(' '); + + ensure!( + s.contains(self.prompt.as_str()), + "dialoguer must be broken as formatted prompt success doesnt contain prompt" + ); + + if eventually { + self.tester + .expect_eventually(sanatize(s).as_str()) + .context("expect input prompt eventually")?; + } else { + self.tester + .expect(sanatize(s).as_str()) + .context("expect input prompt")?; + } + + Ok(self) + } + + pub fn succeeds_with(&mut self, input: &str) -> Result<&mut Self> { + self.tester.send_line(input)?; + self.tester + .expect(input) + .context("expect input to be printed")?; + self.tester + .expect("\r") + .context("expect new line after input to be printed")?; + + let mut s = String::new(); + self.tester + .formatter + .format_input_prompt_selection(&mut s, self.prompt.as_str(), input) + .expect("diagluer theme formatter should succeed"); + if !s.contains(self.prompt.as_str()) { + panic!("dialoguer must be broken as formatted prompt success doesnt contain prompt"); + } + let formatted_success = format!("{}\r\n", sanatize(s)); + + self.tester + .expect(formatted_success.as_str()) + .context("expect immediate prompt success")?; + Ok(self) + } +} + +impl CliTester { + pub fn new(args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + rexpect_session: rexpect_with(args).expect("rexpect to spawn new process"), + formatter: ColorfulTheme::default(), + } + } + + pub fn restart_with(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + self.rexpect_session + .process + .exit() + .expect("process to exit"); + self.rexpect_session = rexpect_with(args).expect("rexpect to spawn new process"); + self + } + + pub fn exit(&mut self) -> Result<()> { + match self + .rexpect_session + .process + .exit() + .context("expect proccess to exit") + { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + } + + /// returns what came before expected message + pub fn expect_eventually(&mut self, message: &str) -> Result { + let before = self + .rexpect_session + .exp_string(message) + .context("exp_string failed")?; + Ok(before) + } + + pub fn expect(&mut self, message: &str) -> Result<&mut Self> { + let before = self.expect_eventually(message)?; + ensure!( + before.is_empty(), + format!( + "expected message \"{}\". but got \"{}\" first.", + message.replace('\n', "\\n").replace('\r', "\\r"), + before.replace('\n', "\\n").replace('\r', "\\r"), + ), + ); + Ok(self) + } + + pub fn expect_end(&mut self) -> Result<()> { + let before = self + .rexpect_session + .exp_eof() + .context("expected immediate end but got timed out")?; + ensure!( + before.is_empty(), + format!( + "expected immediate end but got '{}' first.", + before.replace('\n', "\\n").replace('\r', "\\r"), + ), + ); + Ok(()) + } + + pub fn expect_end_with(&mut self, message: &str) -> Result<()> { + let before = self + .rexpect_session + .exp_eof() + .context("expected immediate end but got timed out")?; + assert_eq!(before, message); + Ok(()) + } + pub fn expect_end_eventually(&mut self) -> Result { + self.rexpect_session + .exp_eof() + .context("expected end eventually but got timed out") + } + + pub fn expect_end_eventually_with(&mut self, message: &str) -> Result<()> { + self.expect_eventually(message)?; + self.expect_end() + } + + fn send_line(&mut self, line: &str) -> Result<()> { + self.rexpect_session + .send_line(line) + .context("send_line failed")?; + Ok(()) + } +} + +/// sanatize unicode string for rexpect +fn sanatize(s: String) -> String { + // remove ansi codes as they don't work with rexpect + strip_str(s) + // sanatize unicode rexpect issue 105 is resolved https://github.com/rust-cli/rexpect/issues/105 + .as_bytes() + .iter() + .map(|c| *c as char) + .collect::() +} + +pub fn rexpect_with(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("ngit")); + cmd.args(args); + // using branch for PR https://github.com/rust-cli/rexpect/pull/103 to strip ansi escape codes + rexpect::session::spawn_with_options( + cmd, + Options { + timeout_ms: Some(2000), + strip_ansi_escape_codes: true, + }, + ) +} + +/// backup and remove application config and data +pub fn before() -> Result<()> { + backup_existing_config() +} + +/// restore backuped application config and data +pub fn after() -> Result<()> { + restore_config_backup() +} + +/// run func between before and after scripts which backup, reset and restore +/// application config +/// +/// TODO: fix issue: if func panics, after() is not run. +pub fn with_fresh_config(func: F) -> Result<()> +where + F: Fn() -> Result<()>, +{ + before()?; + func()?; + after() +} + +fn backup_existing_config() -> Result<()> { + let config_path = get_dirs().config_dir().join("config.json"); + let backup_config_path = get_dirs().config_dir().join("config-backup.json"); + if config_path.exists() { + std::fs::rename(config_path, backup_config_path)?; + } + Ok(()) +} + +fn restore_config_backup() -> Result<()> { + let config_path = get_dirs().config_dir().join("config.json"); + let backup_config_path = get_dirs().config_dir().join("config-backup.json"); + if config_path.exists() { + std::fs::remove_file(&config_path)?; + } + if backup_config_path.exists() { + std::fs::rename(backup_config_path, config_path)?; + } + Ok(()) +} + +fn get_dirs() -> ProjectDirs { + ProjectDirs::from("", "CodeCollaboration", "ngit") + .expect("rust directories crate should return ProjectDirs") +} -- cgit v1.2.3