From 4975a4eb2007bbe2f89e93f959892b8afa539d6a Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Fri, 13 Sep 2024 22:39:10 -0700 Subject: [PATCH 1/8] start implementation for which and uname --- Cargo.lock | 63 +++++++++++++++++++ crates/shell/Cargo.toml | 2 + .../src/{commands.rs => commands/mod.rs} | 7 +++ crates/shell/src/commands/uname.rs | 50 +++++++++++++++ crates/shell/src/commands/which.rs | 30 +++++++++ crates/shell/src/main.rs | 8 +++ 6 files changed, 160 insertions(+) rename crates/shell/src/{commands.rs => commands/mod.rs} (97%) create mode 100644 crates/shell/src/commands/uname.rs create mode 100644 crates/shell/src/commands/which.rs diff --git a/Cargo.lock b/Cargo.lock index 84f3629..1404326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "platform-info" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5ff316b9c4642feda973c18f0decd6c8b0919d4722566f6e4337cce0dd88217" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -1091,6 +1101,8 @@ dependencies = [ "rustyline", "tokio", "uu_ls", + "uu_uname", + "which", ] [[package]] @@ -1322,6 +1334,17 @@ dependencies = [ "uutils_term_grid", ] +[[package]] +name = "uu_uname" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1ca90f9b292bccaad0de70e6feccac5182c6713a5e1ca72d97bf3555b608b4" +dependencies = [ + "clap", + "platform-info", + "uucore", +] + [[package]] name = "uucore" version = "0.0.27" @@ -1437,6 +1460,18 @@ version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "wild" version = "2.2.1" @@ -1446,6 +1481,22 @@ dependencies = [ "glob", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -1455,6 +1506,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.52.0" @@ -1622,6 +1679,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "xattr" version = "1.3.1" diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index a48bbe5..61413a7 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -27,6 +27,8 @@ rustyline = { version = "14.0.0", features = ["derive"] } tokio = "1.40.0" uu_ls = "0.0.27" dirs = "5.0.1" +which = "6.0.3" +uu_uname = "0.0.27" [package.metadata.release] # Dont publish the binary diff --git a/crates/shell/src/commands.rs b/crates/shell/src/commands/mod.rs similarity index 97% rename from crates/shell/src/commands.rs rename to crates/shell/src/commands/mod.rs index 5304be2..5918a6e 100644 --- a/crates/shell/src/commands.rs +++ b/crates/shell/src/commands/mod.rs @@ -6,6 +6,13 @@ use futures::{future::LocalBoxFuture, FutureExt}; use uu_ls::uumain as uu_ls; use crate::execute; + +pub mod uname; +pub mod which; + +pub use uname::UnameCommand; +pub use which::WhichCommand; + pub struct LsCommand; pub struct AliasCommand; diff --git a/crates/shell/src/commands/uname.rs b/crates/shell/src/commands/uname.rs new file mode 100644 index 0000000..9a7923f --- /dev/null +++ b/crates/shell/src/commands/uname.rs @@ -0,0 +1,50 @@ +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::future::LocalBoxFuture; +use uu_uname::{options, UNameOutput}; +pub struct UnameCommand; + +fn display(uname: &UNameOutput) -> String { + let mut output = String::new(); + for name in [ + uname.kernel_name.as_ref(), + uname.nodename.as_ref(), + uname.kernel_release.as_ref(), + uname.kernel_version.as_ref(), + uname.machine.as_ref(), + uname.os.as_ref(), + uname.processor.as_ref(), + uname.hardware_platform.as_ref(), + ] + .into_iter() + .flatten() + { + output.push_str(name); + output.push(' '); + } + output +} + +impl ShellCommand for UnameCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + let matches = uu_uname::uu_app() + .no_binary_name(true) + .try_get_matches_from(context.args).unwrap(); + + let options = uu_uname::Options { + all: matches.get_flag(options::ALL), + kernel_name: matches.get_flag(options::KERNEL_NAME), + nodename: matches.get_flag(options::NODENAME), + kernel_release: matches.get_flag(options::KERNEL_RELEASE), + kernel_version: matches.get_flag(options::KERNEL_VERSION), + machine: matches.get_flag(options::MACHINE), + processor: matches.get_flag(options::PROCESSOR), + hardware_platform: matches.get_flag(options::HARDWARE_PLATFORM), + os: matches.get_flag(options::OS), + }; + + let uname = UNameOutput::new(&options).unwrap(); + context.stdout.write_line(&format!("{}", display(&uname).trim_end())); + + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + } +} \ No newline at end of file diff --git a/crates/shell/src/commands/which.rs b/crates/shell/src/commands/which.rs new file mode 100644 index 0000000..c2beeac --- /dev/null +++ b/crates/shell/src/commands/which.rs @@ -0,0 +1,30 @@ +use std::os::macos::raw::stat; + +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::future::LocalBoxFuture; + +pub struct WhichCommand; + +impl ShellCommand for WhichCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + if context.args.len() != 1 { + context.stderr.write_line("Expected one argument.").unwrap(); + } + let arg = &context.args[0]; + + if let Some(alias) = context.state.alias_map().get(arg) { + context.stdout.write_line(&format!("alias: \"{}\"", alias.join(" "))).unwrap(); + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + } + + if let Some(function) = context.state.resolve_custom_command(&arg) { + context.stdout.write_line("").unwrap(); + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + } + + if let Ok(p) = which::which(arg) { + context.stdout.write_line(&p.to_string_lossy()).unwrap(); + } + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + } +} diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index 8bcebbf..6b5af5f 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -34,6 +34,14 @@ fn commands() -> HashMap> { "source".to_string(), Rc::new(commands::SourceCommand) as Rc, ), + ( + "which".to_string(), + Rc::new(commands::WhichCommand) as Rc, + ), + ( + "uname".to_string(), + Rc::new(commands::UnameCommand) as Rc, + ), ]) } From 431368a4e2d0810890129542ba318b49e4480389 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Fri, 13 Sep 2024 22:41:46 -0700 Subject: [PATCH 2/8] fix --- crates/shell/src/commands/uname.rs | 12 ++++++++---- crates/shell/src/commands/which.rs | 12 +++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/shell/src/commands/uname.rs b/crates/shell/src/commands/uname.rs index 9a7923f..0d956ba 100644 --- a/crates/shell/src/commands/uname.rs +++ b/crates/shell/src/commands/uname.rs @@ -28,7 +28,8 @@ impl ShellCommand for UnameCommand { fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { let matches = uu_uname::uu_app() .no_binary_name(true) - .try_get_matches_from(context.args).unwrap(); + .try_get_matches_from(context.args) + .unwrap(); let options = uu_uname::Options { all: matches.get_flag(options::ALL), @@ -43,8 +44,11 @@ impl ShellCommand for UnameCommand { }; let uname = UNameOutput::new(&options).unwrap(); - context.stdout.write_line(&format!("{}", display(&uname).trim_end())); + context + .stdout + .write_line(display(&uname).trim_end()) + .unwrap(); - return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))) } -} \ No newline at end of file +} diff --git a/crates/shell/src/commands/which.rs b/crates/shell/src/commands/which.rs index c2beeac..450c268 100644 --- a/crates/shell/src/commands/which.rs +++ b/crates/shell/src/commands/which.rs @@ -1,5 +1,3 @@ -use std::os::macos::raw::stat; - use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; use futures::future::LocalBoxFuture; @@ -13,11 +11,14 @@ impl ShellCommand for WhichCommand { let arg = &context.args[0]; if let Some(alias) = context.state.alias_map().get(arg) { - context.stdout.write_line(&format!("alias: \"{}\"", alias.join(" "))).unwrap(); + context + .stdout + .write_line(&format!("alias: \"{}\"", alias.join(" "))) + .unwrap(); return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); } - if let Some(function) = context.state.resolve_custom_command(&arg) { + if context.state.resolve_custom_command(arg).is_some() { context.stdout.write_line("").unwrap(); return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); } @@ -25,6 +26,7 @@ impl ShellCommand for WhichCommand { if let Ok(p) = which::which(arg) { context.stdout.write_line(&p.to_string_lossy()).unwrap(); } - return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); + + Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))) } } From 94cb14d35137af1190065fbe911d3e5b773652d0 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Sat, 14 Sep 2024 07:06:36 -0700 Subject: [PATCH 3/8] improve which implementation --- crates/shell/src/commands/which.rs | 56 +++++++++++++++++++----------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/crates/shell/src/commands/which.rs b/crates/shell/src/commands/which.rs index 450c268..ac796ad 100644 --- a/crates/shell/src/commands/which.rs +++ b/crates/shell/src/commands/which.rs @@ -4,29 +4,45 @@ use futures::future::LocalBoxFuture; pub struct WhichCommand; impl ShellCommand for WhichCommand { - fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { - if context.args.len() != 1 { - context.stderr.write_line("Expected one argument.").unwrap(); - } - let arg = &context.args[0]; - - if let Some(alias) = context.state.alias_map().get(arg) { - context - .stdout - .write_line(&format!("alias: \"{}\"", alias.join(" "))) - .unwrap(); - return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); - } + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(futures::future::ready(execute_which(context))) + } +} - if context.state.resolve_custom_command(arg).is_some() { - context.stdout.write_line("").unwrap(); - return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))); - } +fn execute_which(mut context: ShellCommandContext) -> ExecuteResult { + if context.args.len() != 1 { + context.stderr.write_line("Expected one argument.").unwrap(); + return ExecuteResult::from_exit_code(1); + } + let arg = &context.args[0]; - if let Ok(p) = which::which(arg) { + if let Some(alias) = context.state.alias_map().get(arg) { + context + .stdout + .write_line(&format!("alias: \"{}\"", alias.join(" "))) + .unwrap(); + return ExecuteResult::from_exit_code(0); + } + + if context.state.resolve_custom_command(arg).is_some() { + context.stdout.write_line("").unwrap(); + return ExecuteResult::from_exit_code(0); + } + + if let Some(path) = context.state.env_vars().get("PATH") { + let path = std::ffi::OsString::from(path); + let which_result = which::which_in_global(arg, Some(path)) + .and_then(|mut i| i.next().ok_or(which::Error::CannotFindBinaryPath)); + + if let Ok(p) = which_result { context.stdout.write_line(&p.to_string_lossy()).unwrap(); + return ExecuteResult::from_exit_code(0); } - - Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))) } + + context + .stderr + .write_line(&format!("{} not found", arg)) + .unwrap(); + ExecuteResult::from_exit_code(1) } From 77f2bb8b6e7c8b8e5097631fce2f021132a68cc8 Mon Sep 17 00:00:00 2001 From: prsabahrami Date: Mon, 16 Sep 2024 16:59:49 -0400 Subject: [PATCH 4/8] Added test framework --- Cargo.lock | 31 +- Cargo.toml | 2 +- crates/deno_task_shell/Cargo.toml | 4 +- crates/deno_task_shell/src/shell/mod.rs | 8 +- crates/deno_task_shell/src/shell/test.rs | 793 ------------------ .../deno_task_shell/src/shell/test_builder.rs | 284 ------- crates/shell/Cargo.toml | 10 +- crates/shell/src/commands/mod.rs | 28 +- crates/shell/src/lib.rs | 2 + crates/shell/src/main.rs | 36 +- crates/tests/Cargo.toml | 13 + crates/tests/src/lib.rs | 781 +++++++++++++++++ crates/tests/src/test_builder.rs | 288 +++++++ 13 files changed, 1155 insertions(+), 1125 deletions(-) delete mode 100644 crates/deno_task_shell/src/shell/test.rs delete mode 100644 crates/deno_task_shell/src/shell/test_builder.rs create mode 100644 crates/shell/src/lib.rs create mode 100644 crates/tests/Cargo.toml create mode 100644 crates/tests/src/lib.rs create mode 100644 crates/tests/src/test_builder.rs diff --git a/Cargo.lock b/Cargo.lock index 1404326..c676da3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.87" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f00e1f6e58a40e807377c75c6a7f97bf9044fab57816f2414e6f5f4499d7b8" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "ascii_tree" @@ -1098,7 +1098,9 @@ dependencies = [ "deno_task_shell", "dirs", "futures", + "pretty_assertions", "rustyline", + "tempfile", "tokio", "uu_ls", "uu_uname", @@ -1141,6 +1143,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1202,6 +1214,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tests" +version = "0.1.0" +dependencies = [ + "anyhow", + "deno_task_shell", + "futures", + "pretty_assertions", + "shell", + "tempfile", + "tokio", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -1243,8 +1268,10 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.52.0", ] diff --git a/Cargo.toml b/Cargo.toml index 42367f8..e2047f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ homepage = "https://github.com/prefix-dev/shell" repository = "https://github.com/prefix-dev/shell" license = "BSD-3-Clause" edition = "2021" -readme = "README.md" +readme = "README.md" \ No newline at end of file diff --git a/crates/deno_task_shell/Cargo.toml b/crates/deno_task_shell/Cargo.toml index 5de0656..5fbe1e2 100644 --- a/crates/deno_task_shell/Cargo.toml +++ b/crates/deno_task_shell/Cargo.toml @@ -29,9 +29,9 @@ pest_derive = "2.7.12" dirs = "5.0.1" pest_ascii_tree = { git = "https://github.com/prsabahrami/pest_ascii_tree.git", branch = "master" } miette = "7.2.0" +tempfile = "3.12.0" +pretty_assertions = "1.0.0" [dev-dependencies] parking_lot = "0.12.3" -pretty_assertions = "1" serde_json = "1.0.128" -tempfile = "3.12.0" diff --git a/crates/deno_task_shell/src/shell/mod.rs b/crates/deno_task_shell/src/shell/mod.rs index dac1dac..df180b3 100644 --- a/crates/deno_task_shell/src/shell/mod.rs +++ b/crates/deno_task_shell/src/shell/mod.rs @@ -17,13 +17,9 @@ pub use types::ShellPipeReader; pub use types::ShellPipeWriter; pub use types::ShellState; +pub mod fs_util; + mod command; mod commands; mod execute; -mod fs_util; mod types; - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_builder; diff --git a/crates/deno_task_shell/src/shell/test.rs b/crates/deno_task_shell/src/shell/test.rs deleted file mode 100644 index 1e639ed..0000000 --- a/crates/deno_task_shell/src/shell/test.rs +++ /dev/null @@ -1,793 +0,0 @@ -// Copyright 2018-2024 the Deno authors. MIT license. - -use futures::FutureExt; - -use super::test_builder::TestBuilder; -use super::types::ExecuteResult; - -const FOLDER_SEPARATOR: char = if cfg!(windows) { '\\' } else { '/' }; - -#[tokio::test] -async fn commands() { - TestBuilder::new() - .command("echo 1") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 2 3") - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo "1 2 3""#) - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r"echo 1 2\ \ \ 3") - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo "1 2\ \ \ 3""#) - .assert_stdout("1 2\\ \\ \\ 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo test$(echo "1 2")"#) - .assert_stdout("test1 2\n") - .run() - .await; - - TestBuilder::new() - .command(r#"TEST="1 2" ; echo $TEST"#) - .assert_stdout("1 2\n") - .run() - .await; - - TestBuilder::new() - .command(r#""echo" "1""#) - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command(r#""echo" "*""#) - .assert_stdout("*\n") - .run() - .await; - - TestBuilder::new() - .command("echo test-dashes") - .assert_stdout("test-dashes\n") - .run() - .await; - - TestBuilder::new() - .command("echo 'a/b'/c") - .assert_stdout("a/b/c\n") - .run() - .await; - - TestBuilder::new() - .command("echo 'a/b'ctest\"te st\"'asdf'") - .assert_stdout("a/bctestte stasdf\n") - .run() - .await; - - TestBuilder::new() - .command("echo --test=\"2\" --test='2' test\"TEST\" TEST'test'TEST 'test''test' test'test'\"test\" \"test\"\"test\"'test'") - .assert_stdout("--test=2 --test=2 testTEST TESTtestTEST testtest testtesttest testtesttest\n") - .run() - .await; - - TestBuilder::new() - .command("deno eval 'console.log(1)'") - .env_var("PATH", "") - .assert_stderr("deno: command not found\n") - .assert_exit_code(127) - .run() - .await; - - TestBuilder::new().command("unset").run().await; -} - -#[tokio::test] -async fn boolean_logic() { - TestBuilder::new() - .command("echo 1 && echo 2 || echo 3") - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 || echo 2 && echo 3") - .assert_stdout("1\n3\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 || (echo 2 && echo 3)") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("false || false || (echo 2 && false) || echo 3") - .assert_stdout("2\n3\n") - .run() - .await; -} - -#[tokio::test] -async fn exit() { - TestBuilder::new() - .command("exit 1") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit 5") - .assert_exit_code(5) - .run() - .await; - - TestBuilder::new() - .command("exit 258 && echo 1") - .assert_exit_code(2) - .run() - .await; - - TestBuilder::new() - .command("(exit 0) && echo 1") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("(exit 1) && echo 1") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("echo 1 && (exit 1)") - .assert_stdout("1\n") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit ; echo 2") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit bad args") - .assert_stderr("exit: too many arguments\n") - .assert_exit_code(2) - .run() - .await; -} - -#[tokio::test] -async fn command_substitution() { - TestBuilder::new() - .command("echo $(echo 1)") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("echo $(echo 1 && echo 2)") - .assert_stdout("1 2\n") - .run() - .await; - - // async inside subshell should wait - TestBuilder::new() - .command("$(sleep 0.1 && echo 1 & echo echo) 2") - .assert_stdout("1 2\n") - .run() - .await; - TestBuilder::new() - .command("$(sleep 0.1 && echo 1 && exit 5 &) ; echo 2") - .assert_stdout("2\n") - .assert_stderr("1: command not found\n") - .run() - .await; -} - -#[tokio::test] -async fn sequential_lists() { - TestBuilder::new() - .command(r#"echo 1 ; sleep 0.1 && echo 4 & echo 2 ; echo 3;"#) - .assert_stdout("1\n2\n3\n4\n") - .run() - .await; -} -#[tokio::test] -async fn pipeline() { - TestBuilder::new() - .command(r#"echo 1 | echo 2 && echo 3"#) - .assert_stdout("2\n3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 | tee output.txt"#) - .assert_stdout("1\n") - .assert_file_equals("output.txt", "1\n") - .run() - .await; -} - -#[tokio::test] -async fn redirects_input() { - TestBuilder::new() - .file("test.txt", "Hi!") - .command(r#"cat - < test.txt"#) - .assert_stdout("Hi!") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "Hi!\n") - .command(r#"cat - < test.txt && echo There"#) - .assert_stdout("Hi!\nThere\n") - .run() - .await; - - TestBuilder::new() - .command(r#"cat - <&0"#) - .assert_stderr( - "deno_task_shell: input redirecting file descriptors is not implemented\n", - ) - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn pwd() { - TestBuilder::new() - .directory("sub_dir") - .file("file.txt", "test") - .command("pwd && cd sub_dir && pwd && cd ../ && pwd") - // the actual temp directory will get replaced here - .assert_stdout(&format!( - "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" - )) - .run() - .await; - - TestBuilder::new() - .command("pwd -M") - .assert_stderr("pwd: unsupported flag: -M\n") - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn subshells() { - TestBuilder::new() - .command("(export TEST=1) && echo $TEST") - .assert_stdout("\n") - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .directory("sub_dir") - .command("echo $PWD && (cd sub_dir && echo $PWD) && echo $PWD") - .assert_stdout(&format!( - "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" - )) - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .command( - "export TEST=1 && (echo $TEST && unset TEST && echo $TEST) && echo $TEST", - ) - .assert_stdout("1\n\n1\n") - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .command("(exit 1) && echo 1") - .assert_exit_code(1) - .run() - .await; - TestBuilder::new() - .command("(exit 1) || echo 1") - .assert_stdout("1\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -#[cfg(unix)] -async fn pwd_logical() { - TestBuilder::new() - .directory("main") - .command("ln -s main symlinked_main && cd symlinked_main && pwd && pwd -L") - .assert_stdout("$TEMP_DIR/symlinked_main\n$TEMP_DIR/main\n") - .run() - .await; -} - -#[tokio::test] -async fn cat() { - // no args - TestBuilder::new() - .command("cat") - .stdin("hello") - .assert_stdout("hello") - .run() - .await; - - // dash - TestBuilder::new() - .command("cat -") - .stdin("hello") - .assert_stdout("hello") - .run() - .await; - - // file - TestBuilder::new() - .command("cat file") - .file("file", "test") - .assert_stdout("test") - .run() - .await; - - // multiple files - TestBuilder::new() - .command("cat file1 file2") - .file("file1", "test") - .file("file2", "other") - .assert_stdout("testother") - .run() - .await; - - // multiple files and stdin - TestBuilder::new() - .command("cat file1 file2 -") - .file("file1", "test\n") - .file("file2", "other\n") - .stdin("hello") - .assert_stdout("test\nother\nhello") - .run() - .await; - - // multiple files and stdin different order - TestBuilder::new() - .command("cat file1 - file2") - .file("file1", "test\n") - .file("file2", "other\n") - .stdin("hello\n") - .assert_stdout("test\nhello\nother\n") - .run() - .await; - - // file containing a command to evaluate - TestBuilder::new() - .command("$(cat file)") - .file("file", "echo hello") - .assert_stdout("hello\n") - .run() - .await; -} - -#[tokio::test] -async fn head() { - // no args - TestBuilder::new() - .command("head") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // dash - TestBuilder::new() - .command("head -") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // file - TestBuilder::new() - .command("head file") - .file( - "file", - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // dash + longer than internal buffer (512) - TestBuilder::new() - .command("head -") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" - .repeat(10) - .as_str(), - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // file + longer than internal buffer (512) - TestBuilder::new() - .command("head file") - .file( - "file", - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" - .repeat(1024) - .as_str(), - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // shorter than 10 lines - TestBuilder::new() - .command("head") - .stdin("foo\nbar") - .assert_stdout("foo\nbar") - .run() - .await; - - // -n - TestBuilder::new() - .command("head -n 2") - .stdin("foo\nbar\nbaz\nqux\nquuux") - .assert_stdout("foo\nbar\n") - .run() - .await; - - // --lines - TestBuilder::new() - .command("head --lines=3") - .stdin("foo\nbar\nbaz\nqux\nquuux") - .assert_stdout("foo\nbar\nbaz\n") - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn mv() { - // single file - TestBuilder::new() - .command("mv file1.txt file2.txt") - .file("file1.txt", "test") - .assert_not_exists("file1.txt") - .assert_exists("file2.txt") - .run() - .await; - - // multiple files to folder - TestBuilder::new() - .command("mkdir sub_dir && mv file1.txt file2.txt sub_dir") - .file("file1.txt", "test1") - .file("file2.txt", "test2") - .assert_not_exists("file1.txt") - .assert_not_exists("file2.txt") - .assert_exists("sub_dir/file1.txt") - .assert_exists("sub_dir/file2.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("mv file1.txt file2.txt") - .assert_exit_code(1) - .assert_stderr(&format!( - "mv: could not move file1.txt to file2.txt: {}\n", - no_such_file_error_text() - )) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn cp() { - // single file - TestBuilder::new() - .command("cp file1.txt file2.txt") - .file("file1.txt", "test") - .assert_exists("file1.txt") - .assert_exists("file2.txt") - .run() - .await; - - // multiple files to folder - TestBuilder::new() - .command("mkdir sub_dir && cp file1.txt file2.txt sub_dir") - .file("file1.txt", "test1") - .file("file2.txt", "test2") - .assert_exists("file1.txt") - .assert_exists("file2.txt") - .assert_exists("sub_dir/file1.txt") - .assert_exists("sub_dir/file2.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("cp file1.txt file2.txt") - .assert_exit_code(1) - .assert_stderr(&format!( - "cp: could not copy file1.txt to file2.txt: {}\n", - no_such_file_error_text() - )) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn mkdir() { - TestBuilder::new() - .command("mkdir sub_dir") - .assert_exists("sub_dir") - .run() - .await; - - // error message - TestBuilder::new() - .command("mkdir file.txt") - .file("file.txt", "test") - .assert_stderr("mkdir: cannot create directory 'file.txt': File exists\n") - .assert_exit_code(1) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn rm() { - TestBuilder::new() - .command("mkdir sub_dir && rm -d sub_dir && rm file.txt") - .file("file.txt", "") - .assert_not_exists("sub_dir") - .assert_not_exists("file.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("rm file.txt") - .assert_stderr(&format!( - "rm: cannot remove 'file.txt': {}\n", - no_such_file_error_text() - )) - .assert_exit_code(1) - .run() - .await; -} - -#[cfg(windows)] -#[tokio::test] -async fn windows_resolve_command() { - // not cross platform, but still allow this -} - -#[tokio::test] -async fn custom_command() { - // not cross platform, but still allow this - TestBuilder::new() - .command("add 1 2") - .custom_command( - "add", - Box::new(|mut context| { - async move { - let mut sum = 0; - for val in context.args { - sum += val.parse::().unwrap(); - } - let _ = context.stderr.write_line(&sum.to_string()); - ExecuteResult::from_exit_code(0) - } - .boxed_local() - }), - ) - .assert_stderr("3\n") - .run() - .await; -} - -#[tokio::test] -async fn glob_basic() { - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.txt") - .assert_stdout("test\ntest2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat test?.txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[0-9].txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[!a-z].txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[a-z].txt") - .assert_stdout("testa\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat */*.txt") - .assert_stdout("2\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat **/*.txt") - .assert_stdout("3\n2\n1\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat $PWD/**/*.txt") - .assert_stdout("3\n2\n1\n") - .run() - .await; - - TestBuilder::new() - .directory("dir") - .file("dir/1.txt", "1\n") - .file("dir_1.txt", "2\n") - .command("cat dir*1.txt") - .assert_stdout("2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_exit_code(1) - .run() - .await; - - let mut builder = TestBuilder::new(); - let temp_dir_path = builder.temp_dir_path(); - let error_pos = temp_dir_path.to_string_lossy().len() + 1; - builder.file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat [].ts") - .assert_stderr(&format!("glob: no matches found '$TEMP_DIR/[].ts'. Pattern syntax error near position {}: invalid range pattern\n", error_pos)) - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts || echo 2") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_stdout("2\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts 2> /dev/null || echo 2") - .assert_stderr("") - .assert_stdout("2\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .command("echo --inspect='[::0]:3366'") - .assert_stderr("") - .assert_stdout("--inspect=[::0]:3366\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -async fn glob_case_insensitive() { - TestBuilder::new() - .file("TEST.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat tes*.txt") - .assert_stdout("test\ntest2\ntesta\n") - .run() - .await; -} - -#[tokio::test] -async fn paren_escapes() { - TestBuilder::new() - .command(r"echo \( foo bar \)") - .assert_stdout("( foo bar )\n") - .run() - .await; -} - -fn no_such_file_error_text() -> &'static str { - if cfg!(windows) { - "The system cannot find the file specified. (os error 2)" - } else { - "No such file or directory (os error 2)" - } -} diff --git a/crates/deno_task_shell/src/shell/test_builder.rs b/crates/deno_task_shell/src/shell/test_builder.rs deleted file mode 100644 index 387cc26..0000000 --- a/crates/deno_task_shell/src/shell/test_builder.rs +++ /dev/null @@ -1,284 +0,0 @@ -// Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::Context; -use futures::future::LocalBoxFuture; -use pretty_assertions::assert_eq; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::rc::Rc; -use tokio::task::JoinHandle; - -use crate::execute_with_pipes; -use crate::parser::parse; -use crate::shell::fs_util; -use crate::shell::types::pipe; -use crate::shell::types::ShellPipeWriter; -use crate::shell::types::ShellState; -use crate::ShellCommand; -use crate::ShellCommandContext; - -use super::types::ExecuteResult; - -type FnShellCommandExecute = - Box LocalBoxFuture<'static, ExecuteResult>>; - -struct FnShellCommand(FnShellCommandExecute); - -impl ShellCommand for FnShellCommand { - fn execute( - &self, - context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - (self.0)(context) - } -} - -// Clippy is complaining about them all having `File` prefixes, -// but there might be non-file variants in the future. -#[allow(clippy::enum_variant_names)] -enum TestAssertion { - FileExists(String), - FileNotExists(String), - FileTextEquals(String, String), -} - -struct TempDir { - // hold to keep it alive until drop - _inner: tempfile::TempDir, - cwd: PathBuf, -} - -impl TempDir { - pub fn new() -> Self { - let temp_dir = tempfile::tempdir().unwrap(); - let cwd = fs_util::canonicalize_path(temp_dir.path()).unwrap(); - Self { - _inner: temp_dir, - cwd, - } - } -} - -pub struct TestBuilder { - // it is much much faster to lazily create this - temp_dir: Option, - env_vars: HashMap, - custom_commands: HashMap>, - command: String, - stdin: Vec, - expected_exit_code: i32, - expected_stderr: String, - expected_stdout: String, - assertions: Vec, -} - -impl TestBuilder { - pub fn new() -> Self { - let env_vars = std::env::vars() - .map(|(key, value)| { - // For some very strange reason, key will sometimes be cased as "Path" - // or other times "PATH" on Windows. Since keys are case-insensitive on - // Windows, normalize the keys to be upper case. - if cfg!(windows) { - // need to normalize on windows - (key.to_uppercase(), value) - } else { - (key, value) - } - }) - .collect(); - - Self { - temp_dir: None, - env_vars, - custom_commands: Default::default(), - command: Default::default(), - stdin: Default::default(), - expected_exit_code: 0, - expected_stderr: Default::default(), - expected_stdout: Default::default(), - assertions: Default::default(), - } - } - - pub fn ensure_temp_dir(&mut self) -> &mut Self { - self.get_temp_dir(); - self - } - - fn get_temp_dir(&mut self) -> &mut TempDir { - if self.temp_dir.is_none() { - self.temp_dir = Some(TempDir::new()); - } - self.temp_dir.as_mut().unwrap() - } - - pub fn temp_dir_path(&mut self) -> PathBuf { - self.get_temp_dir().cwd.clone() - } - - pub fn command(&mut self, command: &str) -> &mut Self { - self.command = command.to_string(); - self - } - - pub fn stdin(&mut self, stdin: &str) -> &mut Self { - self.stdin = stdin.as_bytes().to_vec(); - self - } - - pub fn directory(&mut self, path: &str) -> &mut Self { - let temp_dir = self.get_temp_dir(); - fs::create_dir_all(temp_dir.cwd.join(path)).unwrap(); - self - } - - pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { - self.env_vars.insert(name.to_string(), value.to_string()); - self - } - - pub fn custom_command( - &mut self, - name: &str, - execute: FnShellCommandExecute, - ) -> &mut Self { - self - .custom_commands - .insert(name.to_string(), Rc::new(FnShellCommand(execute))); - self - } - - pub fn file(&mut self, path: &str, text: &str) -> &mut Self { - let temp_dir = self.get_temp_dir(); - fs::write(temp_dir.cwd.join(path), text).unwrap(); - self - } - - pub fn assert_exit_code(&mut self, code: i32) -> &mut Self { - self.expected_exit_code = code; - self - } - - pub fn assert_stderr(&mut self, output: &str) -> &mut Self { - self.expected_stderr.push_str(output); - self - } - - pub fn assert_stdout(&mut self, output: &str) -> &mut Self { - self.expected_stdout.push_str(output); - self - } - - pub fn assert_exists(&mut self, path: &str) -> &mut Self { - self.ensure_temp_dir(); - self - .assertions - .push(TestAssertion::FileExists(path.to_string())); - self - } - - pub fn assert_not_exists(&mut self, path: &str) -> &mut Self { - self.ensure_temp_dir(); - self - .assertions - .push(TestAssertion::FileNotExists(path.to_string())); - self - } - - pub fn assert_file_equals( - &mut self, - path: &str, - file_text: &str, - ) -> &mut Self { - self.ensure_temp_dir(); - self.assertions.push(TestAssertion::FileTextEquals( - path.to_string(), - file_text.to_string(), - )); - self - } - - pub async fn run(&mut self) { - let list = parse(&self.command).unwrap(); - let cwd = if let Some(temp_dir) = &self.temp_dir { - temp_dir.cwd.clone() - } else { - std::env::temp_dir() - }; - let (stdin, mut stdin_writer) = pipe(); - stdin_writer.write_all(&self.stdin).unwrap(); - drop(stdin_writer); // prevent a deadlock by dropping the writer - let (stdout, stdout_handle) = get_output_writer_and_handle(); - let (stderr, stderr_handle) = get_output_writer_and_handle(); - - let local_set = tokio::task::LocalSet::new(); - let state = ShellState::new( - self.env_vars.clone(), - &cwd, - self.custom_commands.drain().collect(), - ); - let exit_code = local_set - .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) - .await; - let temp_dir = if let Some(temp_dir) = &self.temp_dir { - temp_dir.cwd.display().to_string() - } else { - "NO_TEMP_DIR".to_string() - }; - assert_eq!( - stderr_handle.await.unwrap(), - self.expected_stderr.replace("$TEMP_DIR", &temp_dir), - "\n\nFailed for: {}", - self.command - ); - assert_eq!( - stdout_handle.await.unwrap(), - self.expected_stdout.replace("$TEMP_DIR", &temp_dir), - "\n\nFailed for: {}", - self.command - ); - assert_eq!( - exit_code, self.expected_exit_code, - "\n\nFailed for: {}", - self.command - ); - - for assertion in &self.assertions { - match assertion { - TestAssertion::FileExists(path) => { - assert!( - cwd.join(path).exists(), - "\n\nFailed for: {}\nExpected '{}' to exist.", - self.command, - path, - ) - } - TestAssertion::FileNotExists(path) => { - assert!( - !cwd.join(path).exists(), - "\n\nFailed for: {}\nExpected '{}' to not exist.", - self.command, - path, - ) - } - TestAssertion::FileTextEquals(path, text) => { - let actual_text = std::fs::read_to_string(cwd.join(path)) - .with_context(|| format!("Error reading {path}")) - .unwrap(); - assert_eq!( - &actual_text, text, - "\n\nFailed for: {}\nPath: {}", - self.command, path, - ) - } - } - } - } -} - -fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { - let (reader, writer) = pipe(); - let handle = reader.pipe_to_string_handle(); - (writer, handle) -} diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 61413a7..61a97f6 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -12,6 +12,10 @@ readme.workspace = true default-run = "shell" publish = false +[lib] +name = "shell" +path = "src/lib.rs" + [[bin]] name = "shell" path = "src/main.rs" @@ -21,7 +25,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.87" clap = { version = "4.5.17", features = ["derive"] } -deno_task_shell = { path = "../deno_task_shell" } +deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } futures = "0.3.30" rustyline = { version = "14.0.0", features = ["derive"] } tokio = "1.40.0" @@ -29,7 +33,9 @@ uu_ls = "0.0.27" dirs = "5.0.1" which = "6.0.3" uu_uname = "0.0.27" +pretty_assertions = "1.0.0" +tempfile = "3.12.0" [package.metadata.release] # Dont publish the binary -release = false +release = false \ No newline at end of file diff --git a/crates/shell/src/commands/mod.rs b/crates/shell/src/commands/mod.rs index 5918a6e..810485b 100644 --- a/crates/shell/src/commands/mod.rs +++ b/crates/shell/src/commands/mod.rs @@ -1,4 +1,4 @@ -use std::{ffi::OsString, fs}; +use std::{collections::HashMap, ffi::OsString, fs, rc::Rc}; use deno_task_shell::{EnvChange, ExecuteResult, ShellCommand, ShellCommandContext}; use futures::{future::LocalBoxFuture, FutureExt}; @@ -21,6 +21,32 @@ pub struct UnAliasCommand; pub struct SourceCommand; +pub fn get_commands() -> HashMap> { + HashMap::from([ + ("ls".to_string(), Rc::new(LsCommand) as Rc), + ( + "alias".to_string(), + Rc::new(AliasCommand) as Rc, + ), + ( + "unalias".to_string(), + Rc::new(UnAliasCommand) as Rc, + ), + ( + "source".to_string(), + Rc::new(SourceCommand) as Rc, + ), + ( + "which".to_string(), + Rc::new(WhichCommand) as Rc, + ), + ( + "uname".to_string(), + Rc::new(UnameCommand) as Rc, + ), + ]) +} + impl ShellCommand for AliasCommand { fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { if context.args.len() != 1 { diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs new file mode 100644 index 0000000..bfdd005 --- /dev/null +++ b/crates/shell/src/lib.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod execute; diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index 6b5af5f..cb30a08 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; use std::path::PathBuf; -use std::rc::Rc; use anyhow::Context; use clap::Parser; use deno_task_shell::parser::debug_parse; -use deno_task_shell::{ShellCommand, ShellState}; +use deno_task_shell::ShellState; use rustyline::error::ReadlineError; use rustyline::{CompletionType, Config, Editor}; @@ -15,36 +13,6 @@ mod execute; mod helper; pub use execute::execute; - -fn commands() -> HashMap> { - HashMap::from([ - ( - "ls".to_string(), - Rc::new(commands::LsCommand) as Rc, - ), - ( - "alias".to_string(), - Rc::new(commands::AliasCommand) as Rc, - ), - ( - "unalias".to_string(), - Rc::new(commands::AliasCommand) as Rc, - ), - ( - "source".to_string(), - Rc::new(commands::SourceCommand) as Rc, - ), - ( - "which".to_string(), - Rc::new(commands::WhichCommand) as Rc, - ), - ( - "uname".to_string(), - Rc::new(commands::UnameCommand) as Rc, - ), - ]) -} - #[derive(Parser)] struct Options { /// The path to the file that should be executed @@ -57,7 +25,7 @@ struct Options { fn init_state() -> ShellState { let env_vars = std::env::vars().collect(); let cwd = std::env::current_dir().unwrap(); - ShellState::new(env_vars, &cwd, commands()) + ShellState::new(env_vars, &cwd, commands::get_commands()) } async fn interactive() -> anyhow::Result<()> { diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml new file mode 100644 index 0000000..1a0c0ef --- /dev/null +++ b/crates/tests/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tests" +version = "0.1.0" +edition = "2021" + +[dependencies] +deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } +shell = { path = "../shell" } +anyhow = "1.0.87" +futures = "0.3.30" +pretty_assertions = "1.0.0" +tempfile = "3.12.0" +tokio = { version = "1.40.0", features = ["full"] } \ No newline at end of file diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs new file mode 100644 index 0000000..02255e6 --- /dev/null +++ b/crates/tests/src/lib.rs @@ -0,0 +1,781 @@ +// Copyright 2018-2024 the Deno authors. MIT license. + +#[cfg(test)] +mod test_builder; +#[cfg(test)] +use deno_task_shell::ExecuteResult; +#[cfg(test)] +use futures::FutureExt; +#[cfg(test)] +use test_builder::TestBuilder; + +#[cfg(test)] +const FOLDER_SEPARATOR: char = if cfg!(windows) { '\\' } else { '/' }; + +#[tokio::test] +async fn commands() { + TestBuilder::new() + .command("echo 1") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 2 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2 3""#) + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r"echo 1 2\ \ \ 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2\ \ \ 3""#) + .assert_stdout("1 2\\ \\ \\ 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo test$(echo "1 2")"#) + .assert_stdout("test1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#"TEST="1 2" ; echo $TEST"#) + .assert_stdout("1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "1""#) + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "*""#) + .assert_stdout("*\n") + .run() + .await; + + TestBuilder::new() + .command("echo test-dashes") + .assert_stdout("test-dashes\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'/c") + .assert_stdout("a/b/c\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'ctest\"te st\"'asdf'") + .assert_stdout("a/bctestte stasdf\n") + .run() + .await; + + TestBuilder::new() + .command("echo --test=\"2\" --test='2' test\"TEST\" TEST'test'TEST 'test''test' test'test'\"test\" \"test\"\"test\"'test'") + .assert_stdout("--test=2 --test=2 testTEST TESTtestTEST testtest testtesttest testtesttest\n") + .run() + .await; + + TestBuilder::new() + .command("deno eval 'console.log(1)'") + .env_var("PATH", "") + .assert_stderr("deno: command not found\n") + .assert_exit_code(127) + .run() + .await; + + TestBuilder::new().command("unset").run().await; +} + +#[tokio::test] +async fn boolean_logic() { + TestBuilder::new() + .command("echo 1 && echo 2 || echo 3") + .assert_stdout("1\n2\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 || echo 2 && echo 3") + .assert_stdout("1\n3\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 || (echo 2 && echo 3)") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("false || false || (echo 2 && false) || echo 3") + .assert_stdout("2\n3\n") + .run() + .await; +} + +#[tokio::test] +async fn exit() { + TestBuilder::new() + .command("exit 1") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit 5") + .assert_exit_code(5) + .run() + .await; + + TestBuilder::new() + .command("exit 258 && echo 1") + .assert_exit_code(2) + .run() + .await; + + TestBuilder::new() + .command("(exit 0) && echo 1") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("(exit 1) && echo 1") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("echo 1 && (exit 1)") + .assert_stdout("1\n") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit ; echo 2") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit bad args") + .assert_stderr("exit: too many arguments\n") + .assert_exit_code(2) + .run() + .await; +} + +#[tokio::test] +async fn command_substitution() { + TestBuilder::new() + .command("echo $(echo 1)") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("echo $(echo 1 && echo 2)") + .assert_stdout("1 2\n") + .run() + .await; + + // async inside subshell should wait + TestBuilder::new() + .command("$(sleep 0.1 && echo 1 & echo echo) 2") + .assert_stdout("1 2\n") + .run() + .await; + TestBuilder::new() + .command("$(sleep 0.1 && echo 1 && exit 5 &) ; echo 2") + .assert_stdout("2\n") + .assert_stderr("1: command not found\n") + .run() + .await; +} + +#[tokio::test] +async fn sequential_lists() { + TestBuilder::new() + .command(r#"echo 1 ; sleep 0.1 && echo 4 & echo 2 ; echo 3;"#) + .assert_stdout("1\n2\n3\n4\n") + .run() + .await; +} +#[tokio::test] +async fn pipeline() { + TestBuilder::new() + .command(r#"echo 1 | echo 2 && echo 3"#) + .assert_stdout("2\n3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo 1 | tee output.txt"#) + .assert_stdout("1\n") + .assert_file_equals("output.txt", "1\n") + .run() + .await; +} + +#[tokio::test] +async fn redirects_input() { + TestBuilder::new() + .file("test.txt", "Hi!") + .command(r#"cat - < test.txt"#) + .assert_stdout("Hi!") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "Hi!\n") + .command(r#"cat - < test.txt && echo There"#) + .assert_stdout("Hi!\nThere\n") + .run() + .await; + + TestBuilder::new() + .command(r#"cat - <&0"#) + .assert_stderr("deno_task_shell: input redirecting file descriptors is not implemented\n") + .assert_exit_code(1) + .run() + .await; +} + +#[tokio::test] +async fn pwd() { + TestBuilder::new() + .directory("sub_dir") + .file("file.txt", "test") + .command("pwd && cd sub_dir && pwd && cd ../ && pwd") + // the actual temp directory will get replaced here + .assert_stdout(&format!( + "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" + )) + .run() + .await; + + TestBuilder::new() + .command("pwd -M") + .assert_stderr("pwd: unsupported flag: -M\n") + .assert_exit_code(1) + .run() + .await; +} + +#[tokio::test] +async fn subshells() { + TestBuilder::new() + .command("(export TEST=1) && echo $TEST") + .assert_stdout("\n") + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .directory("sub_dir") + .command("echo $PWD && (cd sub_dir && echo $PWD) && echo $PWD") + .assert_stdout(&format!( + "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" + )) + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .command("export TEST=1 && (echo $TEST && unset TEST && echo $TEST) && echo $TEST") + .assert_stdout("1\n\n1\n") + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .command("(exit 1) && echo 1") + .assert_exit_code(1) + .run() + .await; + TestBuilder::new() + .command("(exit 1) || echo 1") + .assert_stdout("1\n") + .assert_exit_code(0) + .run() + .await; +} + +#[tokio::test] +#[cfg(unix)] +async fn pwd_logical() { + TestBuilder::new() + .directory("main") + .command("ln -s main symlinked_main && cd symlinked_main && pwd && pwd -L") + .assert_stdout("$TEMP_DIR/symlinked_main\n$TEMP_DIR/main\n") + .run() + .await; +} + +#[tokio::test] +async fn cat() { + // no args + TestBuilder::new() + .command("cat") + .stdin("hello") + .assert_stdout("hello") + .run() + .await; + + // dash + TestBuilder::new() + .command("cat -") + .stdin("hello") + .assert_stdout("hello") + .run() + .await; + + // file + TestBuilder::new() + .command("cat file") + .file("file", "test") + .assert_stdout("test") + .run() + .await; + + // multiple files + TestBuilder::new() + .command("cat file1 file2") + .file("file1", "test") + .file("file2", "other") + .assert_stdout("testother") + .run() + .await; + + // multiple files and stdin + TestBuilder::new() + .command("cat file1 file2 -") + .file("file1", "test\n") + .file("file2", "other\n") + .stdin("hello") + .assert_stdout("test\nother\nhello") + .run() + .await; + + // multiple files and stdin different order + TestBuilder::new() + .command("cat file1 - file2") + .file("file1", "test\n") + .file("file2", "other\n") + .stdin("hello\n") + .assert_stdout("test\nhello\nother\n") + .run() + .await; + + // file containing a command to evaluate + TestBuilder::new() + .command("$(cat file)") + .file("file", "echo hello") + .assert_stdout("hello\n") + .run() + .await; +} + +#[tokio::test] +async fn head() { + // no args + TestBuilder::new() + .command("head") + .stdin("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n") + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // dash + TestBuilder::new() + .command("head -") + .stdin("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n") + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // file + TestBuilder::new() + .command("head file") + .file( + "file", + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // dash + longer than internal buffer (512) + TestBuilder::new() + .command("head -") + .stdin( + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" + .repeat(10) + .as_str(), + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // file + longer than internal buffer (512) + TestBuilder::new() + .command("head file") + .file( + "file", + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" + .repeat(1024) + .as_str(), + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // shorter than 10 lines + TestBuilder::new() + .command("head") + .stdin("foo\nbar") + .assert_stdout("foo\nbar") + .run() + .await; + + // -n + TestBuilder::new() + .command("head -n 2") + .stdin("foo\nbar\nbaz\nqux\nquuux") + .assert_stdout("foo\nbar\n") + .run() + .await; + + // --lines + TestBuilder::new() + .command("head --lines=3") + .stdin("foo\nbar\nbaz\nqux\nquuux") + .assert_stdout("foo\nbar\nbaz\n") + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn mv() { + // single file + TestBuilder::new() + .command("mv file1.txt file2.txt") + .file("file1.txt", "test") + .assert_not_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + // multiple files to folder + TestBuilder::new() + .command("mkdir sub_dir && mv file1.txt file2.txt sub_dir") + .file("file1.txt", "test1") + .file("file2.txt", "test2") + .assert_not_exists("file1.txt") + .assert_not_exists("file2.txt") + .assert_exists("sub_dir/file1.txt") + .assert_exists("sub_dir/file2.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("mv file1.txt file2.txt") + .assert_exit_code(1) + .assert_stderr(&format!( + "mv: could not move file1.txt to file2.txt: {}\n", + no_such_file_error_text() + )) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn cp() { + // single file + TestBuilder::new() + .command("cp file1.txt file2.txt") + .file("file1.txt", "test") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + // multiple files to folder + TestBuilder::new() + .command("mkdir sub_dir && cp file1.txt file2.txt sub_dir") + .file("file1.txt", "test1") + .file("file2.txt", "test2") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .assert_exists("sub_dir/file1.txt") + .assert_exists("sub_dir/file2.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("cp file1.txt file2.txt") + .assert_exit_code(1) + .assert_stderr(&format!( + "cp: could not copy file1.txt to file2.txt: {}\n", + no_such_file_error_text() + )) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn mkdir() { + TestBuilder::new() + .command("mkdir sub_dir") + .assert_exists("sub_dir") + .run() + .await; + + // error message + TestBuilder::new() + .command("mkdir file.txt") + .file("file.txt", "test") + .assert_stderr("mkdir: cannot create directory 'file.txt': File exists\n") + .assert_exit_code(1) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn rm() { + TestBuilder::new() + .command("mkdir sub_dir && rm -d sub_dir && rm file.txt") + .file("file.txt", "") + .assert_not_exists("sub_dir") + .assert_not_exists("file.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("rm file.txt") + .assert_stderr(&format!( + "rm: cannot remove 'file.txt': {}\n", + no_such_file_error_text() + )) + .assert_exit_code(1) + .run() + .await; +} + +#[cfg(windows)] +#[tokio::test] +async fn windows_resolve_command() { + // not cross platform, but still allow this +} + +#[tokio::test] +async fn custom_command() { + // not cross platform, but still allow this + TestBuilder::new() + .command("add 1 2") + .custom_command( + "add", + Box::new(|mut context| { + async move { + let mut sum = 0; + for val in context.args { + sum += val.parse::().unwrap(); + } + let _ = context.stderr.write_line(&sum.to_string()); + ExecuteResult::from_exit_code(0) + } + .boxed_local() + }), + ) + .assert_stderr("3\n") + .run() + .await; +} + +#[tokio::test] +async fn glob_basic() { + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.txt") + .assert_stdout("test\ntest2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat test?.txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[0-9].txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[!a-z].txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[a-z].txt") + .assert_stdout("testa\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat */*.txt") + .assert_stdout("2\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat **/*.txt") + .assert_stdout("3\n2\n1\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat $PWD/**/*.txt") + .assert_stdout("3\n2\n1\n") + .run() + .await; + + TestBuilder::new() + .directory("dir") + .file("dir/1.txt", "1\n") + .file("dir_1.txt", "2\n") + .command("cat dir*1.txt") + .assert_stdout("2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts") + .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") + .assert_exit_code(1) + .run() + .await; + + let mut builder = TestBuilder::new(); + let temp_dir_path = builder.temp_dir_path(); + let error_pos = temp_dir_path.to_string_lossy().len() + 1; + builder.file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat [].ts") + .assert_stderr(&format!("glob: no matches found '$TEMP_DIR/[].ts'. Pattern syntax error near position {}: invalid range pattern\n", error_pos)) + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts || echo 2") + .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") + .assert_stdout("2\n") + .assert_exit_code(0) + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts 2> /dev/null || echo 2") + .assert_stderr("") + .assert_stdout("2\n") + .assert_exit_code(0) + .run() + .await; + + TestBuilder::new() + .command("echo --inspect='[::0]:3366'") + .assert_stderr("") + .assert_stdout("--inspect=[::0]:3366\n") + .assert_exit_code(0) + .run() + .await; +} + +#[tokio::test] +async fn glob_case_insensitive() { + TestBuilder::new() + .file("TEST.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat tes*.txt") + .assert_stdout("test\ntest2\ntesta\n") + .run() + .await; +} + +#[tokio::test] +async fn paren_escapes() { + TestBuilder::new() + .command(r"echo \( foo bar \)") + .assert_stdout("( foo bar )\n") + .run() + .await; +} + +#[cfg(test)] +fn no_such_file_error_text() -> &'static str { + if cfg!(windows) { + "The system cannot find the file specified. (os error 2)" + } else { + "No such file or directory (os error 2)" + } +} diff --git a/crates/tests/src/test_builder.rs b/crates/tests/src/test_builder.rs new file mode 100644 index 0000000..5b6ce1d --- /dev/null +++ b/crates/tests/src/test_builder.rs @@ -0,0 +1,288 @@ +// Copyright 2018-2024 the Deno authors. MIT license. +use anyhow::Context; +use futures::future::LocalBoxFuture; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; +use tokio::task::JoinHandle; + +use deno_task_shell::execute_with_pipes; +use deno_task_shell::fs_util; +use deno_task_shell::parser::parse; +use deno_task_shell::pipe; +use deno_task_shell::ExecuteResult; +use deno_task_shell::ShellCommand; +use deno_task_shell::ShellCommandContext; +use deno_task_shell::ShellPipeWriter; +use deno_task_shell::ShellState; + +type FnShellCommandExecute = + Box LocalBoxFuture<'static, ExecuteResult>>; + +struct FnShellCommand(FnShellCommandExecute); + +impl ShellCommand for FnShellCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + (self.0)(context) + } +} + +// Clippy is complaining about them all having `File` prefixes, +// but there might be non-file variants in the future. +#[allow(clippy::enum_variant_names)] +enum TestAssertion { + FileExists(String), + FileNotExists(String), + FileTextEquals(String, String), +} + +struct TempDir { + // hold to keep it alive until drop + _inner: tempfile::TempDir, + cwd: PathBuf, +} + +impl TempDir { + pub fn new() -> Self { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = fs_util::canonicalize_path(temp_dir.path()).unwrap(); + Self { + _inner: temp_dir, + cwd, + } + } +} + +pub struct TestBuilder { + // it is much much faster to lazily create this + temp_dir: Option, + env_vars: HashMap, + custom_commands: HashMap>, + command: String, + stdin: Vec, + expected_exit_code: i32, + expected_stderr: String, + expected_stdout: String, + assertions: Vec, + assert_stdout: bool, + assert_stderr: bool, +} + +impl TestBuilder { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + let env_vars = std::env::vars() + .map(|(key, value)| { + // For some very strange reason, key will sometimes be cased as "Path" + // or other times "PATH" on Windows. Since keys are case-insensitive on + // Windows, normalize the keys to be upper case. + if cfg!(windows) { + // need to normalize on windows + (key.to_uppercase(), value) + } else { + (key, value) + } + }) + .collect(); + + Self { + temp_dir: None, + env_vars, + custom_commands: shell::commands::get_commands(), + command: Default::default(), + stdin: Default::default(), + expected_exit_code: 0, + expected_stderr: Default::default(), + expected_stdout: Default::default(), + assertions: Default::default(), + assert_stdout: true, + assert_stderr: true, + } + } + + pub fn ensure_temp_dir(&mut self) -> &mut Self { + self.get_temp_dir(); + self + } + + fn get_temp_dir(&mut self) -> &mut TempDir { + if self.temp_dir.is_none() { + self.temp_dir = Some(TempDir::new()); + } + self.temp_dir.as_mut().unwrap() + } + + pub fn temp_dir_path(&mut self) -> PathBuf { + self.get_temp_dir().cwd.clone() + } + + pub fn command(&mut self, command: &str) -> &mut Self { + self.command = command.to_string(); + self + } + + pub fn stdin(&mut self, stdin: &str) -> &mut Self { + self.stdin = stdin.as_bytes().to_vec(); + self + } + + pub fn directory(&mut self, path: &str) -> &mut Self { + let temp_dir = self.get_temp_dir(); + fs::create_dir_all(temp_dir.cwd.join(path)).unwrap(); + self + } + + pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { + self.env_vars.insert(name.to_string(), value.to_string()); + self + } + + pub fn custom_command(&mut self, name: &str, execute: FnShellCommandExecute) -> &mut Self { + self.custom_commands + .insert(name.to_string(), Rc::new(FnShellCommand(execute))); + self + } + + pub fn file(&mut self, path: &str, text: &str) -> &mut Self { + let temp_dir = self.get_temp_dir(); + fs::write(temp_dir.cwd.join(path), text).unwrap(); + self + } + + pub fn assert_exit_code(&mut self, code: i32) -> &mut Self { + self.expected_exit_code = code; + self + } + + pub fn assert_stderr(&mut self, output: &str) -> &mut Self { + self.expected_stderr.push_str(output); + self + } + + pub fn assert_stdout(&mut self, output: &str) -> &mut Self { + self.expected_stdout.push_str(output); + self + } + + pub fn check_stdout(&mut self, check_stdout: bool) -> &mut Self { + self.assert_stdout = check_stdout; + self + } + + pub fn check_stderr(&mut self, check_stderr: bool) -> &mut Self { + self.assert_stderr = check_stderr; + self + } + + pub fn assert_exists(&mut self, path: &str) -> &mut Self { + self.ensure_temp_dir(); + self.assertions + .push(TestAssertion::FileExists(path.to_string())); + self + } + + pub fn assert_not_exists(&mut self, path: &str) -> &mut Self { + self.ensure_temp_dir(); + self.assertions + .push(TestAssertion::FileNotExists(path.to_string())); + self + } + + pub fn assert_file_equals(&mut self, path: &str, file_text: &str) -> &mut Self { + self.ensure_temp_dir(); + self.assertions.push(TestAssertion::FileTextEquals( + path.to_string(), + file_text.to_string(), + )); + self + } + + pub async fn run(&mut self) { + let list = parse(&self.command).unwrap(); + let cwd = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.clone() + } else { + std::env::temp_dir() + }; + let (stdin, mut stdin_writer) = pipe(); + stdin_writer.write_all(&self.stdin).unwrap(); + drop(stdin_writer); // prevent a deadlock by dropping the writer + let (stdout, stdout_handle) = get_output_writer_and_handle(); + let (stderr, stderr_handle) = get_output_writer_and_handle(); + + let local_set = tokio::task::LocalSet::new(); + let state = ShellState::new( + self.env_vars.clone(), + &cwd, + self.custom_commands.drain().collect(), + ); + let exit_code = local_set + .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) + .await; + let temp_dir = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.display().to_string() + } else { + "NO_TEMP_DIR".to_string() + }; + if self.assert_stderr { + assert_eq!( + stderr_handle.await.unwrap(), + self.expected_stderr.replace("$TEMP_DIR", &temp_dir), + "\n\nFailed for: {}", + self.command + ); + } + if self.assert_stdout { + assert_eq!( + stdout_handle.await.unwrap(), + self.expected_stdout.replace("$TEMP_DIR", &temp_dir), + "\n\nFailed for: {}", + self.command + ); + } + assert_eq!( + exit_code, self.expected_exit_code, + "\n\nFailed for: {}", + self.command + ); + + for assertion in &self.assertions { + match assertion { + TestAssertion::FileExists(path) => { + assert!( + cwd.join(path).exists(), + "\n\nFailed for: {}\nExpected '{}' to exist.", + self.command, + path, + ) + } + TestAssertion::FileNotExists(path) => { + assert!( + !cwd.join(path).exists(), + "\n\nFailed for: {}\nExpected '{}' to not exist.", + self.command, + path, + ) + } + TestAssertion::FileTextEquals(path, text) => { + let actual_text = std::fs::read_to_string(cwd.join(path)) + .with_context(|| format!("Error reading {path}")) + .unwrap(); + assert_eq!( + &actual_text, text, + "\n\nFailed for: {}\nPath: {}", + self.command, path, + ) + } + } + } + } +} + +fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { + let (reader, writer) = pipe(); + let handle = reader.pipe_to_string_handle(); + (writer, handle) +} From d63532162543b0962c850bb76b1fe85b3413a694 Mon Sep 17 00:00:00 2001 From: prsabahrami Date: Mon, 16 Sep 2024 17:02:22 -0400 Subject: [PATCH 5/8] Added test for uname --- crates/tests/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 02255e6..ef13aa0 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -771,6 +771,23 @@ async fn paren_escapes() { .await; } +#[tokio::test] +async fn uname() { + TestBuilder::new() + .command("uname") + .assert_exit_code(0) + .check_stderr(false) + .check_stdout(false) + .run() + .await; + + TestBuilder::new() + .command("uname -a") + .assert_exit_code(0) + .run() + .await; +} + #[cfg(test)] fn no_such_file_error_text() -> &'static str { if cfg!(windows) { From aa15ab9366da194f28c48993fefd3fc09fb05a2a Mon Sep 17 00:00:00 2001 From: prsabahrami Date: Mon, 16 Sep 2024 21:13:17 -0400 Subject: [PATCH 6/8] Added test for which --- crates/tests/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index ef13aa0..6fc1f3a 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -784,6 +784,18 @@ async fn uname() { TestBuilder::new() .command("uname -a") .assert_exit_code(0) + .check_stdout(false) + .run() + .await; +} + + +#[tokio::test] +async fn which() { + TestBuilder::new() + .command("which ls") + .assert_exit_code(0) + .assert_stdout("\n") .run() .await; } From 2d70dc1a20a191a70e09cbaa418e0024d737ed0b Mon Sep 17 00:00:00 2001 From: prsabahrami Date: Mon, 16 Sep 2024 21:14:06 -0400 Subject: [PATCH 7/8] run fmt --- crates/tests/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 6fc1f3a..9f2d48a 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -789,7 +789,6 @@ async fn uname() { .await; } - #[tokio::test] async fn which() { TestBuilder::new() From db6ece013afad0c6d81b66733f985032db6a0c35 Mon Sep 17 00:00:00 2001 From: Wolf Vollprecht Date: Tue, 17 Sep 2024 11:31:39 +0200 Subject: [PATCH 8/8] review comments --- Cargo.lock | 30 ++++++++++++++---------------- crates/deno_task_shell/Cargo.toml | 4 ++-- crates/shell/Cargo.toml | 2 -- crates/tests/Cargo.toml | 4 +++- crates/tests/src/test_builder.rs | 7 ++++++- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c676da3..967d755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,9 +161,9 @@ checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cc" -version = "1.1.18" +version = "1.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" dependencies = [ "shlex", ] @@ -547,9 +547,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -925,9 +925,9 @@ dependencies = [ [[package]] name = "pretty_assertions" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", @@ -963,9 +963,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" dependencies = [ "bitflags", ] @@ -989,9 +989,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags", "errno", @@ -1098,9 +1098,7 @@ dependencies = [ "deno_task_shell", "dirs", "futures", - "pretty_assertions", "rustyline", - "tempfile", "tokio", "uu_ls", "uu_uname", @@ -1326,9 +1324,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -1725,6 +1723,6 @@ dependencies = [ [[package]] name = "yansi" -version = "0.5.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/crates/deno_task_shell/Cargo.toml b/crates/deno_task_shell/Cargo.toml index 5fbe1e2..9a19ff3 100644 --- a/crates/deno_task_shell/Cargo.toml +++ b/crates/deno_task_shell/Cargo.toml @@ -29,9 +29,9 @@ pest_derive = "2.7.12" dirs = "5.0.1" pest_ascii_tree = { git = "https://github.com/prsabahrami/pest_ascii_tree.git", branch = "master" } miette = "7.2.0" -tempfile = "3.12.0" -pretty_assertions = "1.0.0" [dev-dependencies] +tempfile = "3.12.0" parking_lot = "0.12.3" serde_json = "1.0.128" +pretty_assertions = "1.0.0" diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 61a97f6..78797fe 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -33,8 +33,6 @@ uu_ls = "0.0.27" dirs = "5.0.1" which = "6.0.3" uu_uname = "0.0.27" -pretty_assertions = "1.0.0" -tempfile = "3.12.0" [package.metadata.release] # Dont publish the binary diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 1a0c0ef..6aa7577 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -8,6 +8,8 @@ deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } shell = { path = "../shell" } anyhow = "1.0.87" futures = "0.3.30" +tokio = { version = "1.40.0", features = ["full"] } + +[dev-dependencies] pretty_assertions = "1.0.0" tempfile = "3.12.0" -tokio = { version = "1.40.0", features = ["full"] } \ No newline at end of file diff --git a/crates/tests/src/test_builder.rs b/crates/tests/src/test_builder.rs index 5b6ce1d..4b1ad3c 100644 --- a/crates/tests/src/test_builder.rs +++ b/crates/tests/src/test_builder.rs @@ -70,8 +70,13 @@ pub struct TestBuilder { assert_stderr: bool, } +impl Default for TestBuilder { + fn default() -> Self { + Self::new() + } +} + impl TestBuilder { - #[allow(clippy::new_without_default)] pub fn new() -> Self { let env_vars = std::env::vars() .map(|(key, value)| {