use std::{ffi::OsStr, path::PathBuf}; use anyhow::{ensure, Context, Result}; use dialoguer::theme::{ColorfulTheme, Theme}; use directories::ProjectDirs; use nostr::{self, prelude::FromSkStr}; use once_cell::sync::Lazy; use rexpect::session::{Options, PtySession}; use strip_ansi_escapes::strip_str; pub mod git; pub static TEST_KEY_1_NSEC: &str = "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; pub static TEST_KEY_1_SK_HEX: &str = "08608a436aee4c07ea5c36f85cb17c58f52b3ad7094f9318cc777771f0bf218b"; pub static TEST_KEY_1_NPUB: &str = "npub175lyhnt6nn00qjw0v3navw9pxgv43txnku0tpxprl4h6mvpr6a5qlphudg"; pub static TEST_KEY_1_DISPLAY_NAME: &str = "bob"; pub static TEST_KEY_1_ENCRYPTED: &str = "ncryptsec1qyq607h3cykxc3f2a44u89cdk336fptccn3fm5pf3nmf93d3c86qpunc7r6klwcn6lyszjy72wxwqq9aljg4pm6atvjrds9e248yhv76xfnt464265kgnjsvg8rlg06wg4sp9uljzfpu8zuaztcvfn2j8ggdrg8mldh850cy75efsyqqansert9wqmn4e6khpgvfz7h5le9"; pub static TEST_KEY_1_ENCRYPTED_WEAK: &str = "ncryptsec1qy8ke0tjqnn8wt3w6lnc86c27ry3qrptxctjfcgruryxy0at238kwyjwsswd7z88thysruzw3awlrsxjvw5uptcd7vt70ft9rtkx00m8cgy3khm4hxa5d2gfnc6athnfruy2eyl6pkas8k34jg85z7xjqqadzfzh9rp0fzxqtw0tvxksac3n8yc98uksvuf93e0lcvqy8j6"; pub static TEST_KEY_1_KEYS: Lazy = Lazy::new(|| nostr::Keys::from_sk_str(TEST_KEY_1_NSEC).unwrap()); pub static TEST_KEY_2_NSEC: &str = "nsec1ypglg6nj6ep0g2qmyfqcv2al502gje3jvpwye6mthmkvj93tqkesknv6qm"; pub static TEST_KEY_2_NPUB: &str = "npub1h2yz2eh0798nh25hvypenrz995nla9dktfuk565ljf3ghnkhdljsul834e"; pub static TEST_KEY_2_DISPLAY_NAME: &str = "carole"; pub static TEST_KEY_2_ENCRYPTED: &str = "...2"; pub static TEST_KEY_2_KEYS: Lazy = Lazy::new(|| nostr::Keys::from_sk_str(TEST_KEY_2_NSEC).unwrap()); pub static TEST_INVALID_NSEC: &str = "nsec1ppsg5sm2aex"; pub static TEST_PASSWORD: &str = "769dfd£pwega8SHGv3!#Bsfd5t"; pub static TEST_INVALID_PASSWORD: &str = "INVALID769dfd£pwega8SHGv3!"; pub static TEST_WEAK_PASSWORD: &str = "fhaiuhfwe"; pub static TEST_RANDOM_TOKEN: &str = "lkjh2398HLKJ43hrweiJ6FaPfdssgtrg"; /// 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 fn expect_password(&mut self, prompt: &str) -> Result { let mut i = CliTesterPasswordPrompt { tester: self, prompt: prompt.to_string(), confirmation_prompt: "".to_string(), }; i.prompt().context("initial password prompt")?; Ok(i) } pub fn expect_confirm( &mut self, prompt: &str, default: Option, ) -> Result { let mut i = CliTesterConfirmPrompt { tester: self, prompt: prompt.to_string(), default, }; i.prompt(false, default).context("initial confirm prompt")?; Ok(i) } pub fn expect_confirm_eventually( &mut self, prompt: &str, default: Option, ) -> Result { let mut i = CliTesterConfirmPrompt { tester: self, prompt: prompt.to_string(), default, }; i.prompt(true, default).context("initial confirm 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) } } pub struct CliTesterPasswordPrompt<'a> { tester: &'a mut CliTester, prompt: String, confirmation_prompt: String, } impl CliTesterPasswordPrompt<'_> { fn prompt(&mut self) -> Result<&mut Self> { let p = match self.confirmation_prompt.is_empty() { true => self.prompt.as_str(), false => self.confirmation_prompt.as_str(), }; let mut s = String::new(); self.tester .formatter .format_password_prompt(&mut s, p) .expect("diagluer theme formatter should succeed"); ensure!(s.contains(p), "dialoguer must be broken"); self.tester .expect(format!("\r{}", sanatize(s)).as_str()) .context("expect password input prompt")?; Ok(self) } pub fn with_confirmation(&mut self, prompt: &str) -> Result<&mut Self> { self.confirmation_prompt = prompt.to_string(); Ok(self) } pub fn succeeds_with(&mut self, password: &str) -> Result<&mut Self> { self.tester.send_line(password)?; self.tester .expect("\r\n") .context("expect new lines after password input")?; if !self.confirmation_prompt.is_empty() { self.prompt() .context("expect password confirmation prompt")?; self.tester.send_line(password)?; self.tester .expect("\r\n\r") .context("expect new lines after password confirmation input")?; } let mut s = String::new(); self.tester .formatter .format_password_prompt_selection(&mut s, self.prompt.as_str()) .expect("diagluer theme formatter should succeed"); ensure!(s.contains(self.prompt.as_str()), "dialoguer must be broken"); self.tester .expect(format!("\r{}\r\n", sanatize(s)).as_str()) .context("expect password prompt success")?; Ok(self) } } pub struct CliTesterConfirmPrompt<'a> { tester: &'a mut CliTester, prompt: String, default: Option, } impl CliTesterConfirmPrompt<'_> { fn prompt(&mut self, eventually: bool, default: Option) -> Result<&mut Self> { let mut s = String::new(); self.tester .formatter .format_confirm_prompt(&mut s, self.prompt.as_str(), default) .expect("diagluer theme formatter should succeed"); 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 confirm prompt")?; } Ok(self) } pub fn succeeds_with(&mut self, input: Option) -> Result<&mut Self> { self.tester.send_line(match input { None => "", Some(true) => "y", Some(false) => "n", })?; self.tester .expect("\r") .context("expect new line after confirm input to be printed")?; let mut s = String::new(); self.tester .formatter .format_confirm_prompt_selection( &mut s, self.prompt.as_str(), match input { None => self.default, Some(_) => 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, 2000).expect("rexpect to spawn new process"), formatter: ColorfulTheme::default(), } } pub fn new_from_dir(dir: &PathBuf, args: I) -> Self where I: IntoIterator, S: AsRef, { Self { rexpect_session: rexpect_with_from_dir(dir, args, 2000) .expect("rexpect to spawn new process"), formatter: ColorfulTheme::default(), } } pub fn new_with_timeout(timeout_ms: u64, args: I) -> Self where I: IntoIterator, S: AsRef, { Self { rexpect_session: rexpect_with(args, timeout_ms).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, 2000).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, timeout_ms: u64) -> 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(timeout_ms), strip_ansi_escape_codes: true, }, ) } pub fn rexpect_with_from_dir( dir: &PathBuf, args: I, timeout_ms: u64, ) -> Result where I: IntoIterator, S: AsRef, { let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("ngit")); cmd.current_dir(dir); 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(timeout_ms), 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") }