From 9fe8e5da9addf7427c37e31f5e927eef2cd1e276 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 13:14:32 -0800 Subject: [PATCH 1/8] Fix updater downloads on GNU Linux and run the test in CI --- src/update.rs | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/update.rs b/src/update.rs index bf8b05a3..058c7a43 100644 --- a/src/update.rs +++ b/src/update.rs @@ -183,7 +183,7 @@ pub fn is_under_homebrew() -> Result { } /// Takes a version string and returns the URL to download the latest release. -fn get_exe_download_url(version: &str) -> String { +fn get_exe_download_url(version: &str, target: &str) -> String { // Make sure the version starts with a v. let version = if !version.starts_with('v') { format!("v{version}") @@ -191,7 +191,8 @@ fn get_exe_download_url(version: &str) -> String { version.to_string() }; - let target = crate::built_info::TARGET; + // Linux releases use musl so they also run on GNU systems. + let target = target.replace("-unknown-linux-gnu", "-unknown-linux-musl"); format!("https://github.com/KittyCAD/cli/releases/download/{version}/zoo-{target}") } @@ -201,14 +202,14 @@ pub async fn download_binary_to_temp_file(version: &str) -> Result { let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("zoo"); - let url = get_exe_download_url(version); + let url = get_exe_download_url(version, crate::built_info::TARGET); // Get the contents of the binary. - let resp = reqwest::get(&url).await?; + let resp = reqwest::get(&url).await?.error_for_status()?; let bin_body = resp.bytes().await?; // Get the contents of the sha256sum. - let resp = reqwest::get(&format!("{url}.sha256")).await?; + let resp = reqwest::get(&format!("{url}.sha256")).await?.error_for_status()?; let sha256_body = resp.text().await?; let sha256_parts = sha256_body.split(' ').collect::>(); let sha256_hash = sha256_parts[0]; @@ -264,10 +265,6 @@ mod test { #[tokio::test] async fn test_download_binary_to_temp_file() { - if crate::update::is_ci() { - return; - } - let file = super::download_binary_to_temp_file("v0.2.42").await.unwrap(); let temp_base = std::env::temp_dir().to_str().unwrap().trim_end_matches('/').to_string(); @@ -276,18 +273,22 @@ mod test { #[test] fn test_get_exe_download_url() { - let url = super::get_exe_download_url("0.1.0"); - let target = crate::built_info::TARGET; - assert_eq!( - url, - format!("https://github.com/KittyCAD/cli/releases/download/v0.1.0/zoo-{target}") - ); - - let url = super::get_exe_download_url("v0.2.0"); - assert_eq!( - url, - format!("https://github.com/KittyCAD/cli/releases/download/v0.2.0/zoo-{target}") - ); + for (target, asset) in [ + ("x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"), + ("aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl"), + ("x86_64-unknown-linux-musl", "x86_64-unknown-linux-musl"), + ("aarch64-unknown-linux-musl", "aarch64-unknown-linux-musl"), + ("x86_64-apple-darwin", "x86_64-apple-darwin"), + ("aarch64-apple-darwin", "aarch64-apple-darwin"), + ("x86_64-pc-windows-gnu", "x86_64-pc-windows-gnu"), + ] { + for version in ["0.2.42", "v0.2.42"] { + assert_eq!( + super::get_exe_download_url(version, target), + format!("https://github.com/KittyCAD/cli/releases/download/v0.2.42/zoo-{asset}") + ); + } + } } #[tokio::test] From a738909a83510ecc6714132b09f5d1abca653294 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 13:21:44 -0800 Subject: [PATCH 2/8] Refresh the KCL gear mass test expectation --- src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests.rs b/src/tests.rs index 3784d360..bfebe0de 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -845,7 +845,7 @@ cli_tests! { ], ) .setup(setup_authenticated) - .stdout_contains("1267.973") + .stdout_contains("1308.435") } get_the_mass_of_a_kcl_file_but_use_project_toml(_ctx) => { From e6733d452b868d5ecaaa149b976d10c6b296bb24 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 13:28:41 -0800 Subject: [PATCH 3/8] Fix unlabeled extrude arguments in the non-fatal error fixture --- tests/non_fatal_error.kcl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/non_fatal_error.kcl b/tests/non_fatal_error.kcl index 37d90b59..ea4343e3 100644 --- a/tests/non_fatal_error.kcl +++ b/tests/non_fatal_error.kcl @@ -82,7 +82,7 @@ hangerSideSketch = sketch(on = XZ) { } hangerRegion = region(point = [20mm, 50mm], sketch = hangerSideSketch) -hangerBlank = extrude(sketches = hangerRegion, length = hangerWidth, symmetric = true) +hangerBlank = extrude(hangerRegion, length = hangerWidth, symmetric = true) hide(hangerSideSketch) screwSeatPadSketch = sketch(on = YZ) { @@ -104,7 +104,7 @@ screwSeatPadSketch = sketch(on = YZ) { } screwSeatPadRegion = region(point = screwSeatPadSketch.padCircle.center, sketch = screwSeatPadSketch) -screwSeatPadRaw = extrude(sketches = screwSeatPadRegion, length = screwSeatPadThickness, symmetric = true) +screwSeatPadRaw = extrude(screwSeatPadRegion, length = screwSeatPadThickness, symmetric = true) screwSeatPad = translate(screwSeatPadRaw, x = plateThickness + screwSeatPadThickness / 2 - screwSeatPadOverlap, global = true) hide(screwSeatPadSketch) @@ -138,7 +138,7 @@ screwClearanceSketch = sketch(on = YZ) { } screwClearanceRegion = region(point = screwClearanceSketch.screwClearance.center, sketch = screwClearanceSketch) -screwClearanceCutter = extrude(sketches = screwClearanceRegion, length = plateThickness + screwSeatPadThickness + 8mm, symmetric = true) +screwClearanceCutter = extrude(screwClearanceRegion, length = plateThickness + screwSeatPadThickness + 8mm, symmetric = true) hide(screwClearanceSketch) screwSeatSketch = sketch(on = YZ) { @@ -163,7 +163,7 @@ screwSeatSketch = sketch(on = YZ) { } screwSeatRegion = region(point = screwSeatSketch.screwSeat.center, sketch = screwSeatSketch) -screwSeatCutterRaw = extrude(sketches = screwSeatRegion, length = screwSeatPadThickness + screwSeatDepth + 2mm, symmetric = true) +screwSeatCutterRaw = extrude(screwSeatRegion, length = screwSeatPadThickness + screwSeatDepth + 2mm, symmetric = true) screwSeatCutter = translate(screwSeatCutterRaw, x = plateThickness + screwSeatPadThickness / 2, global = true) hide(screwSeatSketch) From 31b5df314f4c122f89e9c61d06412f6ca0553318 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 13:40:51 -0800 Subject: [PATCH 4/8] Revert "Fix unlabeled extrude arguments in the non-fatal error fixture" This reverts commit e6733d452b868d5ecaaa149b976d10c6b296bb24. --- tests/non_fatal_error.kcl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/non_fatal_error.kcl b/tests/non_fatal_error.kcl index ea4343e3..37d90b59 100644 --- a/tests/non_fatal_error.kcl +++ b/tests/non_fatal_error.kcl @@ -82,7 +82,7 @@ hangerSideSketch = sketch(on = XZ) { } hangerRegion = region(point = [20mm, 50mm], sketch = hangerSideSketch) -hangerBlank = extrude(hangerRegion, length = hangerWidth, symmetric = true) +hangerBlank = extrude(sketches = hangerRegion, length = hangerWidth, symmetric = true) hide(hangerSideSketch) screwSeatPadSketch = sketch(on = YZ) { @@ -104,7 +104,7 @@ screwSeatPadSketch = sketch(on = YZ) { } screwSeatPadRegion = region(point = screwSeatPadSketch.padCircle.center, sketch = screwSeatPadSketch) -screwSeatPadRaw = extrude(screwSeatPadRegion, length = screwSeatPadThickness, symmetric = true) +screwSeatPadRaw = extrude(sketches = screwSeatPadRegion, length = screwSeatPadThickness, symmetric = true) screwSeatPad = translate(screwSeatPadRaw, x = plateThickness + screwSeatPadThickness / 2 - screwSeatPadOverlap, global = true) hide(screwSeatPadSketch) @@ -138,7 +138,7 @@ screwClearanceSketch = sketch(on = YZ) { } screwClearanceRegion = region(point = screwClearanceSketch.screwClearance.center, sketch = screwClearanceSketch) -screwClearanceCutter = extrude(screwClearanceRegion, length = plateThickness + screwSeatPadThickness + 8mm, symmetric = true) +screwClearanceCutter = extrude(sketches = screwClearanceRegion, length = plateThickness + screwSeatPadThickness + 8mm, symmetric = true) hide(screwClearanceSketch) screwSeatSketch = sketch(on = YZ) { @@ -163,7 +163,7 @@ screwSeatSketch = sketch(on = YZ) { } screwSeatRegion = region(point = screwSeatSketch.screwSeat.center, sketch = screwSeatSketch) -screwSeatCutterRaw = extrude(screwSeatRegion, length = screwSeatPadThickness + screwSeatDepth + 2mm, symmetric = true) +screwSeatCutterRaw = extrude(sketches = screwSeatRegion, length = screwSeatPadThickness + screwSeatDepth + 2mm, symmetric = true) screwSeatCutter = translate(screwSeatCutterRaw, x = plateThickness + screwSeatPadThickness / 2, global = true) hide(screwSeatSketch) From bcd3d561f127a8b63adc52462183771507717b51 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 14:14:35 -0800 Subject: [PATCH 5/8] Use a simple cylinder for the mass CLI test --- src/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index bfebe0de..be0291d4 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -834,7 +834,7 @@ cli_tests! { "zoo", "kcl", "mass", - "tests/gear.kcl", + "tests/with-settings/cylinder.kcl", "--format=json", "--output-unit", "g", @@ -845,7 +845,7 @@ cli_tests! { ], ) .setup(setup_authenticated) - .stdout_contains("1308.435") + .stdout_contains("0.0125808") } get_the_mass_of_a_kcl_file_but_use_project_toml(_ctx) => { From c44d03547f046d5c83740096b6f1b1dda653bd32 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 14:30:11 -0800 Subject: [PATCH 6/8] Bound and retry KCL integration tests in CI --- .config/nextest.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index 4cadd647..0a89d185 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,3 +3,8 @@ fail-fast = false [profile.ci.junit] path = "../../../test-results/junit.xml" + +[[profile.ci.overrides]] +filter = 'test(_a_kcl_) | test(=tests::snapshot_via_engine) | binary(=kcl_process)' +retries = 2 +slow-timeout = { period = "60s", terminate-after = 5 } From 2ee517b188e0bb9781e93b3259eb1d8a2c0ac33b Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 14:59:05 -0800 Subject: [PATCH 7/8] Check mass output numerically with a measurement tolerance --- src/tests.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index be0291d4..6c27861b 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -100,6 +100,7 @@ struct TestItem { args: Vec, stdin: Option, want_out: String, + stdout_check: Option, want_err: String, want_code: i32, current_directory: Option, @@ -114,6 +115,7 @@ impl TestItem { args, stdin: None, want_out: String::new(), + stdout_check: None, want_err: String::new(), want_code: 0, current_directory: None, @@ -137,6 +139,11 @@ impl TestItem { self } + fn check_stdout(mut self, check: fn(&str)) -> Self { + self.stdout_check = Some(check); + self + } + fn stderr_contains(mut self, want_err: impl Into) -> Self { self.want_err = want_err.into(); self @@ -373,9 +380,8 @@ async fn run_test_item_with_config(config: &mut TestConfig, item: TestItem) { "test '{}': unexpected exit code\nactual stdout: {stdout}\nactual stderr: {stderr}", item.name ); - assert_eq!( - stdout.is_empty(), - item.want_out.is_empty(), + assert!( + item.stdout_check.is_some() || stdout.is_empty() == item.want_out.is_empty(), "test '{}': stdout mismatch\nactual stdout: {stdout}\nexpected stdout to contain: {}", item.name, item.want_out @@ -395,6 +401,9 @@ async fn run_test_item_with_config(config: &mut TestConfig, item: TestItem) { item.want_out ); } + if let Some(check) = item.stdout_check { + check(&stdout); + } if !item.want_err.is_empty() { assert!( stderr.contains(&item.want_err), @@ -845,7 +854,17 @@ cli_tests! { ], ) .setup(setup_authenticated) - .stdout_contains("0.0125808") + .check_stdout(|stdout| { + let mass: kittycad_modeling_cmds::output::Mass = + serde_json::from_str(stdout).expect("mass output must be JSON"); + let expected = 250.0 * std::f64::consts::PI * 453.59237 / 304.8_f64.powi(3); + assert_eq!(mass.output_unit, kittycad_modeling_cmds::units::UnitMass::Grams); + // Allow 0.2% error in the circular geometry's measured mass. + assert!( + (mass.mass - expected).abs() < expected * 0.002, + "mass: {mass:?}, expected about {expected} g" + ); + }) } get_the_mass_of_a_kcl_file_but_use_project_toml(_ctx) => { From 9d3981fcafeac9a7bf58d02a4ffc49110498f038 Mon Sep 17 00:00:00 2001 From: Jessie Frazelle Date: Thu, 5 Nov 2026 15:27:18 -0800 Subject: [PATCH 8/8] Use the cylinder fixture for remaining CLI smoke tests --- src/tests.rs | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 6c27861b..7035115d 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -787,21 +787,6 @@ cli_tests! { .set_env("ENGINE_EXECUTION", "1") } - 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", - ], - ) - .setup(setup_authenticated) - .stdout_contains("Snapshot from engine 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", @@ -916,7 +901,7 @@ cli_tests! { "zoo", "kcl", "analyze", - "tests/gear.kcl", + "tests/with-settings/cylinder.kcl", "--volume-output-unit", "cm3", "--mass-output-unit", @@ -1046,6 +1031,33 @@ cli_tests! { } } +#[test_context(MainContext)] +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +#[serial_test::serial] +async fn snapshot_a_kcl_file_with_a_project_toml_as_png(ctx: &mut MainContext) { + let output_dir = tempfile::tempdir().expect("failed to create snapshot output dir"); + let output = output_dir.path().join("snapshot.png"); + + run_test_item( + ctx, + TestItem::new( + "snapshot a kcl file with a project.toml as png", + svec![ + "zoo", + "kcl", + "snapshot", + "tests/with-settings/cylinder.kcl", + output.display(), + ], + ) + .setup(setup_authenticated) + .stdout_contains(format!("Snapshot from engine saved to `{}`", output.display())), + ) + .await; + + assert!(output.is_file(), "snapshot file was not created"); +} + #[test_context(MainContext)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[serial_test::serial]