diff --git a/src/context.rs b/src/context.rs index 0b45bf9c..a14be7cb 100644 --- a/src/context.rs +++ b/src/context.rs @@ -621,17 +621,25 @@ impl Context<'_> { data: code.as_bytes().to_vec(), }); - // Walk the directory and collect all the kcl files. - let parent = filepath.parent().ok_or_else(|| { + // Walk the containing directory and collect all the sibling kcl files. For a + // relative input like `gear.kcl`, `parent()` is `Some("")`, which needs to be + // treated as the current directory rather than an invalid path. + let project_root = filepath.parent().ok_or_else(|| { let filepath_display = filepath.display().to_string(); anyhow!("Could not get parent directory to: `{filepath_display}`") })?; - let walked_kcl = kcl_lib::walk_dir(&parent.to_path_buf()).await?; + let project_root = if project_root.as_os_str().is_empty() { + std::path::PathBuf::from(".") + } else { + project_root.to_path_buf() + }; + let walked_kcl = kcl_lib::walk_dir(&project_root).await?; + let canonical_filepath = std::fs::canonicalize(&filepath).unwrap_or_else(|_| filepath.clone()); // Get all the attachements async. let futures = walked_kcl .into_iter() - .filter(|file| *file != filepath) + .filter(|file| std::fs::canonicalize(file).unwrap_or_else(|_| file.clone()) != canonical_filepath) .map(|file| { tokio::spawn(async move { let path_display = file.display().to_string(); @@ -1122,6 +1130,38 @@ mod test { assert_eq!(h3, "http://foo:1234"); } + #[tokio::test] + #[serial_test::serial] + async fn collect_kcl_files_uses_current_directory_for_relative_file_inputs() { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + std::fs::write(tmp.path().join("gear.kcl"), "cube(1)\n").expect("write gear.kcl"); + + let old_current_directory = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(tmp.path()).expect("set current dir"); + + let mut config = crate::config::new_blank_config().unwrap(); + let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); + let (io, _stdout_path, _stderr_path) = crate::iostreams::IoStreams::test(); + let mut ctx = Context { + config: &mut c, + io, + debug: false, + override_host: None, + }; + + let (files, filepath) = ctx + .collect_kcl_files(std::path::Path::new("gear.kcl")) + .await + .expect("collect relative project files"); + + std::env::set_current_dir(old_current_directory).expect("restore current dir"); + + assert_eq!(filepath, std::path::PathBuf::from("gear.kcl")); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "gear.kcl"); + assert_eq!(files[0].filepath.as_deref(), Some(std::path::Path::new("gear.kcl"))); + } + #[test] fn test_format_reasoning_plain() { let lines = format_reasoning( diff --git a/src/tests.rs b/src/tests.rs index e40784e0..aee3abe5 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,22 +1,91 @@ -use pretty_assertions::assert_eq; +use std::path::{Path, PathBuf}; + +use anyhow::Result; use test_context::{test_context, AsyncTestContext}; -#[derive(Default, Debug, Clone, PartialEq, Eq)] -pub struct TestItem { - name: String, +use crate::config::Config; + +type SetupFn = fn(&mut TestConfig, &MainContext) -> Result<()>; + +macro_rules! svec { + ($($item:expr),* $(,)?) => { + vec![$($item.to_string()),*] + }; +} + +macro_rules! cli_tests { + ($($name:ident($ctx:ident) => $body:block)+) => { + $( + #[test_context(MainContext)] + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + #[serial_test::serial] + async fn $name($ctx: &mut MainContext) { + let test = $body; + run_test_item($ctx, test).await; + } + )+ + }; +} + +struct TestItem { + name: &'static str, args: Vec, stdin: Option, want_out: String, want_err: String, want_code: i32, - current_directory: Option, + current_directory: Option, + setup: Option, +} + +impl TestItem { + fn new(name: &'static str, args: Vec) -> Self { + Self { + name, + args, + stdin: None, + want_out: String::new(), + want_err: String::new(), + want_code: 0, + current_directory: None, + setup: None, + } + } + + fn stdin(mut self, stdin: impl Into) -> Self { + self.stdin = Some(stdin.into()); + self + } + + fn stdout_contains(mut self, want_out: impl Into) -> Self { + self.want_out = want_out.into(); + self + } + + fn stderr_contains(mut self, want_err: impl Into) -> Self { + self.want_err = want_err.into(); + self + } + + fn exit_code(mut self, want_code: i32) -> Self { + self.want_code = want_code; + self + } + + fn current_directory(mut self, current_directory: impl Into) -> Self { + self.current_directory = Some(current_directory.into()); + self + } + + fn setup(mut self, setup: SetupFn) -> Self { + self.setup = Some(setup); + self + } } struct MainContext { test_host: String, test_token: String, - #[allow(dead_code)] - client: kittycad::Client, } impl AsyncTestContext for MainContext { @@ -27,1208 +96,1006 @@ impl AsyncTestContext for MainContext { .to_string(); let test_token = std::env::var("ZOO_TEST_TOKEN").expect("ZOO_TEST_TOKEN is required"); - let mut zoo = kittycad::Client::new(&test_token); - if !test_host.is_empty() { - zoo.set_base_url(&test_host); + Self { test_host, test_token } + } + + async fn teardown(self) {} +} + +#[derive(Debug)] +struct TestConfig { + inner: crate::config_from_file::FileConfig, +} + +impl TestConfig { + fn new() -> Result { + let root = crate::config::new_blank_root()?; + Ok(Self { + inner: crate::config_from_file::FileConfig { + map: crate::config_map::ConfigMap { + root: root.as_table().clone(), + }, + }, + }) + } + + fn aliases_table(&self) -> Result { + match self.inner.map.find_entry("aliases") { + Ok(aliases) => match aliases.as_table() { + Some(table) => Ok(table.clone()), + None => anyhow::bail!("aliases is not a table"), + }, + Err(err) => { + if err.to_string().contains("not found") { + Ok(toml_edit::Table::new()) + } else { + anyhow::bail!("Error reading aliases table: {err}") + } + } } + } +} - Self { - test_host, - test_token, - client: zoo, +impl crate::config::Config for TestConfig { + fn get(&self, hostname: &str, key: &str) -> Result { + self.inner.get(hostname, key) + } + + fn get_with_source(&self, hostname: &str, key: &str) -> Result<(String, String)> { + self.inner.get_with_source(hostname, key) + } + + fn set(&mut self, hostname: &str, key: &str, value: Option<&str>) -> Result<()> { + self.inner.set(hostname, key, value) + } + + fn unset_host(&mut self, key: &str) -> Result<()> { + self.inner.unset_host(key) + } + + fn hosts(&self) -> Result> { + self.inner.hosts() + } + + fn default_host(&self) -> Result { + self.inner.default_host() + } + + fn default_host_with_source(&self) -> Result<(String, String)> { + self.inner.default_host_with_source() + } + + fn aliases(&mut self) -> Result> { + let aliases_table = self.aliases_table()?; + + Ok(crate::config_alias::AliasConfig { + map: crate::config_map::ConfigMap { root: aliases_table }, + parent: self, + }) + } + + fn save_aliases(&mut self, aliases: &crate::config_map::ConfigMap) -> Result<()> { + self.inner.save_aliases(aliases) + } + + fn expand_alias(&mut self, args: Vec) -> Result<(Vec, bool)> { + self.inner.expand_alias(args) + } + + fn check_writable(&self, hostname: &str, key: &str) -> Result<()> { + self.inner.check_writable(hostname, key) + } + + fn write(&self) -> Result<()> { + Ok(()) + } + + fn config_to_string(&self) -> Result { + self.inner.config_to_string() + } + + fn hosts_to_string(&self) -> Result { + self.inner.hosts_to_string() + } +} + +struct CurrentDirGuard { + original_directory: PathBuf, +} + +impl CurrentDirGuard { + fn change_to(path: Option<&Path>) -> Result { + let original_directory = std::env::current_dir()?; + if let Some(path) = path { + std::env::set_current_dir(path)?; } + + Ok(Self { original_directory }) } +} - async fn teardown(self) {} +impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original_directory); + } } -#[test_context(MainContext)] -#[tokio::test(flavor = "multi_thread", worker_threads = 3)] -#[serial_test::serial] -async fn test_main(ctx: &mut MainContext) { - let version = clap::crate_version!(); - - let mut tests: Vec = vec![ - TestItem { - name: "existing command".to_string(), - args: vec!["zoo".to_string(), "completion".to_string()], - want_out: "complete -F _zoo -o nosort -o bashdefault -o default zoo\n".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "existing command with args".to_string(), - args: vec![ - "zoo".to_string(), - "completion".to_string(), - "-s".to_string(), - "zsh".to_string(), - ], - want_out: "_zoo \"$@\"\n".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - // ML: text-to-cad export streams reasoning to stderr by default. - TestItem { - name: "ml text-to-cad export reasoning on".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "text-to-cad".to_string(), - "export".to_string(), - "-t".to_string(), - "obj".to_string(), - "--output-dir".to_string(), - "/tmp".to_string(), - "A".to_string(), - "2x4".to_string(), - "lego".to_string(), - "brick".to_string(), - ], - // Just assert completion appears in stdout table. - want_out: "Completed".to_string(), - // Look for explicit reasoning output label in stderr. - want_err: "reasoning:".to_string(), - want_code: 0, - ..Default::default() - }, - // ML: text-to-cad export does not stream when disabled. - TestItem { - name: "ml text-to-cad export no reasoning".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "text-to-cad".to_string(), - "export".to_string(), - "-t".to_string(), - "obj".to_string(), - "--output-dir".to_string(), - "/tmp".to_string(), - "--no-reasoning".to_string(), - "A".to_string(), - "2x4".to_string(), - "lego".to_string(), - "brick".to_string(), - ], - want_out: "Completed".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - // ML: kcl copilot should only start within a project directory containing main.kcl. - TestItem { - name: "ml kcl copilot requires main.kcl".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "copilot".to_string(), - ], - // No stdout expected; assert error message substring. - want_out: "".to_string(), - want_err: "does not contain a main.kcl file".to_string(), - want_code: 1, - ..Default::default() - }, - TestItem { - name: "add an alias".to_string(), - args: vec![ - "zoo".to_string(), - "alias".to_string(), - "set".to_string(), - "foo".to_string(), - "completion -s zsh".to_string(), +fn setup_authenticated(config: &mut TestConfig, ctx: &MainContext) -> Result<()> { + config.set(&ctx.test_host, "token", Some(&ctx.test_token))?; + config.set(&ctx.test_host, "default", Some("true"))?; + Ok(()) +} + +fn setup_alias_completion(config: &mut TestConfig, _ctx: &MainContext) -> Result<()> { + let mut aliases = config.aliases()?; + aliases.add("foo", "completion -s zsh")?; + Ok(()) +} + +fn setup_alias_shell(config: &mut TestConfig, _ctx: &MainContext) -> Result<()> { + let mut aliases = config.aliases()?; + aliases.add("bar", "!which bash")?; + Ok(()) +} + +fn setup_aliases(config: &mut TestConfig, ctx: &MainContext) -> Result<()> { + setup_alias_completion(config, ctx)?; + setup_alias_shell(config, ctx) +} + +fn make_single_file_edit_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + std::fs::copy("tests/gear.kcl", tmp.path().join("gear.kcl")).expect("copy gear.kcl"); + tmp +} + +fn make_multi_file_edit_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + std::fs::create_dir_all(tmp.path().join("subdir")).expect("create subdir"); + std::fs::write(tmp.path().join("main.kcl"), "// Glorious cube\n\nsideLength = 10\n").expect("write main.kcl"); + std::fs::write( + tmp.path().join("subdir/main.kcl"), + "// Glorious cylinder\n\nheight = 20\n", + ) + .expect("write subdir/main.kcl"); + tmp +} + +fn make_large_copilot_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("failed to create temp dir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main.kcl"); + for idx in 0..26 { + std::fs::write(tmp.path().join(format!("extra-{idx}.kcl")), "cube(1)\n").expect("write extra file"); + } + tmp +} + +async fn run_test_item(ctx: &mut MainContext, item: TestItem) { + let mut config = TestConfig::new().expect("failed to create blank test config"); + if let Some(setup) = item.setup { + setup(&mut config, ctx).unwrap_or_else(|err| panic!("setup for '{}' failed: {err}", item.name)); + } + + let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); + io.set_stdout_tty(false); + io.set_color_enabled(false); + if let Some(stdin) = item.stdin { + io.stdin = Box::new(std::io::Cursor::new(stdin)); + } + + let mut command_ctx = crate::context::Context { + config: &mut config, + io, + debug: false, + override_host: None, + }; + + let _cwd_guard = CurrentDirGuard::change_to(item.current_directory.as_deref()) + .unwrap_or_else(|err| panic!("failed to set cwd for '{}': {err}", item.name)); + + let result = crate::do_main(item.args, &mut command_ctx).await; + + let stdout = std::fs::read_to_string(stdout_path).unwrap_or_default(); + let stderr = std::fs::read_to_string(stderr_path).unwrap_or_default(); + + match result { + Ok(code) => { + assert_eq!( + code, item.want_code, + "test '{}': unexpected exit code\nactual stdout: {stdout}\nactual stderr: {stderr}", + item.name + ); + assert_eq!( + stdout.is_empty(), + item.want_out.is_empty(), + "test '{}': stdout mismatch\nactual stdout: {stdout}\nexpected stdout to contain: {}", + item.name, + item.want_out + ); + assert_eq!( + stderr.is_empty(), + item.want_err.is_empty(), + "test '{}': stderr mismatch\nactual stderr: {stderr}\nexpected stderr to contain: {}", + item.name, + item.want_err + ); + if !item.want_out.is_empty() { + assert!( + stdout.contains(&item.want_out), + "test '{}': stdout mismatch\nactual stdout: {stdout}\nexpected stdout to contain: {}\nactual stderr: {stderr}", + item.name, + item.want_out + ); + } + if !item.want_err.is_empty() { + assert!( + stderr.contains(&item.want_err), + "test '{}': stderr mismatch\nactual stderr: {stderr}\nexpected stderr to contain: {}\nactual stdout: {stdout}", + item.name, + item.want_err + ); + } + } + Err(err) => { + assert!( + !item.want_err.is_empty(), + "test '{}': actual error: {err}\ndid not expect any error", + item.name + ); + assert!( + err.to_string().contains(&item.want_err), + "test '{}': actual error: {err}\nexpected error to contain: {}", + item.name, + item.want_err + ); + assert!( + stderr.is_empty(), + "test '{}': stderr should have been empty, but it was {stderr}", + item.name + ); + } + } +} + +cli_tests! { + existing_command(_ctx) => { + TestItem::new("existing command", svec!["zoo", "completion"]) + .stdout_contains("complete -F _zoo -o nosort -o bashdefault -o default zoo\n") + } + + existing_command_with_args(_ctx) => { + TestItem::new("existing command with args", svec!["zoo", "completion", "-s", "zsh"]) + .stdout_contains("_zoo \"$@\"\n") + } + + ml_text_to_cad_export_reasoning_on(_ctx) => { + TestItem::new( + "ml text-to-cad export reasoning on", + svec![ + "zoo", + "ml", + "text-to-cad", + "export", + "-t", + "obj", + "--output-dir", + "/tmp", + "A", + "2x4", + "lego", + "brick", ], - want_out: "- Adding alias for foo: completion -s zsh\n✔ Added alias.".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "add a shell alias".to_string(), - args: vec![ - "zoo".to_string(), - "alias".to_string(), - "set".to_string(), - "-s".to_string(), - "bar".to_string(), - "which bash".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Completed") + .stderr_contains("reasoning:") + } + + ml_text_to_cad_export_no_reasoning(_ctx) => { + TestItem::new( + "ml text-to-cad export no reasoning", + svec![ + "zoo", + "ml", + "text-to-cad", + "export", + "-t", + "obj", + "--output-dir", + "/tmp", + "--no-reasoning", + "A", + "2x4", + "lego", + "brick", ], - want_out: "- Adding alias for bar: !which bash\n✔ Added alias.".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "list our aliases".to_string(), - args: vec!["zoo".to_string(), "alias".to_string(), "list".to_string()], - want_out: "\"completion -s zsh\"".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "call alias".to_string(), - args: vec!["zoo".to_string(), "foo".to_string()], - want_out: "_zoo \"$@\"\n".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "call alias with different binary name".to_string(), - args: vec!["/bin/thing/zoo".to_string(), "foo".to_string()], - want_out: "_zoo \"$@\"\n".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "call shell alias".to_string(), - args: vec!["zoo".to_string(), "bar".to_string()], - want_out: "/bash".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "version".to_string(), - args: vec!["zoo".to_string(), "version".to_string()], - want_out: format!( - "zoo {} ({})\n{}", - version, - git_rev::revision_string!(), - crate::cmd_version::changelog_url(version) - ), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "login".to_string(), - args: vec![ - "zoo".to_string(), - "--host".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Completed") + } + + ml_kcl_copilot_requires_main_kcl(_ctx) => { + TestItem::new( + "ml kcl copilot requires main.kcl", + svec!["zoo", "ml", "kcl", "copilot"], + ) + .stderr_contains("does not contain a main.kcl file") + .exit_code(1) + } + + add_an_alias(_ctx) => { + TestItem::new( + "add an alias", + svec!["zoo", "alias", "set", "foo", "completion -s zsh"], + ) + .stdout_contains("- Adding alias for foo: completion -s zsh\n✔ Added alias.") + } + + add_a_shell_alias(_ctx) => { + TestItem::new( + "add a shell alias", + svec!["zoo", "alias", "set", "-s", "bar", "which bash"], + ) + .stdout_contains("- Adding alias for bar: !which bash\n✔ Added alias.") + } + + list_our_aliases(_ctx) => { + TestItem::new("list our aliases", svec!["zoo", "alias", "list"]) + .setup(setup_aliases) + .stdout_contains("\"completion -s zsh\"") + } + + call_alias(_ctx) => { + TestItem::new("call alias", svec!["zoo", "foo"]) + .setup(setup_alias_completion) + .stdout_contains("_zoo \"$@\"\n") + } + + call_alias_with_different_binary_name(_ctx) => { + TestItem::new("call alias with different binary name", svec!["/bin/thing/zoo", "foo"]) + .setup(setup_alias_completion) + .stdout_contains("_zoo \"$@\"\n") + } + + call_shell_alias(_ctx) => { + TestItem::new("call shell alias", svec!["zoo", "bar"]) + .setup(setup_alias_shell) + .stdout_contains("/bash") + } + + version(_ctx) => { + let version = clap::crate_version!(); + TestItem::new("version", svec!["zoo", "version"]).stdout_contains(format!( + "zoo {} ({})\n{}", + version, + git_rev::revision_string!(), + crate::cmd_version::changelog_url(version) + )) + } + + login(ctx) => { + TestItem::new( + "login", + svec![ + "zoo", + "--host", ctx.test_host.clone(), - "auth".to_string(), - "login".to_string(), - "--with-token".to_string(), - ], - stdin: Some(ctx.test_token.clone()), - want_out: "✔ Logged in as ".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api /user".to_string(), - args: vec!["zoo".to_string(), "api".to_string(), "/user".to_string()], - want_out: r#""created_at": ""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api user (no leading /)".to_string(), - args: vec!["zoo".to_string(), "api".to_string(), "user".to_string()], - want_out: r#""created_at": ""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api user with header".to_string(), - args: vec![ - "zoo".to_string(), - "api".to_string(), - "user".to_string(), - "-H".to_string(), - "Origin: https://example.com".to_string(), + "auth", + "login", + "--with-token", ], - want_out: r#""created_at": ""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api user with headers".to_string(), - args: vec![ - "zoo".to_string(), - "api".to_string(), - "user".to_string(), - "-H".to_string(), - "Origin: https://example.com".to_string(), - "-H".to_string(), - "Another: thing".to_string(), - ], - want_out: r#""created_at": ""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api user with output headers".to_string(), - args: vec![ - "zoo".to_string(), - "api".to_string(), - "user".to_string(), - "--include".to_string(), - ], - want_out: r#"HTTP/2.0 200 OK"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "api endpoint does not exist".to_string(), - args: vec!["zoo".to_string(), "api".to_string(), "foo/bar".to_string()], - want_out: "".to_string(), - want_err: "404 Not Found Not Found".to_string(), - want_code: 1, - ..Default::default() - }, - TestItem { - name: "try to paginate over a post".to_string(), - args: vec![ - "zoo".to_string(), - "api".to_string(), - "organizations".to_string(), - "--method".to_string(), - "POST".to_string(), - "--paginate".to_string(), - ], - want_out: "".to_string(), - want_err: "the `--paginate` option is not supported for non-GET request".to_string(), - want_code: 1, - ..Default::default() - }, - TestItem { - name: "get your user".to_string(), - args: vec!["zoo".to_string(), "user".to_string(), "view".to_string()], - want_out: "name |".to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get your user as json".to_string(), - args: vec![ - "zoo".to_string(), - "user".to_string(), - "view".to_string(), - "--format=json".to_string(), - ], - want_out: r#""created_at": ""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "convert a file".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "convert".to_string(), - "assets/in_obj.obj".to_string(), - "/tmp/".to_string(), - "--output-format".to_string(), - "stl".to_string(), + ) + .stdin(ctx.test_token.clone()) + .stdout_contains("✔ Logged in as ") + } + + api_user_with_leading_slash(_ctx) => { + TestItem::new("api /user", svec!["zoo", "api", "/user"]) + .setup(setup_authenticated) + .stdout_contains(r#""created_at": ""#) + } + + api_user_without_leading_slash(_ctx) => { + TestItem::new("api user (no leading /)", svec!["zoo", "api", "user"]) + .setup(setup_authenticated) + .stdout_contains(r#""created_at": ""#) + } + + api_user_with_header(_ctx) => { + TestItem::new( + "api user with header", + svec!["zoo", "api", "user", "-H", "Origin: https://example.com"], + ) + .setup(setup_authenticated) + .stdout_contains(r#""created_at": ""#) + } + + api_user_with_headers(_ctx) => { + TestItem::new( + "api user with headers", + svec![ + "zoo", + "api", + "user", + "-H", + "Origin: https://example.com", + "-H", + "Another: thing", ], - want_out: r#"status | Completed"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file volume".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "volume".to_string(), - "assets/in_obj.obj".to_string(), - "--output-unit".to_string(), - "cm3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains(r#""created_at": ""#) + } + + api_user_with_output_headers(_ctx) => { + TestItem::new( + "api user with output headers", + svec!["zoo", "api", "user", "--include"], + ) + .setup(setup_authenticated) + .stdout_contains("HTTP/2.0 200 OK") + } + + api_endpoint_does_not_exist(_ctx) => { + TestItem::new( + "api endpoint does not exist", + svec!["zoo", "api", "foo/bar"], + ) + .setup(setup_authenticated) + .stderr_contains("404 Not Found Not Found") + .exit_code(1) + } + + try_to_paginate_over_a_post(_ctx) => { + TestItem::new( + "try to paginate over a post", + svec![ + "zoo", + "api", + "organizations", + "--method", + "POST", + "--paginate", ], - want_out: r#"volume | 0.05360"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file density".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "density".to_string(), - "assets/in_obj.obj".to_string(), - "--output-unit".to_string(), - "lb-ft3".to_string(), - "--material-mass-unit".to_string(), - "g".to_string(), - "--material-mass".to_string(), - "1.0".to_string(), + ) + .setup(setup_authenticated) + .stderr_contains("the `--paginate` option is not supported for non-GET request") + .exit_code(1) + } + + get_your_user(_ctx) => { + TestItem::new("get your user", svec!["zoo", "user", "view"]) + .setup(setup_authenticated) + .stdout_contains("name") + } + + get_your_user_as_json(_ctx) => { + TestItem::new( + "get your user as json", + svec!["zoo", "user", "view", "--format=json"], + ) + .setup(setup_authenticated) + .stdout_contains(r#""created_at": ""#) + } + + convert_a_file(_ctx) => { + TestItem::new( + "convert a file", + svec![ + "zoo", + "file", + "convert", + "assets/in_obj.obj", + "/tmp/", + "--output-format", + "stl", ], - want_out: r#"density | 1164.67"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file mass".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "mass".to_string(), - "assets/in_obj.obj".to_string(), - "--output-unit".to_string(), - "g".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Completed") + } + + get_the_file_volume(_ctx) => { + TestItem::new( + "get the file volume", + svec![ + "zoo", + "file", + "volume", + "assets/in_obj.obj", + "--output-unit", + "cm3", ], - want_out: r#"mass | 0.00085"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file surface-area".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "surface-area".to_string(), - "assets/in_obj.obj".to_string(), - "--output-unit".to_string(), - "cm2".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("0.05360") + } + + get_the_file_density(_ctx) => { + TestItem::new( + "get the file density", + svec![ + "zoo", + "file", + "density", + "assets/in_obj.obj", + "--output-unit", + "lb-ft3", + "--material-mass-unit", + "g", + "--material-mass", + "1.0", ], - want_out: r#"surface_area | 1.088"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file center-of-mass".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "center-of-mass".to_string(), - "assets/in_obj.obj".to_string(), - "--output-unit".to_string(), - "cm".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("1164.67") + } + + get_the_file_mass(_ctx) => { + TestItem::new( + "get the file mass", + svec![ + "zoo", + "file", + "mass", + "assets/in_obj.obj", + "--output-unit", + "g", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"center_of_mass | Point3D { x: -0.0133"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the file mass as json".to_string(), - args: vec![ - "zoo".to_string(), - "file".to_string(), - "mass".to_string(), - "assets/in_obj.obj".to_string(), - "--format=json".to_string(), - "--output-unit".to_string(), - "g".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("0.00085") + } + + get_the_file_surface_area(_ctx) => { + TestItem::new( + "get the file surface-area", + svec![ + "zoo", + "file", + "surface-area", + "assets/in_obj.obj", + "--output-unit", + "cm2", ], - want_out: r#""mass": 0.000858"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "snapshot a kcl file as png".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "snapshot".to_string(), - "tests/gear.kcl".to_string(), - "tests/gear.png".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("1.088") + } + + get_the_file_center_of_mass(_ctx) => { + TestItem::new( + "get the file center-of-mass", + svec![ + "zoo", + "file", + "center-of-mass", + "assets/in_obj.obj", + "--output-unit", + "cm", ], - want_out: r#"Snapshot saved to `tests/gear.png`"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "snapshot a kcl file with a project.toml as png".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "snapshot".to_string(), - "tests/with-settings/gear.kcl".to_string(), - "tests/with-settings/gear.png".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Point3D { x: -0.0133") + } + + get_the_file_mass_as_json(_ctx) => { + TestItem::new( + "get the file mass as json", + svec![ + "zoo", + "file", + "mass", + "assets/in_obj.obj", + "--format=json", + "--output-unit", + "g", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"Snapshot saved to `tests/with-settings/gear.png`"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "snapshot a kcl file with a nested project.toml as png".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "snapshot".to_string(), - "tests/nested-settings/subdir/gear.kcl".to_string(), - "tests/nested-settings/subdir/gear.png".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains(r#""mass": 0.000858"#) + } + + snapshot_a_kcl_file_as_png(_ctx) => { + TestItem::new( + "snapshot a kcl file as png", + svec!["zoo", "kcl", "snapshot", "tests/gear.kcl", "tests/gear.png"], + ) + .setup(setup_authenticated) + .stdout_contains("Snapshot saved to `tests/gear.png`") + } + + snapshot_a_kcl_file_with_a_project_toml_as_png(_ctx) => { + TestItem::new( + "snapshot a kcl file with a project.toml as png", + svec![ + "zoo", + "kcl", + "snapshot", + "tests/with-settings/gear.kcl", + "tests/with-settings/gear.png", ], - want_out: r#"Snapshot saved to `tests/nested-settings/subdir/gear.png`"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "snapshot a kcl assembly as png".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "snapshot".to_string(), - "tests/walkie-talkie".to_string(), - "tests/walkie-talkie.png".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Snapshot saved to `tests/with-settings/gear.png`") + } + + snapshot_a_kcl_file_with_a_nested_project_toml_as_png(_ctx) => { + TestItem::new( + "snapshot a kcl file with a nested project.toml as png", + svec![ + "zoo", + "kcl", + "snapshot", + "tests/nested-settings/subdir/gear.kcl", + "tests/nested-settings/subdir/gear.png", ], - want_out: r#"Snapshot saved to `tests/walkie-talkie.png`"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "snapshot a kcl assembly as png with .".to_string(), - current_directory: Some(std::env::current_dir().unwrap().join("tests/walkie-talkie")), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "snapshot".to_string(), - ".".to_string(), - "walkie-talkie.png".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("Snapshot saved to `tests/nested-settings/subdir/gear.png`") + } + + snapshot_a_kcl_assembly_as_png(_ctx) => { + TestItem::new( + "snapshot a kcl assembly as png", + svec!["zoo", "kcl", "snapshot", "tests/walkie-talkie", "tests/walkie-talkie.png"], + ) + .setup(setup_authenticated) + .stdout_contains("Snapshot saved to `tests/walkie-talkie.png`") + } + + snapshot_a_kcl_assembly_as_png_with_dot(_ctx) => { + TestItem::new( + "snapshot a kcl assembly as png with .", + svec!["zoo", "kcl", "snapshot", ".", "walkie-talkie.png"], + ) + .setup(setup_authenticated) + .current_directory(std::env::current_dir().unwrap().join("tests/walkie-talkie")) + .stdout_contains("Snapshot saved to `walkie-talkie.png`") + } + + get_the_mass_of_a_kcl_file(_ctx) => { + TestItem::new( + "get the mass of a kcl file", + svec![ + "zoo", + "kcl", + "mass", + "tests/gear.kcl", + "--format=json", + "--output-unit", + "g", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"Snapshot saved to `walkie-talkie.png`"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the mass of a kcl file".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "mass".to_string(), - "tests/gear.kcl".to_string(), - "--format=json".to_string(), - "--output-unit".to_string(), - "g".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("1268.234") + } + + get_the_mass_of_a_kcl_file_but_use_project_toml(_ctx) => { + TestItem::new( + "get the mass of a kcl file but use project.toml", + svec![ + "zoo", + "kcl", + "mass", + "tests/with-settings/gear.kcl", + "--format=json", + "--output-unit", + "g", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"1268.234"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the mass of a kcl file but use project.toml".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "mass".to_string(), - "tests/with-settings/gear.kcl".to_string(), - "--format=json".to_string(), - "--output-unit".to_string(), - "g".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("74.052") + } + + get_the_mass_of_a_kcl_file_with_nested_dirs_and_a_project_toml(_ctx) => { + TestItem::new( + "get the mass of a kcl file with nested dirs and a project.toml", + svec![ + "zoo", + "kcl", + "mass", + "tests/nested-settings/subdir/gear.kcl", + "--format=json", + "--output-unit", + "g", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"74.053"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "get the mass of a kcl file with nested dirs and a project.toml".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "mass".to_string(), - "tests/nested-settings/subdir/gear.kcl".to_string(), - "--format=json".to_string(), - "--output-unit".to_string(), - "g".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("74.052") + } + + analyze_a_kcl_file_as_table(_ctx) => { + TestItem::new( + "analyze a kcl file as table", + svec![ + "zoo", + "kcl", + "analyze", + "tests/gear.kcl", + "--volume-output-unit", + "cm3", + "--mass-output-unit", + "g", + "--surface-area-output-unit", + "cm2", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"74.053"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "analyze a kcl file as table".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "analyze".to_string(), - "tests/gear.kcl".to_string(), - "--volume-output-unit".to_string(), - "cm3".to_string(), - "--mass-output-unit".to_string(), - "g".to_string(), - "--surface-area-output-unit".to_string(), - "cm2".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("center_of_mass") + } + + analyze_a_kcl_file_as_json(_ctx) => { + TestItem::new( + "analyze a kcl file as json", + svec![ + "zoo", + "kcl", + "analyze", + "tests/gear.kcl", + "--format=json", + "--volume-output-unit", + "cm3", + "--mass-output-unit", + "g", + "--surface-area-output-unit", + "cm2", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#"center_of_mass"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "analyze a kcl file as json".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "analyze".to_string(), - "tests/gear.kcl".to_string(), - "--format=json".to_string(), - "--volume-output-unit".to_string(), - "cm3".to_string(), - "--mass-output-unit".to_string(), - "g".to_string(), - "--surface-area-output-unit".to_string(), - "cm2".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains(r#""center_of_mass""#) + } + + analyze_a_kcl_file_as_json_with_default_metric_units(_ctx) => { + TestItem::new( + "analyze a kcl file as json with default metric units", + svec![ + "zoo", + "kcl", + "analyze", + "tests/gear.kcl", + "--format=json", + "--material-density", + "1.0", ], - want_out: r#""center_of_mass""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "analyze a kcl file as json with default metric units".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "analyze".to_string(), - "tests/gear.kcl".to_string(), - "--format=json".to_string(), - "--material-density".to_string(), - "1.0".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains(r#""output_unit": "kg:m3""#) + } + + analyze_a_kcl_file_and_use_project_toml(_ctx) => { + TestItem::new( + "analyze a kcl file and use project.toml", + svec![ + "zoo", + "kcl", + "analyze", + "tests/with-settings/gear.kcl", + "--format=json", + "--volume-output-unit", + "cm3", + "--mass-output-unit", + "g", + "--surface-area-output-unit", + "cm2", + "--material-density", + "1.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#""output_unit": "kg:m3""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "analyze a kcl file and use project.toml".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "analyze".to_string(), - "tests/with-settings/gear.kcl".to_string(), - "--format=json".to_string(), - "--volume-output-unit".to_string(), - "cm3".to_string(), - "--mass-output-unit".to_string(), - "g".to_string(), - "--surface-area-output-unit".to_string(), - "cm2".to_string(), - "--material-density".to_string(), - "1.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains(r#""mass""#) + } + + analyze_a_kcl_file_with_invalid_density(_ctx) => { + TestItem::new( + "analyze a kcl file with invalid density", + svec![ + "zoo", + "kcl", + "analyze", + "tests/gear.kcl", + "--volume-output-unit", + "cm3", + "--mass-output-unit", + "g", + "--surface-area-output-unit", + "cm2", + "--material-density", + "0.0", + "--material-density-unit", + "lb-ft3", ], - want_out: r#""mass""#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - TestItem { - name: "analyze a kcl file with invalid density".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "analyze".to_string(), - "tests/gear.kcl".to_string(), - "--volume-output-unit".to_string(), - "cm3".to_string(), - "--mass-output-unit".to_string(), - "g".to_string(), - "--surface-area-output-unit".to_string(), - "cm2".to_string(), - "--material-density".to_string(), - "0.0".to_string(), - "--material-density-unit".to_string(), - "lb-ft3".to_string(), + ) + .setup(setup_authenticated) + .stderr_contains("`--material-density` must not be 0.0") + .exit_code(1) + } + + get_the_density_of_a_kcl_file(_ctx) => { + TestItem::new( + "get the density of a kcl file", + svec![ + "zoo", + "kcl", + "density", + "tests/gear.kcl", + "--output-unit", + "lb-ft3", + "--material-mass-unit", + "g", + "--material-mass", + "1.0", ], - want_out: r#""#.to_string(), - want_err: r#"`--material-density` must not be 0.0"#.to_string(), - want_code: 1, - ..Default::default() - }, - TestItem { - name: "get the density of a kcl file".to_string(), - args: vec![ - "zoo".to_string(), - "kcl".to_string(), - "density".to_string(), - "tests/gear.kcl".to_string(), - "--output-unit".to_string(), - "lb-ft3".to_string(), - "--material-mass-unit".to_string(), - "g".to_string(), - "--material-mass".to_string(), - "1.0".to_string(), + ) + .setup(setup_authenticated) + .stdout_contains("0.0007") + } +} + +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn ml_kcl_edit_reasoning_on(ctx: &mut MainContext) { + let tmp = make_single_file_edit_project(); + run_test_item( + ctx, + TestItem::new( + "ml kcl edit reasoning on", + svec!["zoo", "ml", "kcl", "edit", "gear.kcl", "Make", "it", "blue",], + ) + .setup(setup_authenticated) + .current_directory(tmp.path().to_path_buf()) + .stdout_contains("gear.kcl") + .stderr_contains("reasoning:"), + ) + .await; +} + +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn ml_kcl_edit_no_reasoning(ctx: &mut MainContext) { + let tmp = make_single_file_edit_project(); + run_test_item( + ctx, + TestItem::new( + "ml kcl edit no reasoning", + svec![ + "zoo", + "ml", + "kcl", + "edit", + "--no-reasoning", + "gear.kcl", + "Make", + "it", + "blue", ], - want_out: r#"0.0007"#.to_string(), - want_err: "".to_string(), - want_code: 0, - ..Default::default() - }, - // TestItem { - // name: "get the volume of a kcl file".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "volume".to_string(), - // "tests/gear.kcl".to_string(), - // "--output-unit".to_string(), - // "cm3".to_string(), - // ], - // want_out: r#"79173.2958833619"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "get the surface-area of a kcl file".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "surface-area".to_string(), - // "tests/gear.kcl".to_string(), - // "--output-unit".to_string(), - // "cm2".to_string(), - // ], - // want_out: r#"surface_area | 17351.484299764335"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "get the center-of-mass of a kcl file".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "center-of-mass".to_string(), - // "tests/gear.kcl".to_string(), - // "--output-unit".to_string(), - // "cm".to_string(), - // ], - // want_out: r#"center_of_mass | (-0.015537803061306477, 7.619970321655273, -0.00008108330803224817)"# - // .to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "export a kcl file as gltf".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "export".to_string(), - // "--output-format=gltf".to_string(), - // "tests/gear.kcl".to_string(), - // "tests/".to_string(), - // ], - // want_out: r#"Wrote file: tests/output.gltf"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "export a kcl file as step, deterministically".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "export".to_string(), - // "--output-format=step".to_string(), - // "--deterministic".to_string(), - // "tests/gear.kcl".to_string(), - // "tests/".to_string(), - // ], - // want_out: r#"Wrote file: tests/output.step"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "export a kcl file with a parse error".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "export".to_string(), - // "--output-format=gltf".to_string(), - // "tests/parse_error.kcl".to_string(), - // "tests/".to_string(), - // ], - // want_out: r#""#.to_string(), - // want_err: "syntax: Unexpected token".to_string(), - // want_code: 1, - // ..Default::default() - // }, - // TestItem { - // name: "format a kcl file".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "fmt".to_string(), - // "tests/gear.kcl".to_string(), - // ], - // want_out: r#"startSketchOn(XY)"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "format a directory".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "fmt".to_string(), - // "--write".to_string(), - // "tests/walkie-talkie".to_string(), - // ], - // want_out: r#"Formatted directory `tests/walkie-talkie`"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "lint some kcl".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "lint".to_string(), - // "tests/gear.kcl".to_string(), - // ], - // want_out: r#""#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "snapshot a gltf with embedded buffer".to_string(), - // args: vec![ - // "zoo".to_string(), - // "file".to_string(), - // "snapshot".to_string(), - // "tests/output-1.gltf".to_string(), - // "tests/output-1.png".to_string(), - // ], - // want_out: r#"Snapshot saved to `tests/output-1.png`"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "snapshot a gltf with external buffer".to_string(), - // args: vec![ - // "zoo".to_string(), - // "file".to_string(), - // "snapshot".to_string(), - // "tests/output-2.gltf".to_string(), - // "tests/output-2.png".to_string(), - // ], - // want_out: r#"Snapshot saved to `tests/output-2.png`"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "snapshot a text-to-cad prompt as png".to_string(), - // args: vec![ - // "zoo".to_string(), - // "ml".to_string(), - // "text-to-cad".to_string(), - // "snapshot".to_string(), - // "--output-dir".to_string(), - // "tests/".to_string(), - // "a".to_string(), - // "2x4".to_string(), - // "lego".to_string(), - // "brick".to_string(), - // ], - // want_out: r#"Snapshot saved to `"#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "export a text-to-cad prompt as obj".to_string(), - // args: vec![ - // "zoo".to_string(), - // "ml".to_string(), - // "text-to-cad".to_string(), - // "export".to_string(), - // "--output-format=obj".to_string(), - // "a".to_string(), - // "2x4".to_string(), - // "lego".to_string(), - // "brick".to_string(), - // ], - // want_out: r#"wrote file "#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "export a text-to-cad prompt as kcl".to_string(), - // args: vec![ - // "zoo".to_string(), - // "ml".to_string(), - // "text-to-cad".to_string(), - // "export".to_string(), - // "--output-format=kcl".to_string(), - // "a".to_string(), - // "2x6".to_string(), - // "mounting".to_string(), - // "plate".to_string(), - // ], - // want_out: r#"wrote file "#.to_string(), - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "edit a kcl file".to_string(), - // args: vec![ - // "zoo".to_string(), - // "ml".to_string(), "kcl".to_string(), - // "edit".to_string(), - // "tests/assembly-edit".to_string(), - // "make".to_string(), - // "it".to_string(), - // "blue".to_string(), - // ], - // want_out: r#"Wrote to tests/assembly-edit/main.kcl"#.to_string(), // Make sure it keeps - // // the path. - // want_err: "".to_string(), - // want_code: 0, - // ..Default::default() - // }, - // TestItem { - // name: "view a kcl file with multi-file errors".to_string(), - // args: vec![ - // "zoo".to_string(), - // "kcl".to_string(), - // "view".to_string(), - // "tests/parse_file_error".to_string(), - // ], - // want_out: r#""#.to_string(), - // want_err: "lksjndflsskjfnak;jfna## - // ·" - // .to_string(), - // want_code: 1, - // ..Default::default() - // }, - ]; - - // Add e2e tests for `ml kcl edit` using a temp project to avoid modifying repo files. - let mut temp_projects: Vec = Vec::new(); - let tmp = tempfile::tempdir().expect("failed to create temp dir"); - let tmp_path = tmp.path().to_path_buf(); - std::fs::copy("tests/gear.kcl", tmp_path.join("gear.kcl")).expect("copy gear.kcl"); - // Hold the dir open for the duration of the test run. - temp_projects.push(tmp); - - // Temp project for multi-file edit: root main.kcl and subdir/main.kcl. - let tmp_multi = tempfile::tempdir().expect("failed to create temp dir"); - let tmp_multi_path = tmp_multi.path().to_path_buf(); - std::fs::create_dir_all(tmp_multi_path.join("subdir")).expect("create subdir"); - std::fs::write(tmp_multi_path.join("main.kcl"), "// Glorious cube\n\nsideLength = 10\n").expect("write main.kcl"); - std::fs::write( - tmp_multi_path.join("subdir/main.kcl"), - "// Glorious cylinder\n\nheight = 20\n", + ) + .setup(setup_authenticated) + .current_directory(tmp.path().to_path_buf()) + .stdout_contains("gear.kcl"), ) - .expect("write subdir/main.kcl"); - temp_projects.push(tmp_multi); + .await; +} - // Temp project exceeding the Copilot size limit (> 25 entries). - let tmp_large = tempfile::tempdir().expect("failed to create temp dir"); - let tmp_large_path = tmp_large.path().to_path_buf(); - std::fs::write(tmp_large_path.join("main.kcl"), "cube(1)\n").expect("write main.kcl"); - for idx in 0..26 { - std::fs::write(tmp_large_path.join(format!("extra-{idx}.kcl")), "cube(1)\n").expect("write extra file"); - } - temp_projects.push(tmp_large); - - tests.push(TestItem { - name: "ml kcl edit reasoning on".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "edit".to_string(), - "gear.kcl".to_string(), - "Make".to_string(), - "it".to_string(), - "blue".to_string(), - ], - // Do not match on the "Wrote to" phrase; match on filename presence. - want_out: "gear.kcl".to_string(), - // Look for explicit reasoning output label in stderr. - want_err: "reasoning:".to_string(), - want_code: 0, - current_directory: Some(tmp_path.clone()), - ..Default::default() - }); - - tests.push(TestItem { - name: "ml kcl edit no reasoning".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "edit".to_string(), - "--no-reasoning".to_string(), - "gear.kcl".to_string(), - "Make".to_string(), - "it".to_string(), - "blue".to_string(), - ], - // Do not match on the "Wrote to" phrase; match on filename presence. - want_out: "gear.kcl".to_string(), - want_err: "".to_string(), - want_code: 0, - current_directory: Some(tmp_path.clone()), - ..Default::default() - }); - - // Multi-file project: ensure both root and subdir files are edited. - tests.push(TestItem { - name: "ml kcl edit multi-file (root)".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "edit".to_string(), - "--no-reasoning".to_string(), - ".".to_string(), - "Add".to_string(), - "a".to_string(), - "simple".to_string(), - "cube".to_string(), - "to".to_string(), - "main.kcl".to_string(), - "and".to_string(), - "a".to_string(), - "cylinder".to_string(), - "to".to_string(), - "subdir/main.kcl".to_string(), - ], - // Only assert presence of the root file path in stdout. - want_out: "main.kcl".to_string(), - want_err: "".to_string(), - want_code: 0, - current_directory: Some(tmp_multi_path.clone()), - ..Default::default() - }); - tests.push(TestItem { - name: "ml kcl edit multi-file (subdir)".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "edit".to_string(), - "--no-reasoning".to_string(), - ".".to_string(), - "Add".to_string(), - "a".to_string(), - "simple".to_string(), - "cube".to_string(), - "to".to_string(), - "main.kcl".to_string(), - "and".to_string(), - "a".to_string(), - "cylinder".to_string(), - "to".to_string(), - "subdir/main.kcl".to_string(), - ], - // Only assert presence of the subdir file path in stdout. - want_out: "subdir/main.kcl".to_string(), - want_err: "".to_string(), - want_code: 0, - current_directory: Some(tmp_multi_path.clone()), - ..Default::default() - }); - - tests.push(TestItem { - name: "ml kcl copilot rejects large project".to_string(), - args: vec![ - "zoo".to_string(), - "ml".to_string(), - "kcl".to_string(), - "copilot".to_string(), - ], - want_out: "".to_string(), - want_err: "Copilot needs a smaller project".to_string(), - want_code: 1, - current_directory: Some(tmp_large_path.clone()), - ..Default::default() - }); - - let mut config = crate::config::new_blank_config().unwrap(); - let mut c = crate::config_from_env::EnvConfig::inherit_env(&mut config); - - struct TestError { - name: String, - reason: String, - } - - let mut test_errors: Vec = Vec::new(); - let num_tests = tests.len(); - let mut num_tests_run = 0; - let test_filter = std::env::var("ZOO_CLI_TEST_NAME").ok(); - for t in tests { - if let Some(filter) = &test_filter { - if !t.name.contains(filter) { - continue; - } - } - num_tests_run += 1; - let (mut io, stdout_path, stderr_path) = crate::iostreams::IoStreams::test(); - io.set_stdout_tty(false); - io.set_color_enabled(false); - if let Some(stdin) = t.stdin { - io.stdin = Box::new(std::io::Cursor::new(stdin)); - } - let mut ctx = crate::context::Context { - config: &mut c, - io, - debug: false, - override_host: None, - }; - - let old_current_directory = std::env::current_dir().unwrap(); - if let Some(current_directory) = t.current_directory { - std::env::set_current_dir(¤t_directory).unwrap(); - } +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn ml_kcl_edit_multi_file_root(ctx: &mut MainContext) { + let tmp = make_multi_file_edit_project(); + run_test_item( + ctx, + TestItem::new( + "ml kcl edit multi-file (root)", + svec![ + "zoo", + "ml", + "kcl", + "edit", + "--no-reasoning", + ".", + "Add", + "a", + "simple", + "cube", + "to", + "main.kcl", + "and", + "a", + "cylinder", + "to", + "subdir/main.kcl", + ], + ) + .setup(setup_authenticated) + .current_directory(tmp.path().to_path_buf()) + .stdout_contains("main.kcl"), + ) + .await; +} - let result = crate::do_main(t.args, &mut ctx).await; - - let stdout = std::fs::read_to_string(stdout_path).unwrap_or_default(); - let stderr = std::fs::read_to_string(stderr_path).unwrap_or_default(); - - // Reset the cwd. - std::env::set_current_dir(old_current_directory).unwrap(); - - if !stdout.contains(&t.want_out) { - test_errors.push(TestError { - name: t.name.clone(), - reason: format!( - "Actual stdout: {}\nExpected stdout: {}\nActual stderror: {}", - stdout, t.want_out, stderr - ), - }); - continue; - }; - - match result { - Ok(code) => { - // assert_eq!(code, t.want_code, "test {}", t.name); - if code != t.want_code { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual error code: {code}\nExpected error code: {}", t.want_code), - }); - continue; - } - if stdout.is_empty() != t.want_out.is_empty() { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual stdout: {}\nExpected stdout: {}", stdout, t.want_out), - }); - continue; - } - if stderr.to_string().is_empty() != t.want_err.is_empty() { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual stderr: {}\nExpected stderr: {}", stderr, t.want_err), - }); - continue; - } - if !stderr.contains(&t.want_err) { - test_errors.push(TestError { - name: t.name, - reason: format!( - "Actual stderr: {}\nExpected stderr to contain: {}\nActual stdout: {}", - stderr, t.want_err, stdout - ), - }); - continue; - } - } - Err(err) => { - if t.want_err.is_empty() { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual error: {err}\nDid not expect any error"), - }); - continue; - } - if !err.to_string().contains(&t.want_err) { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual error: {}\nExpected error to contain: {}", err, t.want_err), - }); - continue; - } - if err.to_string().is_empty() != t.want_err.is_empty() { - test_errors.push(TestError { - name: t.name, - reason: format!("Actual error: {}\nExpected error to contain: {}", err, t.want_err), - }); - continue; - } - if !stderr.is_empty() { - test_errors.push(TestError { - name: t.name, - reason: format!("Stderr should have been empty, but it was {stderr}"), - }); - continue; - } - } - } - } +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn ml_kcl_edit_multi_file_subdir(ctx: &mut MainContext) { + let tmp = make_multi_file_edit_project(); + run_test_item( + ctx, + TestItem::new( + "ml kcl edit multi-file (subdir)", + svec![ + "zoo", + "ml", + "kcl", + "edit", + "--no-reasoning", + ".", + "Add", + "a", + "simple", + "cube", + "to", + "main.kcl", + "and", + "a", + "cylinder", + "to", + "subdir/main.kcl", + ], + ) + .setup(setup_authenticated) + .current_directory(tmp.path().to_path_buf()) + .stdout_contains("subdir/main.kcl"), + ) + .await; +} - let failed = test_errors.len(); - let passed = num_tests_run - failed; - let skipped = num_tests - num_tests_run; - assert_eq!(num_tests, failed + passed + skipped); - eprintln!("Failed {failed} tests, passed {passed}, skipped {skipped}"); - for test_error in test_errors { - eprintln!("==="); - eprintln!("Test '{}' failed:\n{}", test_error.name, test_error.reason); - } - eprintln!("Failed {failed} tests, passed {passed}, skipped {skipped}"); +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn ml_kcl_copilot_rejects_large_project(ctx: &mut MainContext) { + let tmp = make_large_copilot_project(); + run_test_item( + ctx, + TestItem::new( + "ml kcl copilot rejects large project", + svec!["zoo", "ml", "kcl", "copilot"], + ) + .setup(setup_authenticated) + .current_directory(tmp.path().to_path_buf()) + .stderr_contains("Copilot needs a smaller project") + .exit_code(1), + ) + .await; }